diff --git a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx index 8ec5592c47a0c..4ced2cdf8e5c2 100644 --- a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.story.tsx @@ -26,11 +26,38 @@ 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', + }, + }, + }, + }; + 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..cc6e864a8073b --- /dev/null +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx @@ -0,0 +1,97 @@ +/** + * 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('Multi-factor authentication')).toBeInTheDocument(); + expect( + screen.getByText( + 'Re-enter your multi-factor authentication in the browser to continue.' + ) + ).toBeInTheDocument(); + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).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/Hardware Key'); + 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..805d4d0c79804 100644 --- a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx @@ -23,13 +23,16 @@ import Dialog, { DialogContent, } from 'design/Dialog'; import { Danger } from 'design/Alert'; -import { Text, ButtonPrimary, ButtonSecondary, Flex } from 'design'; +import { Text, ButtonSecondary, Flex, ButtonLink } 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}> + ({ width: '400px' })} open={true}> Multi-factor authentication @@ -37,7 +40,7 @@ export default function AuthnDialog({ mfa, onCancel }: Props) { {mfa.errorText && ( - + {mfa.errorText} )} @@ -45,18 +48,31 @@ export default function AuthnDialog({ mfa, onCancel }: Props) { Re-enter your multi-factor authentication in the browser to continue. - - {/* 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/Hardware Key + + )} + + Cancel + ); diff --git a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts index 68eae3367f6ea..2117197e72195 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 19 + /** + * @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/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index 2eb11957b8fbd..62bbca2f7c22f 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,29 @@ 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 19 + const backwardCompatibleData = { + ...data.webauthn_response, + ...data, + }; + console.log({ data }); + const encoded = this._proto.encodeChallengeResponse( + JSON.stringify(backwardCompatibleData) + ); + const bytearray = new Uint8Array(encoded); + this.socket.send(bytearray); + } + + // TODO (avatus) DELETE IN 19 + /** + * @deprecated Use sendChallengeResponse instead. + */ sendWebAuthn(data: WebauthnAssertionResponse) { const encoded = this._proto.encodeChallengeResponse(JSON.stringify(data)); const bytearray = new Uint8Array(encoded); diff --git a/web/packages/teleport/src/lib/useMfa.ts b/web/packages/teleport/src/lib/useMfa.ts index 8d55cf4c73f75..3c4f552696255 100644 --- a/web/packages/teleport/src/lib/useMfa.ts +++ b/web/packages/teleport/src/lib/useMfa.ts @@ -41,11 +41,64 @@ 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, + })); + } + + // open a broadcast channel if sso challenge exists so it can listen + // for a confirmation response token + useEffect(() => { + if (!state.ssoChallenge) { + return; + } + + const channel = new BroadcastChannel(state.ssoChallenge.channelId); + + function handleMessage(e: MessageEvent<{ mfaToken: string }>) { + if (!state.ssoChallenge) { + return; + } + + emitterSender.sendChallengeResponse({ + sso_response: { + requestId: state.ssoChallenge.requestId, + token: e.data.mfaToken, + }, + }); + clearChallenges(); + } + + channel.addEventListener('message', handleMessage); + + return () => { + channel.removeEventListener('message', handleMessage); + channel.close(); + }; + }, [state, emitterSender, state.ssoChallenge]); + 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() { @@ -83,6 +136,7 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { setState(prevState => ({ ...prevState, + addMfaToScpUrls: true, ssoChallenge, webauthnPublicKey, totpChallenge, 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; +};