Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/packages/teleterm/src/services/config/appConfigSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export const createAppConfigSchema = (platform: Platform) => {
.boolean()
.default(false)
.describe('Enables sharing the computer.'),
'headless.skipConfirm': z
.boolean()
.default(false)
.describe(
'Skips the confirmation prompt for headless login approval and instead prompts for WebAuthn immediately.'
),
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import React, { useRef } from 'react';
import React, { useRef, useEffect } from 'react';

import { useAsync } from 'shared/hooks/useAsync';

Expand All @@ -29,6 +29,7 @@ interface HeadlessAuthenticationProps {
rootClusterUri: RootClusterUri;
headlessAuthenticationId: string;
clientIp: string;
skipConfirm: boolean;
onCancel(): void;
onSuccess(): void;
}
Expand Down Expand Up @@ -68,18 +69,23 @@ export function HeadlessAuthentication(props: HeadlessAuthenticationProps) {
}
}

useEffect(() => {
if (props.skipConfirm && updateHeadlessStateAttempt.status === '') {
handleHeadlessApprove();
}
}, []);

return (
<HeadlessPrompt
cluster={cluster}
clientIp={props.clientIp}
skipConfirm={props.skipConfirm}
onApprove={handleHeadlessApprove}
abortApproval={refAbortCtrl.current.abort}
onReject={handleHeadlessReject}
headlessAuthenticationId={props.headlessAuthenticationId}
updateHeadlessStateAttempt={updateHeadlessStateAttempt}
onCancel={() => {
props.onCancel();
refAbortCtrl.current.abort();
}}
onCancel={props.onCancel}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export const Story = () => (
<HeadlessPrompt
cluster={makeRootCluster()}
clientIp="localhost"
skipConfirm={false}
onApprove={async () => {}}
abortApproval={() => {}}
onReject={async () => {}}
updateHeadlessStateAttempt={makeEmptyAttempt<void>()}
onCancel={() => {}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import type * as tsh from 'teleterm/services/tshd/types';
export type HeadlessPromptProps = {
cluster: tsh.Cluster;
clientIp: string;
skipConfirm: boolean;
onApprove(): Promise<void>;
abortApproval(): void;
onReject(): Promise<void>;
headlessAuthenticationId: string;
updateHeadlessStateAttempt: Attempt<void>;
Expand All @@ -42,13 +44,17 @@ export type HeadlessPromptProps = {
export function HeadlessPrompt({
cluster,
clientIp,
skipConfirm,
onApprove,
abortApproval,
onReject,
headlessAuthenticationId,
updateHeadlessStateAttempt,
onCancel,
}: HeadlessPromptProps) {
const [waitForMfa, setWaitForMfa] = useState(false);
// skipConfirm automatically attempts to approve a headless auth attempt,
// so let's show waitForMfa from the very beginning in that case.
const [waitForMfa, setWaitForMfa] = useState(skipConfirm);

return (
<DialogConfirmation
Expand All @@ -63,7 +69,14 @@ export function HeadlessPrompt({
<Text typography="h4">
Headless command on <b>{cluster.name}</b>
</Text>
<ButtonIcon type="button" onClick={onCancel} color="text.slightlyMuted">
<ButtonIcon
type="button"
color="text.slightlyMuted"
onClick={() => {
abortApproval();
onCancel();
}}
>
<Icons.Cross size="medium" />
</ButtonIcon>
</DialogHeader>
Expand All @@ -72,52 +85,55 @@ export function HeadlessPrompt({
{updateHeadlessStateAttempt.statusText}
</Alerts.Danger>
)}

{!waitForMfa && (
<>
<DialogContent>
<Text color="text.slightlyMuted">
Someone initiated a headless command from <b>{clientIp}</b>.
<br />
If it was not you, click Reject and contact your administrator.
</Text>
<Text color="text.muted" mt={1} fontSize="12px">
Request ID: {headlessAuthenticationId}
</Text>
</DialogContent>
<DialogFooter>
<ButtonSecondary
autoFocus
mr={3}
type="submit"
onClick={e => {
e.preventDefault();
setWaitForMfa(true);
onApprove();
}}
>
Approve
</ButtonSecondary>
<ButtonSecondary
type="button"
onClick={e => {
e.preventDefault();
onReject();
}}
>
Reject
</ButtonSecondary>
</DialogFooter>
</>
)}
<DialogContent>
<Text color="text.slightlyMuted">
Someone initiated a headless command from <b>{clientIp}</b>.
<br />
If it was not you, click Cancel and contact your administrator.
</Text>
<Text color="text.muted" mt={1} fontSize="12px">
Request ID: {headlessAuthenticationId}
</Text>
</DialogContent>
{waitForMfa && (
<DialogContent mb={2}>
<Text color="text.slightlyMuted">
Complete MFA verification to approve the Headless Login.
</Text>
<PromptWebauthn prompt={'tap'} onCancel={onCancel} />
<PromptWebauthn
prompt={'tap'}
onCancel={() => {
abortApproval();
onReject();
}}
/>
</DialogContent>
)}
{!waitForMfa && (
<DialogFooter>
<ButtonSecondary
autoFocus
mr={3}
type="submit"
onClick={e => {
e.preventDefault();
setWaitForMfa(true);
onApprove();
}}
>
Approve
</ButtonSecondary>
<ButtonSecondary
type="button"
onClick={e => {
e.preventDefault();
onReject();
}}
>
Cancel
</ButtonSecondary>
</DialogFooter>
)}
</DialogConfirmation>
);
}
1 change: 1 addition & 0 deletions web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
rootClusterUri={dialog.rootClusterUri}
headlessAuthenticationId={dialog.headlessAuthenticationId}
clientIp={dialog.headlessAuthenticationClientIp}
skipConfirm={dialog.skipConfirm}
onCancel={() => {
handleClose();
dialog.onCancel();
Expand Down
3 changes: 2 additions & 1 deletion web/packages/teleterm/src/ui/appContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ export default class AppContext implements IAppContext {
this.headlessAuthenticationService = new HeadlessAuthenticationService(
mainProcessClient,
this.modalsService,
tshClient
tshClient,
this.configService
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,37 @@
import { SendPendingHeadlessAuthenticationRequest } from 'teleterm/services/tshdEvents';
import { MainProcessClient } from 'teleterm/types';
import { ModalsService } from 'teleterm/ui/services/modals';
import { ConfigService } from 'teleterm/services/config';

import type * as types from 'teleterm/services/tshd/types';

export class HeadlessAuthenticationService {
constructor(
private mainProcessClient: MainProcessClient,
private modalsService: ModalsService,
private tshClient: types.TshClient
private tshClient: types.TshClient,
private configService: ConfigService
) {}

sendPendingHeadlessAuthentication(
request: SendPendingHeadlessAuthenticationRequest,
onRequestCancelled: (callback: () => void) => void
): Promise<void> {
this.mainProcessClient.forceFocusWindow();
const skipConfirm = this.configService.get('headless.skipConfirm').value;

// If the user wants to skip the confirmation step, then don't force the window.
// Instead, they can just tap their blinking yubikey with the window in the background.
if (!skipConfirm) {
this.mainProcessClient.forceFocusWindow();
}

return new Promise(resolve => {
const { closeDialog } = this.modalsService.openImportantDialog({
kind: 'headless-authn',
rootClusterUri: request.rootClusterUri,
headlessAuthenticationId: request.headlessAuthenticationId,
headlessAuthenticationClientIp: request.headlessAuthenticationClientIp,
skipConfirm: skipConfirm,
onSuccess: () => resolve(),
onCancel: () => resolve(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export interface DialogHeadlessAuthentication {
rootClusterUri: RootClusterUri;
headlessAuthenticationId: string;
headlessAuthenticationClientIp: string;
skipConfirm: boolean;
onSuccess(): void;
onCancel(): void;
}
Expand Down