diff --git a/lib/teleterm/daemon/daemon.go b/lib/teleterm/daemon/daemon.go index 19724daceb6b3..13f12f4dfa253 100644 --- a/lib/teleterm/daemon/daemon.go +++ b/lib/teleterm/daemon/daemon.go @@ -1131,10 +1131,14 @@ func (s *Service) AuthenticateWebDevice(ctx context.Context, rootClusterURI uri. } devicesClient := proxyClient.CurrentCluster().DevicesClient() - ceremony := dtauthn.NewCeremony() - confirmationToken, err := ceremony.RunWeb(ctx, devicesClient, &devicepb.DeviceWebToken{ - Id: req.DeviceWebToken.Id, - Token: req.DeviceWebToken.Token, + var confirmationToken *devicepb.DeviceConfirmationToken + err = clusters.AddMetadataToRetryableError(ctx, func() error { + ceremony := dtauthn.NewCeremony() + confirmationToken, err = ceremony.RunWeb(ctx, devicesClient, &devicepb.DeviceWebToken{ + Id: req.DeviceWebToken.Id, + Token: req.DeviceWebToken.Token, + }) + return trace.Wrap(err) }) if err != nil { return nil, trace.Wrap(err) diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index 8696b36ee532d..8d0fa6999711d 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -101,7 +101,13 @@ export class MockTshClient implements TshdClient { getSuggestedAccessLists = () => new MockedUnaryCall({ accessLists: [] }); promoteAccessRequest = () => new MockedUnaryCall({}); updateTshdEventsServerAddress = () => new MockedUnaryCall({}); - authenticateWebDevice = () => new MockedUnaryCall({}); + authenticateWebDevice = () => + new MockedUnaryCall({ + confirmationToken: { + id: '123456789', + token: '7c8e7438-abe1-4cbc-b3e6-bd233bba967c', + }, + }); startHeadlessWatcher = () => new MockedUnaryCall({}); } diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx new file mode 100644 index 0000000000000..7b697092f7c62 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx @@ -0,0 +1,90 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { wait } from 'shared/utils/wait'; + +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { + makeRootCluster, + makeLoggedInUser, + rootClusterUri, +} from 'teleterm/services/tshd/testHelpers'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; +import * as types from 'teleterm/ui/services/workspacesService'; +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; + +import { DocumentAuthorizeWebSession } from './DocumentAuthorizeWebSession'; + +export default { + title: 'Teleterm/DocumentAuthorizeWebSession', +}; + +const doc: types.DocumentAuthorizeWebSession = { + uri: '/docs/e2hyt5', + rootClusterUri: rootClusterUri, + kind: 'doc.authorize_web_session', + title: 'Authorize Web Session', + webSessionRequest: { + redirectUri: '', + token: '', + id: '', + }, +}; + +export function DeviceNotTrusted() { + const rootCluster = makeRootCluster(); + const appContext = new MockAppContext(); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(rootCluster.uri, rootCluster); + }); + return ( + + + + + + ); +} + +export function DeviceTrusted() { + const rootCluster = makeRootCluster({ + loggedInUser: makeLoggedInUser({ isDeviceTrusted: true }), + }); + const appContext = new MockAppContext(); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(rootCluster.uri, rootCluster); + }); + appContext.clustersService.authenticateWebDevice = async () => { + await wait(2_000); + return new MockedUnaryCall({ + confirmationToken: { + id: '123456789', + token: '7c8e7438-abe1-4cbc-b3e6-bd233bba967c', + }, + }); + }; + + return ( + + + + + + ); +} diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.test.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.test.tsx new file mode 100644 index 0000000000000..c06ff109ff93c --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.test.tsx @@ -0,0 +1,105 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { render, screen } from 'design/utils/testing'; +import userEvent from '@testing-library/user-event'; + +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; +import { + makeRootCluster, + makeLoggedInUser, + rootClusterUri, +} from 'teleterm/services/tshd/testHelpers'; +import * as types from 'teleterm/ui/services/workspacesService'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; + +import { DocumentAuthorizeWebSession } from './DocumentAuthorizeWebSession'; + +const doc: types.DocumentAuthorizeWebSession = { + uri: '/docs/e2hyt5', + rootClusterUri: rootClusterUri, + kind: 'doc.authorize_web_session', + title: 'Authorize Web Session', + webSessionRequest: { + redirectUri: '', + token: '', + id: '', + }, +}; + +test('authorize button is disabled when device is not trusted', async () => { + const rootCluster = makeRootCluster({ + loggedInUser: makeLoggedInUser({ isDeviceTrusted: false }), + }); + const appContext = new MockAppContext(); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(rootCluster.uri, rootCluster); + }); + + render( + + + + + + ); + + expect(await screen.findByText(/This device is not trusted/)).toBeVisible(); + expect(await screen.findByText(/Authorize Session/)).toBeDisabled(); +}); + +test('authorizing a session opens its URL and closes document', async () => { + jest.spyOn(window, 'open').mockImplementation(); + const rootCluster = makeRootCluster({ + loggedInUser: makeLoggedInUser({ isDeviceTrusted: true }), + }); + const appContext = new MockAppContext(); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(rootCluster.uri, rootCluster); + }); + appContext.workspacesService.setState(draftState => { + draftState.workspaces[rootCluster.uri] = { + localClusterUri: rootCluster.uri, + documents: [doc], + location: undefined, + accessRequests: undefined, + }; + }); + + render( + + + + + + ); + + const button = await screen.findByText(/Authorize Session/); + await userEvent.click(button); + + expect(await screen.findByText(/Session Authorized/)).toBeVisible(); + expect(window.open).toHaveBeenCalledWith( + 'https://teleport-local:3080/webapi/devices/webconfirm?id=123456789&token=7c8e7438-abe1-4cbc-b3e6-bd233bba967c' + ); + expect( + appContext.workspacesService + .getWorkspaceDocumentService(rootCluster.uri) + .getDocuments() + ).toHaveLength(0); +}); diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx new file mode 100644 index 0000000000000..461a8de679ae4 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx @@ -0,0 +1,196 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Text, Alert, ButtonPrimary, H1, ButtonText } from 'design'; +import Flex from 'design/Flex'; +import { useAsync, Attempt } from 'shared/hooks/useAsync'; +import { processRedirectUri } from 'shared/redirects'; +import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; +import { DeviceConfirmationToken } from 'gen-proto-ts/teleport/devicetrust/v1/device_confirmation_token_pb'; + +import Document from 'teleterm/ui/Document'; +import { routing } from 'teleterm/ui/uri'; +import { retryWithRelogin } from 'teleterm/ui/utils'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import * as types from 'teleterm/ui/services/workspacesService'; +import { WebSessionRequest } from 'teleterm/ui/services/workspacesService'; +import { useWorkspaceContext } from 'teleterm/ui/Documents'; + +export function DocumentAuthorizeWebSession(props: { + doc: types.DocumentAuthorizeWebSession; + visible: boolean; +}) { + const ctx = useAppContext(); + const { documentsService } = useWorkspaceContext(); + const rootCluster = ctx.clustersService.findCluster(props.doc.rootClusterUri); + const [authorizeAttempt, authorize] = useAsync(async () => { + const { + response: { confirmationToken }, + } = await retryWithRelogin(ctx, props.doc.rootClusterUri, () => + ctx.clustersService.authenticateWebDevice( + props.doc.rootClusterUri, + props.doc.webSessionRequest + ) + ); + return confirmationToken; + }); + const clusterName = routing.parseClusterName(props.doc.rootClusterUri); + const canAuthorize = rootCluster.loggedInUser?.isDeviceTrusted; + + async function authorizeAndCloseDocument() { + const [confirmationToken, error] = await authorize(); + if (!error) { + const url = buildAuthorizedSessionUrl( + rootCluster, + props.doc.webSessionRequest, + confirmationToken + ); + // This endpoint verifies the token and "upgrades" the web session and redirects to "/web". + window.open(url); + closeAndNotify(); + } + } + + function openUnauthorizedAndCloseDocument() { + const url = buildUnauthorizedSessionUrl( + rootCluster, + props.doc.webSessionRequest + ); + window.open(url); + closeAndNotify(); + } + + function closeAndNotify() { + documentsService.close(props.doc.uri); + ctx.notificationsService.notifyInfo( + 'Web session has been opened in the browser' + ); + } + + return ( + + +

Authorize Web Session

+ + {/*It's technically possible to open a deep link to authorize a session on a device that is not enrolled.*/} + {!canAuthorize && ( + + To authorize a web session, you must first{' '} + + enroll your device + + . Then log out of Teleport Connect, log back in, and try + again. + + } + > + This device is not trusted + + )} + {authorizeAttempt.status === 'error' && ( + + Could not authorize the session + + )} + + Would you like to authorize a device trust web session for{' '} + {clusterName}? +
+ The session will automatically open in a new browser tab. +
+ + + {getButtonText(authorizeAttempt)} + + + Open Session Without Device Trust + + +
+
+
+ ); +} + +const confirmPath = 'webapi/devices/webconfirm'; + +function buildAuthorizedSessionUrl( + rootCluster: Cluster, + webSessionRequest: WebSessionRequest, + confirmationToken: DeviceConfirmationToken +): string { + const { redirectUri } = webSessionRequest; + + let url = `https://${rootCluster.proxyHost}/${confirmPath}?id=${confirmationToken.id}&token=${confirmationToken.token}`; + if (redirectUri) { + url = `${url}&redirect_uri=${redirectUri}`; + } + return url; +} + +function buildUnauthorizedSessionUrl( + rootCluster: Cluster, + webSessionRequest: WebSessionRequest +): string { + // processedRedirectUri is the path part of the redirectUri. + // Unlike in buildAuthorizedSessionUrl, here we return a full path to open + // instead of including redirection as the `redirect_uri` query parameter. + const processedRedirectUri = processRedirectUri( + webSessionRequest.redirectUri + ); + return `https://${rootCluster.proxyHost}${processedRedirectUri}`; +} + +function getButtonText(attempt: Attempt): string { + switch (attempt.status) { + case '': + case 'error': + return 'Authorize Session'; + case 'processing': + return 'Authorizing Session…'; + case 'success': + return 'Session Authorized'; + } +} diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.story.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/index.ts similarity index 52% rename from web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.story.tsx rename to web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/index.ts index b6f35a6e673f2..ffd92d5d17b86 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/index.ts @@ -1,6 +1,6 @@ /** * Teleport - * Copyright (C) 2023 Gravitational, Inc. + * Copyright (C) 2024 Gravitational, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -16,24 +16,4 @@ * along with this program. If not, see . */ -import React from 'react'; - -import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; -import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; - -import { AuthenticateWebDevice } from './AuthenticateWebDevice'; - -export default { - title: 'Teleterm/ModalsHost/AuthenticateWebDevice', -}; - -export const Dialog = () => ( - - {}} - onCancel={() => {}} - onAuthorize={async () => {}} - /> - -); +export * from './DocumentAuthorizeWebSession'; diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx index 0be864d2748f6..d1d300f0d8428 100644 --- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx +++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx @@ -41,6 +41,7 @@ import { } from 'teleterm/ui/ConnectMyComputer'; import { DocumentGatewayKube } from 'teleterm/ui/DocumentGatewayKube'; import { DocumentGatewayApp } from 'teleterm/ui/DocumentGatewayApp'; +import { DocumentAuthorizeWebSession } from 'teleterm/ui/DocumentAuthorizeWebSession'; import Document from 'teleterm/ui/Document'; import { RootClusterUri, isDatabaseUri, isAppUri } from 'teleterm/ui/uri'; @@ -153,6 +154,8 @@ function MemoizedDocument(props: { doc: types.Document; visible: boolean }) { return ; case 'doc.connect_my_computer': return ; + case 'doc.authorize_web_session': + return ; default: return ( diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index 3ccb6f6c164ad..ba276907d9037 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -32,7 +32,6 @@ import { assertUnreachable } from '../utils'; import { UsageData } from './modals/UsageData'; import { UserJobRole } from './modals/UserJobRole'; import { ReAuthenticate } from './modals/ReAuthenticate'; -import { AuthenticateWebDevice } from './modals/AuthenticateWebDevice/AuthenticateWebDevice'; import { ChangeAccessRequestKind } from './modals/ChangeAccessRequestKind'; import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys'; @@ -87,20 +86,6 @@ function renderDialog({ } switch (dialog.kind) { - case 'device-trust-authorize': { - return ( -