diff --git a/web/packages/shared/components/ButtonSso/ButtonSso.tsx b/web/packages/shared/components/ButtonSso/ButtonSso.tsx index 32007d92ad2c6..9cc4d8c208660 100644 --- a/web/packages/shared/components/ButtonSso/ButtonSso.tsx +++ b/web/packages/shared/components/ButtonSso/ButtonSso.tsx @@ -23,7 +23,7 @@ import { ButtonProps, ButtonSecondary } from 'design/Button'; import { ResourceIcon } from 'design/ResourceIcon'; -import { AuthProviderType } from 'shared/services'; +import { AuthProviderType, SSOType } from 'shared/services'; const ButtonSso = forwardRef((props: Props, ref) => { const { ssoType = 'unknown', title, ...rest } = props; @@ -41,16 +41,7 @@ type Props = ButtonProps<'button'> & { title: string; }; -type SSOType = - | 'microsoft' - | 'github' - | 'bitbucket' - | 'google' - | 'openid' - | 'okta' - | 'unknown'; - -function SSOIcon({ type }: { type: SSOType }) { +export function SSOIcon({ type }: { type: SSOType }) { const commonResourceIconProps = { width: '24px', height: '24px', diff --git a/web/packages/shared/services/types.ts b/web/packages/shared/services/types.ts index ecc9c55f9233c..a9989ffa30dd2 100644 --- a/web/packages/shared/services/types.ts +++ b/web/packages/shared/services/types.ts @@ -16,6 +16,15 @@ * along with this program. If not, see . */ +export type SSOType = + | 'microsoft' + | 'github' + | 'bitbucket' + | 'google' + | 'openid' + | 'okta' + | 'unknown'; + export type AuthProviderType = 'oidc' | 'saml' | 'github'; export type Auth2faType = 'otp' | 'off' | 'optional' | 'on' | 'webauthn'; diff --git a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx index 8ec5592c47a0c..c14222e102ae9 100644 --- a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx @@ -26,11 +26,41 @@ export default { title: 'Teleport/AuthnDialog', }; -export const Loaded = () => ; +export const Loaded = () => { + const props: Props = { + ...defaultProps, + mfa: { + ...defaultProps.mfa, + ssoChallenge: { + redirectUrl: 'hi', + requestId: '123', + channelId: '123', + device: { + connectorId: '123', + connectorType: 'saml', + displayName: 'Okta', + }, + }, + webauthnPublicKey: { + challenge: new ArrayBuffer(1), + }, + }, + }; + return ; +}; -export const Error = () => ; +export const Error = () => { + const props: Props = { + ...defaultProps, + mfa: { + ...defaultProps.mfa, + errorText: 'Something went wrong', + }, + }; + return ; +}; -const props: Props = { +const defaultProps: Props = { mfa: makeDefaultMfaState(), onCancel: () => null, }; diff --git a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx new file mode 100644 index 0000000000000..0f7df2729eb93 --- /dev/null +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx @@ -0,0 +1,99 @@ +/** + * 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 React from 'react'; +import { render, screen, fireEvent } from 'design/utils/testing'; + +import { makeDefaultMfaState, MfaState } from 'teleport/lib/useMfa'; +import { SSOChallenge } from 'teleport/services/auth'; + +import AuthnDialog from './AuthnDialog'; + +const mockSsoChallenge: SSOChallenge = { + redirectUrl: 'url', + requestId: '123', + device: { + displayName: 'Okta', + connectorId: '123', + connectorType: 'saml', + }, + channelId: '123', +}; + +function makeMockState(partial: Partial): MfaState { + const mfa = makeDefaultMfaState(); + return { + ...mfa, + ...partial, + }; +} + +describe('AuthnDialog', () => { + const mockOnCancel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders the dialog with basic content', () => { + const mfa = makeMockState({ ssoChallenge: mockSsoChallenge }); + render(); + + expect( + screen.getByText('Re-authenticate in the Browser') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'To continue, you must verify your identity by re-authenticating:' + ) + ).toBeInTheDocument(); + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.getByTestId('close-dialog')).toBeInTheDocument(); + }); + + test('displays error text when provided', () => { + const errorText = 'Authentication failed'; + const mfa = makeMockState({ errorText }); + render(); + + expect(screen.getByTestId('danger-alert')).toBeInTheDocument(); + expect(screen.getByText(errorText)).toBeInTheDocument(); + }); + + test('sso button renders with callback', async () => { + const mfa = makeMockState({ + ssoChallenge: mockSsoChallenge, + onSsoAuthenticate: jest.fn(), + }); + render(); + const ssoButton = screen.getByText('Okta'); + fireEvent.click(ssoButton); + expect(mfa.onSsoAuthenticate).toHaveBeenCalledTimes(1); + }); + + test('webauthn button renders with callback', async () => { + const mfa = makeMockState({ + webauthnPublicKey: { challenge: new ArrayBuffer(0) }, + onWebauthnAuthenticate: jest.fn(), + }); + render(); + const webauthn = screen.getByText('Passkey or MFA Device'); + fireEvent.click(webauthn); + expect(mfa.onWebauthnAuthenticate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx index 05685c0d6a3eb..52edb304c9f38 100644 --- a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx @@ -17,46 +17,64 @@ */ import React from 'react'; -import Dialog, { - DialogHeader, - DialogTitle, - DialogContent, -} from 'design/Dialog'; +import Dialog, { DialogContent } from 'design/Dialog'; import { Danger } from 'design/Alert'; -import { Text, ButtonPrimary, ButtonSecondary, Flex } from 'design'; +import { FingerprintSimple, Cross } from 'design/Icon'; + +import { Text, ButtonSecondary, Flex, ButtonIcon, H2 } from 'design'; + +import { guessProviderType } from 'shared/components/ButtonSso'; +import { SSOIcon } from 'shared/components/ButtonSso/ButtonSso'; import { MfaState } from 'teleport/lib/useMfa'; export default function AuthnDialog({ mfa, onCancel }: Props) { return ( - ({ width: '500px' })} open={true}> - - - Multi-factor authentication - - - + ({ width: '400px' })} open={true}> + +

Re-authenticate in the Browser

+ + + +
+ {mfa.errorText && ( - + {mfa.errorText} )} - - Re-enter your multi-factor authentication in the browser to continue. + + To continue, you must verify your identity by re-authenticating: - - {/* TODO (avatus) this will eventually be conditionally rendered based on what - type of challenges exist. For now, its only webauthn. */} - - {mfa.errorText ? 'Retry' : 'OK'} - - Cancel + + {mfa.ssoChallenge && ( + + + {mfa.ssoChallenge.device.displayName} + + )} + {mfa.webauthnPublicKey && ( + + + Passkey or MFA Device + + )}
); diff --git a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts index 68eae3367f6ea..473ab8129221a 100644 --- a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts +++ b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts @@ -18,13 +18,25 @@ import { EventEmitter } from 'events'; -import { WebauthnAssertionResponse } from 'teleport/services/auth'; +import { + MfaChallengeResponse, + WebauthnAssertionResponse, +} from 'teleport/services/auth'; class EventEmitterMfaSender extends EventEmitter { constructor() { super(); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + sendChallengeResponse(data: MfaChallengeResponse) { + throw new Error('Not implemented'); + } + + // TODO (avatus) DELETE IN 18 + /** + * @deprecated Use sendChallengeResponse instead. + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars sendWebAuthn(data: WebauthnAssertionResponse) { throw new Error('Not implemented'); diff --git a/web/packages/teleport/src/lib/tdp/client.ts b/web/packages/teleport/src/lib/tdp/client.ts index 6f000b083d820..98e3121b5dbc7 100644 --- a/web/packages/teleport/src/lib/tdp/client.ts +++ b/web/packages/teleport/src/lib/tdp/client.ts @@ -374,7 +374,7 @@ export default class Client extends EventEmitterMfaSender { try { const mfaJson = this.codec.decodeMfaJson(buffer); if (mfaJson.mfaType == 'n') { - this.emit(TermEvent.WEBAUTHN_CHALLENGE, mfaJson.jsonString); + this.emit(TermEvent.MFA_CHALLENGE, mfaJson.jsonString); } else { // mfaJson.mfaType === 'u', or else decodeMfaJson would have thrown an error. this.handleError( diff --git a/web/packages/teleport/src/lib/term/enums.ts b/web/packages/teleport/src/lib/term/enums.ts index 277ce3fd46565..a818929eb863c 100644 --- a/web/packages/teleport/src/lib/term/enums.ts +++ b/web/packages/teleport/src/lib/term/enums.ts @@ -36,7 +36,7 @@ export enum TermEvent { SESSION = 'terminal.new_session', DATA = 'terminal.data', CONN_CLOSE = 'connection.close', - WEBAUTHN_CHALLENGE = 'terminal.webauthn', + MFA_CHALLENGE = 'terminal.webauthn', LATENCY = 'terminal.latency', KUBE_EXEC = 'terminal.kube_exec', } diff --git a/web/packages/teleport/src/lib/term/protobuf.ts b/web/packages/teleport/src/lib/term/protobuf.ts index 9db0625ea4fe2..43f5f985a465c 100644 --- a/web/packages/teleport/src/lib/term/protobuf.ts +++ b/web/packages/teleport/src/lib/term/protobuf.ts @@ -29,7 +29,7 @@ export const MessageTypeEnum = { RESIZE: 'w', FILE_TRANSFER_REQUEST: 'f', FILE_TRANSFER_DECISION: 't', - WEBAUTHN_CHALLENGE: 'n', + MFA_CHALLENGE: 'n', ERROR: 'e', LATENCY: 'l', KUBE_EXEC: 'k', @@ -59,7 +59,7 @@ export const messageFields = { data: MessageTypeEnum.RAW.charCodeAt(0), event: MessageTypeEnum.AUDIT.charCodeAt(0), close: MessageTypeEnum.SESSION_END.charCodeAt(0), - challengeResponse: MessageTypeEnum.WEBAUTHN_CHALLENGE.charCodeAt(0), + challengeResponse: MessageTypeEnum.MFA_CHALLENGE.charCodeAt(0), kubeExec: MessageTypeEnum.KUBE_EXEC.charCodeAt(0), error: MessageTypeEnum.ERROR.charCodeAt(0), }, diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index 2eb11957b8fbd..d5d43845b1141 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -19,7 +19,10 @@ import Logger from 'shared/libs/logger'; import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender'; -import { WebauthnAssertionResponse } from 'teleport/services/auth'; +import { + MfaChallengeResponse, + WebauthnAssertionResponse, +} from 'teleport/services/auth'; import { AuthenticatedWebSocket } from 'teleport/lib/AuthenticatedWebSocket'; import { EventType, TermEvent, WebsocketCloseCode } from './enums'; @@ -80,6 +83,28 @@ class Tty extends EventEmitterMfaSender { this.socket.send(bytearray.buffer); } + sendChallengeResponse(data: MfaChallengeResponse) { + // we want to have the backend listen on a single message type + // for any responses. so our data will look like data.webauthn, data.sso, etc + // but to be backward compatible, we need to still spread the existing webauthn only fields + // as "top level" fields so old proxies can still respond to webauthn challenges. + // in 19, we can just pass "data" without this extra step + // TODO (avatus): DELETE IN 18 + const backwardCompatibleData = { + ...data.webauthn_response, + ...data, + }; + const encoded = this._proto.encodeChallengeResponse( + JSON.stringify(backwardCompatibleData) + ); + const bytearray = new Uint8Array(encoded); + this.socket.send(bytearray); + } + + // TODO (avatus) DELETE IN 18 + /** + * @deprecated Use sendChallengeResponse instead. + */ sendWebAuthn(data: WebauthnAssertionResponse) { const encoded = this._proto.encodeChallengeResponse(JSON.stringify(data)); const bytearray = new Uint8Array(encoded); @@ -190,8 +215,8 @@ class Tty extends EventEmitterMfaSender { const msg = this._proto.decode(uintArray); switch (msg.type) { - case MessageTypeEnum.WEBAUTHN_CHALLENGE: - this.emit(TermEvent.WEBAUTHN_CHALLENGE, msg.payload); + case MessageTypeEnum.MFA_CHALLENGE: + this.emit(TermEvent.MFA_CHALLENGE, msg.payload); break; case MessageTypeEnum.AUDIT: this._processAuditPayload(msg.payload); diff --git a/web/packages/teleport/src/lib/useMfa.ts b/web/packages/teleport/src/lib/useMfa.ts index 8d55cf4c73f75..7b4b5fc68f839 100644 --- a/web/packages/teleport/src/lib/useMfa.ts +++ b/web/packages/teleport/src/lib/useMfa.ts @@ -41,11 +41,33 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { totpChallenge: false, }); - // TODO (avatus), this is stubbed for types but will not be called - // until SSO as MFA backend is in. + function clearChallenges() { + setState(prevState => ({ + ...prevState, + totpChallenge: false, + webauthnPublicKey: null, + ssoChallenge: null, + })); + } + function onSsoAuthenticate() { - // eslint-disable-next-line no-console - console.error('not yet implemented'); + if (!state.ssoChallenge) { + setState(prevState => ({ + ...prevState, + errorText: 'Invalid or missing SSO challenge', + })); + return; + } + + // try to center the screen + const width = 1045; + const height = 550; + const left = (screen.width - width) / 2; + const top = (screen.height - height) / 2; + + // these params will open a tiny window. + const params = `width=${width},height=${height},left=${left},top=${top}`; + window.open(state.ssoChallenge.redirectUrl, '_blank', params); } function onWebauthnAuthenticate() { @@ -77,27 +99,64 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { }); } - const onChallenge = useCallback(challengeJson => { - const { webauthnPublicKey, ssoChallenge, totpChallenge } = - makeMfaAuthenticateChallenge(challengeJson); - - setState(prevState => ({ - ...prevState, - ssoChallenge, - webauthnPublicKey, - totpChallenge, - })); - }, []); + const waitForSsoChallengeResponse = useCallback( + async ( + ssoChallenge: SSOChallenge, + abortSignal: AbortSignal + ): Promise => { + const channel = new BroadcastChannel(ssoChallenge.channelId); + + try { + const event = await waitForMessage(channel, abortSignal); + emitterSender.sendChallengeResponse({ + sso_response: { + requestId: ssoChallenge.requestId, + token: event.data.mfaToken, + }, + }); + clearChallenges(); + } catch (error) { + if (error.name !== 'AbortError') { + throw error; + } + } finally { + channel.close(); + } + }, + [emitterSender] + ); useEffect(() => { - if (emitterSender) { - emitterSender.on(TermEvent.WEBAUTHN_CHALLENGE, onChallenge); - - return () => { - emitterSender.removeListener(TermEvent.WEBAUTHN_CHALLENGE, onChallenge); - }; - } - }, [emitterSender, onChallenge]); + let ssoChallengeAbortController: AbortController | undefined; + const challengeHandler = (challengeJson: string) => { + const { webauthnPublicKey, ssoChallenge, totpChallenge } = + makeMfaAuthenticateChallenge(challengeJson); + + setState(prevState => ({ + ...prevState, + addMfaToScpUrls: true, + ssoChallenge, + webauthnPublicKey, + totpChallenge, + })); + + if (ssoChallenge) { + ssoChallengeAbortController?.abort(); + ssoChallengeAbortController = new AbortController(); + void waitForSsoChallengeResponse( + ssoChallenge, + ssoChallengeAbortController.signal + ); + } + }; + + emitterSender?.on(TermEvent.MFA_CHALLENGE, challengeHandler); + + return () => { + ssoChallengeAbortController?.abort(); + emitterSender?.removeListener(TermEvent.MFA_CHALLENGE, challengeHandler); + }; + }, [emitterSender, waitForSsoChallengeResponse]); function setErrorText(newErrorText: string) { setState(prevState => ({ ...prevState, errorText: newErrorText })); @@ -146,3 +205,25 @@ export function makeDefaultMfaState(): MfaState { ssoChallenge: null, }; } + +function waitForMessage( + channel: BroadcastChannel, + abortSignal: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + // Create the event listener + function eventHandler(e: MessageEvent) { + // Remove the event listener after it triggers + channel.removeEventListener('message', eventHandler); + // Resolve the promise with the event object + resolve(e); + } + + // Add the event listener + channel.addEventListener('message', eventHandler); + abortSignal.onabort = e => { + channel.removeEventListener('message', eventHandler); + reject(e); + }; + }); +} diff --git a/web/packages/teleport/src/services/auth/makeMfa.ts b/web/packages/teleport/src/services/auth/makeMfa.ts index 506cca4a874c7..163bcd007b041 100644 --- a/web/packages/teleport/src/services/auth/makeMfa.ts +++ b/web/packages/teleport/src/services/auth/makeMfa.ts @@ -56,7 +56,7 @@ export function makeMfaRegistrationChallenge(json): MfaRegistrationChallenge { // - allowCredentials[i].id export function makeMfaAuthenticateChallenge(json): MfaAuthenticateChallenge { const challenge = typeof json === 'string' ? JSON.parse(json) : json; - const { sso_challenge, webauthn_challenge } = challenge; + const { sso_challenge, webauthn_challenge, totp_challenge } = challenge; const webauthnPublicKey = webauthn_challenge?.publicKey; if (webauthnPublicKey) { @@ -73,13 +73,8 @@ export function makeMfaAuthenticateChallenge(json): MfaAuthenticateChallenge { } return { - ssoChallenge: sso_challenge - ? { - redirectUrl: sso_challenge.redirect_url, - requestId: sso_challenge.request_id, - } - : null, - totpChallenge: json.totp_challenge, + ssoChallenge: sso_challenge, + totpChallenge: totp_challenge, webauthnPublicKey: webauthnPublicKey, }; } @@ -146,6 +141,11 @@ export function makeWebauthnAssertionResponse(res): WebauthnAssertionResponse { }; } +export type SsoChallengeResponse = { + requestId: string; + token: string; +}; + export type WebauthnAssertionResponse = { id: string; type: string; @@ -160,3 +160,8 @@ export type WebauthnAssertionResponse = { userHandle: string; }; }; + +export type MfaChallengeResponse = { + webauthn_response?: WebauthnAssertionResponse; + sso_response?: SsoChallengeResponse; +}; diff --git a/web/packages/teleport/src/services/auth/types.ts b/web/packages/teleport/src/services/auth/types.ts index 170d4eedee272..734cb53a53112 100644 --- a/web/packages/teleport/src/services/auth/types.ts +++ b/web/packages/teleport/src/services/auth/types.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { AuthProviderType } from 'shared/services'; + import { EventMeta } from 'teleport/services/userEvent'; import { IsMfaRequiredRequest, MfaChallengeScope } from './auth'; @@ -33,8 +35,14 @@ export type AuthnChallengeRequest = { }; export type SSOChallenge = { + channelId: string; redirectUrl: string; requestId: string; + device: { + connectorId: string; + connectorType: AuthProviderType; + displayName: string; + }; }; export type MfaAuthenticateChallenge = {