Skip to content

Commit

Permalink
Open authorize device popup with deeplink (#40802)
Browse files Browse the repository at this point in the history
  • Loading branch information
avatus authored Apr 26, 2024
1 parent f573c2d commit 0051a2b
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
20 changes: 0 additions & 20 deletions web/packages/teleterm/src/deepLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,26 +213,6 @@ describe('parseDeepLink followed by makeDeepLinkWithSafeInput gives the same res
'teleport://cluster.example.com/connect_my_computer',
'teleport://[email protected]/connect_my_computer',
'teleport://alice.bobson%[email protected]: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://[email protected]/connect_my_computer',
'teleport://[email protected]/authenticate_web_device?id=123&token=234',
];

Expand Down
2 changes: 0 additions & 2 deletions web/packages/teleterm/src/deepLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -51,6 +52,15 @@ export default function ModalsHost() {

function renderDialog(dialog: Dialog, handleClose: () => void) {
switch (dialog.kind) {
case 'device-trust-authorize': {
return (
<AuthenticateWebDevice
rootClusterUri={dialog.rootClusterUri}
onAuthorize={dialog.onAuthorize}
onClose={handleClose}
/>
);
}
case 'cluster-connect': {
return (
<ClusterConnect
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Teleport
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
*/

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 = () => (
<MockAppContextProvider>
<AuthenticateWebDevice
rootClusterUri={makeRootCluster().uri}
onClose={() => {}}
onAuthorize={async () => {}}
/>
</MockAppContextProvider>
);
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<void>;
};

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 (
<Dialog open={true}>
{/* 360px was used as a way to do our best to get clusterName as the first item on the second line */}
<DialogContent maxWidth="360px">
<Text>
Would you like to launch an authorized web session for{' '}
<b>{clusterName}</b>?
</Text>
</DialogContent>
{attempt.status === 'error' && <Alert>{attempt.statusText}</Alert>}
<Flex>
<ButtonPrimary
disabled={attempt.status === 'processing'}
block={true}
onClick={run}
mr={3}
>
Launch Web Session
</ButtonPrimary>
<ButtonSecondary
disabled={attempt.status === 'processing'}
onClick={onClose}
>
Cancel
</ButtonSecondary>
</Flex>
</Dialog>
);
};
25 changes: 25 additions & 0 deletions web/packages/teleterm/src/ui/services/clusters/clustersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ export class ClustersService extends ImmutableStore<types.ClustersServiceState>
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
Expand Down
88 changes: 74 additions & 14 deletions web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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';
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,
Expand Down Expand Up @@ -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<void> {
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<void> {
if (this.runtimeSettings.platform === 'win32') {
Expand All @@ -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 = {
Expand All @@ -131,7 +195,9 @@ export class DeepLinksService {
});

if (canceled) {
return;
return {
isAtDesiredWorkspace: false,
};
}
}

Expand All @@ -144,12 +210,6 @@ export class DeepLinksService {
prefill
);

if (!isAtDesiredWorkspace) {
return;
}

this.workspacesService
.getWorkspaceDocumentService(rootClusterUri)
.openConnectMyComputerDocument({ rootClusterUri });
return { isAtDesiredWorkspace, rootClusterUri };
}
}
8 changes: 8 additions & 0 deletions web/packages/teleterm/src/ui/services/modals/modalsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ export interface DialogDocumentsReopen {
onCancel?(): void;
}

export interface DialogDeviceTrustAuthorize {
kind: 'device-trust-authorize';
rootClusterUri: RootClusterUri;
onAuthorize(): Promise<void>;
onCancel(): void;
}

export interface DialogUsageData {
kind: 'usage-data';
onAllow(): void;
Expand Down Expand Up @@ -206,6 +213,7 @@ export type Dialog =
| DialogClusterConnect
| DialogClusterLogout
| DialogDocumentsReopen
| DialogDeviceTrustAuthorize
| DialogUsageData
| DialogUserJobRole
| DialogResourceSearchErrors
Expand Down

0 comments on commit 0051a2b

Please sign in to comment.