diff --git a/web/packages/shared/components/AuthorizeDeviceWeb/AuthorizeDeviceWeb.tsx b/web/packages/shared/components/AuthorizeDeviceWeb/AuthorizeDeviceWeb.tsx index ad5e866dbd901..9a46336ea0efb 100644 --- a/web/packages/shared/components/AuthorizeDeviceWeb/AuthorizeDeviceWeb.tsx +++ b/web/packages/shared/components/AuthorizeDeviceWeb/AuthorizeDeviceWeb.tsx @@ -56,11 +56,14 @@ export const PassthroughPage = () => { useEffect(() => { window.open(deviceTrustAuthorize); - // the deviceWebToken is only valid for 60 seconds, so we can forward + // the deviceWebToken is only valid for 5 minutes, so we can forward // to the dashboard - const id = window.setTimeout(() => { - history.push(cfg.routes.root, true); - }, 1000 * 60 /* 1 minute */); + const id = window.setTimeout( + () => { + history.push(cfg.routes.root, true); + }, + 1000 * 60 * 5 /* 5 minutes */ + ); return () => window.clearTimeout(id); }, [deviceTrustAuthorize]); diff --git a/web/packages/teleterm/src/deepLinks.test.ts b/web/packages/teleterm/src/deepLinks.test.ts index 90cfb1e17c9a5..43843be1454a1 100644 --- a/web/packages/teleterm/src/deepLinks.test.ts +++ b/web/packages/teleterm/src/deepLinks.test.ts @@ -213,26 +213,6 @@ describe('parseDeepLink followed by makeDeepLinkWithSafeInput gives the same res 'teleport://cluster.example.com/connect_my_computer', 'teleport://alice@cluster.example.com/connect_my_computer', 'teleport://alice.bobson%40example.com@cluster.example.com:1337/connect_my_computer', - ]; - - test.each(inputs)('%s', input => { - const parseResult = parseDeepLink(input); - expect(parseResult).toMatchObject({ status: 'success' }); - const { url } = parseResult as DeepLinkParseResultSuccess; - const deepLink = makeDeepLinkWithSafeInput({ - proxyHost: url.host, - path: url.pathname, - username: url.username, - searchParams: {}, - }); - expect(deepLink).toEqual(input); - }); -}); - -describe('"parseDeepLink followed by makeDeepLinkWithSafeInput gives the same result"', () => { - const inputs: string[] = [ - 'teleport://cluster.example.com/connect_my_computer', - 'teleport://alice@cluster.example.com/connect_my_computer', 'teleport://alice@cluster.example.com/authenticate_web_device?id=123&token=234', ]; diff --git a/web/packages/teleterm/src/deepLinks.ts b/web/packages/teleterm/src/deepLinks.ts index cc6b06a7243da..977514bc0949a 100644 --- a/web/packages/teleterm/src/deepLinks.ts +++ b/web/packages/teleterm/src/deepLinks.ts @@ -84,8 +84,6 @@ export function parseDeepLink(rawUrl: string): DeepLinkParseResult { }; } - // TODO (avatus): remove when authenticate web device case is implemented - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { host, hostname, port, username, pathname, searchParams } = whatwgURL; const baseUrl = { host, diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index 4937c8b1884b6..2778d0efcfdb7 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -32,6 +32,7 @@ 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'; export default function ModalsHost() { const { modalsService } = useAppContext(); @@ -51,6 +52,15 @@ export default function ModalsHost() { function renderDialog(dialog: Dialog, handleClose: () => void) { switch (dialog.kind) { + case 'device-trust-authorize': { + return ( + + ); + } case 'cluster-connect': { return ( . + */ + +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 = () => ( + + {}} + onAuthorize={async () => {}} + /> + +); diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx new file mode 100644 index 0000000000000..f6662a694d03f --- /dev/null +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.tsx @@ -0,0 +1,76 @@ +/** + * 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 Alert from 'design/Alert'; +import { ButtonPrimary, ButtonSecondary, Text } from 'design'; +import Dialog, { DialogContent } from 'design/Dialog'; +import Flex from 'design/Flex'; +import { useAsync } from 'shared/hooks/useAsync'; + +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { RootClusterUri, routing } from 'teleterm/ui/uri'; + +type Props = { + rootClusterUri: RootClusterUri; + onClose(): void; + onAuthorize(): Promise; +}; + +export const AuthenticateWebDevice = ({ + onAuthorize, + onClose, + rootClusterUri, +}: Props) => { + const [attempt, run] = useAsync(async () => { + await onAuthorize(); + onClose(); + }); + const { clustersService } = useAppContext(); + const clusterName = + clustersService.findCluster(rootClusterUri)?.name || + routing.parseClusterName(rootClusterUri); + + return ( + + {/* 360px was used as a way to do our best to get clusterName as the first item on the second line */} + + + Would you like to launch an authorized web session for{' '} + {clusterName}? + + + {attempt.status === 'error' && {attempt.statusText}} + + + Launch Web Session + + + Cancel + + + + ); +}; diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index 2027b4d93f5b2..4f7a623ff201c 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -146,6 +146,31 @@ export class ClustersService extends ImmutableStore this.usageService.captureUserLogin(params.clusterUri, params.providerType); } + async authenticateWebDevice( + rootClusterUri: uri.RootClusterUri, + { + id, + token, + }: { + id: string; + token: string; + } + ) { + return await this.client.authenticateWebDevice({ + rootClusterUri, + deviceWebToken: { + id, + token, + // empty fields, ignore + webSessionId: '', + browserIp: '', + browserUserAgent: '', + user: '', + expectedDeviceIds: [], + }, + }); + } + async loginPasswordless( params: types.LoginPasswordlessParams, abortSignal: CloneableAbortSignal diff --git a/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts b/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts index f14d97cb1c4cc..3e88cb249106b 100644 --- a/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts +++ b/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts @@ -16,10 +16,10 @@ * along with this program. If not, see . */ -import { DeepURL } from 'shared/deepLinks'; +import { AuthenticateWebDeviceDeepURL, DeepURL } from 'shared/deepLinks'; import { DeepLinkParseResult } from 'teleterm/deepLinks'; -import { routing } from 'teleterm/ui/uri'; +import { RootClusterUri, routing } from 'teleterm/ui/uri'; import { assertUnreachable } from 'teleterm/ui/utils'; import { RuntimeSettings } from 'teleterm/types'; import { ClustersService } from 'teleterm/ui/services/clusters'; @@ -27,6 +27,7 @@ import { WorkspacesService } from 'teleterm/ui/services/workspacesService'; import { ModalsService } from 'teleterm/ui/services/modals'; import { NotificationsService } from 'teleterm/ui/services/notifications'; +const confirmPath = 'webapi/devices/webconfirm'; export class DeepLinksService { constructor( private runtimeSettings: RuntimeSettings, @@ -88,17 +89,54 @@ export class DeepLinksService { break; } case '/authenticate_web_device': { - // TODO (avatus): add authenticate device web confirmation dialog - // this case is a stub and will not be reached + await this.askAuthorizeDeviceTrust(result.url); break; } } } + /** + * askAuthorizeDeviceTrust opens a dialog asking the user if they'd like to authorize + * a web session with device trust. If confirmed, the web session will be upgraded and the + * user will be directed back to the web UI + */ + private async askAuthorizeDeviceTrust( + url: AuthenticateWebDeviceDeepURL + ): Promise { + const { id, token } = url.searchParams; + + const result = await this.loginAndSetActiveWorkspace(url); + if (!result.isAtDesiredWorkspace) { + return; + } + + const { rootClusterUri } = result; + const rootCluster = this.clustersService.findCluster(rootClusterUri); + + this.modalsService.openRegularDialog({ + kind: 'device-trust-authorize', + rootClusterUri, + onCancel: () => {}, + onAuthorize: async () => { + const result = await this.clustersService.authenticateWebDevice( + rootClusterUri, + { + id, + token, + } + ); + // open url to confirm the token. This endpoint verifies the token and "upgrades" + // the web session and redirects to "/web" + window.open( + `https://${rootCluster.proxyHost}/${confirmPath}?id=${result.response.confirmationToken.id}&token=${result.response.confirmationToken.token}` + ); + }, + }); + } + /** * launchConnectMyComputer opens a Connect My Computer tab in the cluster workspace that the URL - * points to. If the relevant cluster is not in the app yet, it opens a login dialog with the - * cluster address and username prefilled from the URL. + * points to. */ private async launchConnectMyComputer(url: DeepURL): Promise { if (this.runtimeSettings.platform === 'win32') { @@ -108,6 +146,32 @@ export class DeepLinksService { return; } + const result = await this.loginAndSetActiveWorkspace(url); + + if (!result.isAtDesiredWorkspace) { + return; + } + + const { rootClusterUri } = result; + + this.workspacesService + .getWorkspaceDocumentService(rootClusterUri) + .openConnectMyComputerDocument({ rootClusterUri }); + } + + /** + * loginAndSetActiveWorkspace will set the relevant cluster if it is in the app and, if not, + * it opens a login dialog with cluster address and username prefilled from the URL. + */ + private async loginAndSetActiveWorkspace(url: DeepURL): Promise< + | { + isAtDesiredWorkspace: false; + } + | { + isAtDesiredWorkspace: true; + rootClusterUri: RootClusterUri; + } + > { const rootClusterId = url.hostname; const clusterAddress = url.host; const prefill = { @@ -131,7 +195,9 @@ export class DeepLinksService { }); if (canceled) { - return; + return { + isAtDesiredWorkspace: false, + }; } } @@ -144,12 +210,6 @@ export class DeepLinksService { prefill ); - if (!isAtDesiredWorkspace) { - return; - } - - this.workspacesService - .getWorkspaceDocumentService(rootClusterUri) - .openConnectMyComputerDocument({ rootClusterUri }); + return { isAtDesiredWorkspace, rootClusterUri }; } } diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts index 74996fe606458..ce79ef9133e8f 100644 --- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts +++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts @@ -165,6 +165,13 @@ export interface DialogDocumentsReopen { onCancel?(): void; } +export interface DialogDeviceTrustAuthorize { + kind: 'device-trust-authorize'; + rootClusterUri: RootClusterUri; + onAuthorize(): Promise; + onCancel(): void; +} + export interface DialogUsageData { kind: 'usage-data'; onAllow(): void; @@ -206,6 +213,7 @@ export type Dialog = | DialogClusterConnect | DialogClusterLogout | DialogDocumentsReopen + | DialogDeviceTrustAuthorize | DialogUsageData | DialogUserJobRole | DialogResourceSearchErrors