diff --git a/web/packages/teleport/src/Account/Account.test.tsx b/web/packages/teleport/src/Account/Account.test.tsx index 7dcf86f471adb..3ca45002ffaee 100644 --- a/web/packages/teleport/src/Account/Account.test.tsx +++ b/web/packages/teleport/src/Account/Account.test.tsx @@ -243,7 +243,7 @@ test('adding an MFA device', async () => { const user = userEvent.setup(); const ctx = createTeleportContext(); jest.spyOn(ctx.mfaService, 'fetchDevices').mockResolvedValue([testPasskey]); - jest.spyOn(auth, 'getChallenge').mockResolvedValue({ + jest.spyOn(auth, 'getMfaChallenge').mockResolvedValue({ webauthnPublicKey: null, totpChallenge: true, ssoChallenge: null, @@ -327,7 +327,7 @@ test('removing an MFA method', async () => { const user = userEvent.setup(); const ctx = createTeleportContext(); jest.spyOn(ctx.mfaService, 'fetchDevices').mockResolvedValue([testMfaMethod]); - jest.spyOn(auth, 'getChallenge').mockResolvedValue({ + jest.spyOn(auth, 'getMfaChallenge').mockResolvedValue({ webauthnPublicKey: null, totpChallenge: false, ssoChallenge: null, diff --git a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx index 07283ae6c1c8d..3ec850a0aeba7 100644 --- a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx +++ b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.story.tsx @@ -23,10 +23,11 @@ import Dialog from 'design/Dialog'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { ContextProvider } from 'teleport'; -import { MfaDevice } from 'teleport/services/mfa'; +import { MfaDevice, WebauthnAssertionResponse } from 'teleport/services/mfa'; import { ChangePasswordStep, + ChangePasswordWizardStepProps, ReauthenticateStep, createReauthOptions, } from './ChangePasswordWizard'; @@ -107,12 +108,16 @@ const stepProps = { flowLength: 2, refCallback: () => {}, - // Other props - reauthOptions: defaultReauthOptions, - reauthMethod: defaultReauthOptions[0].value, - credential: { id: '', type: '' }, - onReauthMethodChange() {}, - onAuthenticated() {}, + // Shared props + reauthMethod: 'mfaDevice', onClose() {}, onSuccess() {}, -}; + + // ReauthenticateStepProps + reauthOptions: defaultReauthOptions, + onReauthMethodChange() {}, + onWebauthnResponse() {}, + + // ChangePasswordStepProps + webauthnResponse: {} as WebauthnAssertionResponse, +} satisfies ChangePasswordWizardStepProps; diff --git a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx index 30935e14eb399..d23469ead98fd 100644 --- a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx +++ b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.test.tsx @@ -24,6 +24,8 @@ import { userEvent, UserEvent } from '@testing-library/user-event'; import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; +import { MfaChallengeResponse } from 'teleport/services/mfa'; + import { ChangePasswordWizardProps, createReauthOptions, @@ -31,9 +33,21 @@ import { import { ChangePasswordWizard } from '.'; -const dummyCredential: Credential = { - id: 'cred-id', - type: 'public-key', +const dummyChallengeResponse: MfaChallengeResponse = { + webauthn_response: { + id: 'cred-id', + type: 'public-key', + extensions: { + appid: true, + }, + rawId: 'rawId', + response: { + authenticatorData: 'authenticatorData', + clientDataJSON: 'clientDataJSON', + signature: 'signature', + userHandle: 'userHandle', + }, + }, }; let user: UserEvent; let onSuccess: jest.Mock; @@ -72,9 +86,10 @@ beforeEach(() => { user = userEvent.setup(); onSuccess = jest.fn(); + jest.spyOn(auth, 'getMfaChallenge').mockResolvedValueOnce(undefined); jest - .spyOn(auth, 'fetchWebAuthnChallenge') - .mockResolvedValueOnce(dummyCredential); + .spyOn(auth, 'getMfaChallengeResponse') + .mockResolvedValueOnce(dummyChallengeResponse); jest.spyOn(auth, 'changePassword').mockResolvedValueOnce(undefined); }); @@ -89,10 +104,11 @@ describe('with passwordless reauthentication', () => { ); await user.click(reauthenticateStep.getByText('Passkey')); await user.click(reauthenticateStep.getByText('Next')); - expect(auth.fetchWebAuthnChallenge).toHaveBeenCalledWith({ + expect(auth.getMfaChallenge).toHaveBeenCalledWith({ scope: MfaChallengeScope.CHANGE_PASSWORD, userVerificationRequirement: 'required', }); + expect(auth.getMfaChallengeResponse).toHaveBeenCalled(); } it('changes password', async () => { @@ -113,7 +129,7 @@ describe('with passwordless reauthentication', () => { oldPassword: '', newPassword: 'new-pass1234', secondFactorToken: '', - credential: dummyCredential, + webauthnResponse: dummyChallengeResponse.webauthn_response, }); expect(onSuccess).toHaveBeenCalled(); }); @@ -180,7 +196,7 @@ describe('with WebAuthn MFA reauthentication', () => { ); await user.click(reauthenticateStep.getByText('MFA Device')); await user.click(reauthenticateStep.getByText('Next')); - expect(auth.fetchWebAuthnChallenge).toHaveBeenCalledWith({ + expect(auth.getMfaChallenge).toHaveBeenCalledWith({ scope: MfaChallengeScope.CHANGE_PASSWORD, userVerificationRequirement: 'discouraged', }); @@ -208,7 +224,7 @@ describe('with WebAuthn MFA reauthentication', () => { oldPassword: 'current-pass', newPassword: 'new-pass1234', secondFactorToken: '', - credential: dummyCredential, + webauthnResponse: dummyChallengeResponse.webauthn_response, }); expect(onSuccess).toHaveBeenCalled(); }); @@ -282,7 +298,7 @@ describe('with OTP MFA reauthentication', () => { ); await user.click(reauthenticateStep.getByText('Authenticator App')); await user.click(reauthenticateStep.getByText('Next')); - expect(auth.fetchWebAuthnChallenge).not.toHaveBeenCalled(); + expect(auth.getMfaChallenge).not.toHaveBeenCalled(); } it('changes password', async () => { @@ -406,11 +422,11 @@ describe('without reauthentication', () => { 'new-pass1234' ); await user.click(changePasswordStep.getByText('Save Changes')); - expect(auth.fetchWebAuthnChallenge).not.toHaveBeenCalled(); + expect(auth.getMfaChallenge).not.toHaveBeenCalled(); expect(auth.changePassword).toHaveBeenCalledWith({ oldPassword: 'current-pass', newPassword: 'new-pass1234', - credential: undefined, + webauthnResponse: undefined, secondFactorToken: '', }); expect(onSuccess).toHaveBeenCalled(); diff --git a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx index 458357e83ed54..3a66e65a070ce 100644 --- a/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx +++ b/web/packages/teleport/src/Account/ChangePasswordWizard/ChangePasswordWizard.tsx @@ -38,7 +38,7 @@ import Box from 'design/Box'; import { ChangePasswordReq } from 'teleport/services/auth'; import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; -import { MfaDevice } from 'teleport/services/mfa'; +import { MfaDevice, WebauthnAssertionResponse } from 'teleport/services/mfa'; export interface ChangePasswordWizardProps { /** MFA type setting, as configured in the cluster's configuration. */ @@ -66,7 +66,8 @@ export function ChangePasswordWizard({ const [reauthMethod, setReauthMethod] = useState( reauthOptions[0]?.value ); - const [credential, setCredential] = useState(); + const [webauthnResponse, setWebauthnResponse] = + useState(); const reauthRequired = reauthOptions.length > 0; return ( @@ -84,9 +85,9 @@ export function ChangePasswordWizard({ // Step properties reauthOptions={reauthOptions} reauthMethod={reauthMethod} - credential={credential} onReauthMethodChange={setReauthMethod} - onAuthenticated={setCredential} + webauthnResponse={webauthnResponse} + onWebauthnResponse={setWebauthnResponse} onClose={onClose} onSuccess={onSuccess} /> @@ -146,7 +147,7 @@ const wizardFlows = { withoutReauthentication: [ChangePasswordStep], }; -type ChangePasswordWizardStepProps = StepComponentProps & +export type ChangePasswordWizardStepProps = StepComponentProps & ReauthenticateStepProps & ChangePasswordStepProps; @@ -154,7 +155,7 @@ interface ReauthenticateStepProps { reauthOptions: ReauthenticationOption[]; reauthMethod: ReauthenticationMethod; onReauthMethodChange(method: ReauthenticationMethod): void; - onAuthenticated(res: Credential): void; + onWebauthnResponse(res: WebauthnAssertionResponse): void; onClose(): void; } @@ -166,18 +167,25 @@ export function ReauthenticateStep({ reauthOptions, reauthMethod, onReauthMethodChange, - onAuthenticated, + onWebauthnResponse, onClose, }: ChangePasswordWizardStepProps) { const [reauthenticateAttempt, reauthenticate] = useAsync( async (m: ReauthenticationMethod) => { if (m === 'passwordless' || m === 'mfaDevice') { - const res = await auth.fetchWebAuthnChallenge({ + const challenge = await auth.getMfaChallenge({ scope: MfaChallengeScope.CHANGE_PASSWORD, userVerificationRequirement: m === 'passwordless' ? 'required' : 'discouraged', }); - onAuthenticated(res); + + const response = await auth.getMfaChallengeResponse( + challenge, + 'webauthn' + ); + + // TODO(Joerger): handle non-webauthn response. + onWebauthnResponse(response.webauthn_response); } next(); } @@ -225,7 +233,7 @@ export function ReauthenticateStep({ } interface ChangePasswordStepProps { - credential: Credential; + webauthnResponse: WebauthnAssertionResponse; reauthMethod: ReauthenticationMethod; onClose(): void; onSuccess(): void; @@ -236,7 +244,7 @@ export function ChangePasswordStep({ prev, stepIndex, flowLength, - credential, + webauthnResponse, reauthMethod, onClose, onSuccess, @@ -275,7 +283,7 @@ export function ChangePasswordStep({ oldPassword, newPassword, secondFactorToken: authCode, - credential, + webauthnResponse, }); } diff --git a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts index 287bb9a77afc6..7c6ec1917673f 100644 --- a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts +++ b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts @@ -48,14 +48,15 @@ export default function useManageDevices(ctx: Ctx) { async function onAddDevice(usage: DeviceUsage) { setNewDeviceUsage(usage); - const response = await auth.getChallenge({ + const challenge = await auth.getMfaChallenge({ scope: MfaChallengeScope.MANAGE_DEVICES, }); // If the user doesn't receieve any challenges from the backend, that means // they have no valid devices to be challenged and should instead use a privilege token // to add a new device. // TODO (avatus): add SSO challenge here as well when we add SSO for MFA - if (!response.webauthnPublicKey?.challenge && !response.totpChallenge) { + // TODO(Joerger): privilege token is no longer required to add first device. + if (!challenge) { createRestrictedTokenAttempt.run(() => auth.createRestrictedPrivilegeToken().then(token => { setToken(token); diff --git a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts index 220b925d3996f..478ccbcc5fa59 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts @@ -35,15 +35,21 @@ export default function useGetScpUrl(addMfaToScpUrls: boolean) { return cfg.getScpUrl(params); } try { - let webauthn = await auth.getWebauthnResponse( - MfaChallengeScope.USER_SESSION + const challenge = await auth.getMfaChallenge({ + scope: MfaChallengeScope.USER_SESSION, + }); + + const response = await auth.getMfaChallengeResponse( + challenge, + 'webauthn' ); + setAttempt({ status: 'success', statusText: '', }); return cfg.getScpUrl({ - webauthn, + webauthn: response.webauthn_response, ...params, }); } catch (error) { diff --git a/web/packages/teleport/src/Users/useUsers.ts b/web/packages/teleport/src/Users/useUsers.ts index 5d60ed265b840..d23da34ff659a 100644 --- a/web/packages/teleport/src/Users/useUsers.ts +++ b/web/packages/teleport/src/Users/useUsers.ts @@ -87,16 +87,12 @@ export default function useUsers({ } async function onCreate(u: User) { - const webauthnResponse = await auth.getWebauthnResponseForAdminAction(true); + const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); return ctx.userService - .createUser(u, ExcludeUserField.Traits, webauthnResponse) + .createUser(u, ExcludeUserField.Traits, mfaResponse) .then(result => setUsers([result, ...users])) .then(() => - ctx.userService.createResetPasswordToken( - u.name, - 'invite', - webauthnResponse - ) + ctx.userService.createResetPasswordToken(u.name, 'invite', mfaResponse) ); } diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts index d6500860c8ae4..7b2746de0a25c 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts +++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts @@ -58,11 +58,10 @@ export default function useReAuthenticate(props: Props) { if ('onMfaResponse' in props) { auth - .getWebauthnResponse(props.challengeScope) - .then(webauthnResponse => - props.onMfaResponse({ webauthn_response: webauthnResponse }) - ) + .getMfaChallenge({ scope: props.challengeScope }) + .then(challenge => auth.getMfaChallengeResponse(challenge, 'webauthn')) .catch(handleError); + return; } diff --git a/web/packages/teleport/src/lib/useMfa.ts b/web/packages/teleport/src/lib/useMfa.ts index d5c82e678d3b9..664016790e002 100644 --- a/web/packages/teleport/src/lib/useMfa.ts +++ b/web/packages/teleport/src/lib/useMfa.ts @@ -20,14 +20,12 @@ import { useState, useEffect, useCallback } from 'react'; import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender'; import { TermEvent } from 'teleport/lib/term/enums'; -import { - parseMfaChallengeJson as parseMfaChallenge, - makeWebauthnAssertionResponse, -} from 'teleport/services/mfa/makeMfa'; +import { parseMfaChallengeJson as parseMfaChallenge } from 'teleport/services/mfa/makeMfa'; import { MfaAuthenticateChallengeJson, SSOChallenge, } from 'teleport/services/mfa'; +import auth from 'teleport/services/auth/auth'; export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { const [state, setState] = useState<{ @@ -86,16 +84,17 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { return; } - navigator.credentials - .get({ publicKey: state.webauthnPublicKey }) + auth + .getMfaChallengeResponse({ + webauthnPublicKey: state.webauthnPublicKey, + }) .then(res => { setState(prevState => ({ ...prevState, errorText: '', webauthnPublicKey: null, })); - const credential = makeWebauthnAssertionResponse(res); - emitterSender.sendWebAuthn(credential); + emitterSender.sendWebAuthn(res.webauthn_response); }) .catch((err: Error) => { setErrorText(err.message); diff --git a/web/packages/teleport/src/services/api/api.test.ts b/web/packages/teleport/src/services/api/api.test.ts index ec5787bb56451..ec6b26a27de2c 100644 --- a/web/packages/teleport/src/services/api/api.test.ts +++ b/web/packages/teleport/src/services/api/api.test.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { MfaChallengeResponse } from '../mfa'; + import api, { MFA_HEADER, defaultRequestOptions, @@ -26,18 +28,20 @@ import api, { describe('api.fetch', () => { const mockedFetch = jest.spyOn(global, 'fetch').mockResolvedValue({} as any); // we don't care about response - const webauthnResp = { - id: 'some-id', - type: 'some-type', - extensions: { - appid: false, - }, - rawId: 'some-raw-id', - response: { - authenticatorData: 'authen-data', - clientDataJSON: 'client-data-json', - signature: 'signature', - userHandle: 'user-handle', + const mfaResp: MfaChallengeResponse = { + webauthn_response: { + id: 'some-id', + type: 'some-type', + extensions: { + appid: false, + }, + rawId: 'some-raw-id', + response: { + authenticatorData: 'authen-data', + clientDataJSON: 'client-data-json', + signature: 'signature', + userHandle: 'user-handle', + }, }, }; @@ -88,7 +92,7 @@ describe('api.fetch', () => { }); test('with webauthnResponse', async () => { - await api.fetch('/something', undefined, webauthnResp); + await api.fetch('/something', undefined, mfaResp); expect(mockedFetch).toHaveBeenCalledTimes(1); const firstCall = mockedFetch.mock.calls[0]; @@ -100,14 +104,14 @@ describe('api.fetch', () => { ...defaultRequestOptions.headers, ...getAuthHeaders(), [MFA_HEADER]: JSON.stringify({ - webauthnAssertionResponse: webauthnResp, + webauthnAssertionResponse: mfaResp.webauthn_response, }), }, }); }); test('with customOptions and webauthnResponse', async () => { - await api.fetch('/something', customOpts, webauthnResp); + await api.fetch('/something', customOpts, mfaResp); expect(mockedFetch).toHaveBeenCalledTimes(1); const firstCall = mockedFetch.mock.calls[0]; @@ -120,7 +124,7 @@ describe('api.fetch', () => { ...customOpts.headers, ...getAuthHeaders(), [MFA_HEADER]: JSON.stringify({ - webauthnAssertionResponse: webauthnResp, + webauthnAssertionResponse: mfaResp.webauthn_response, }), }, }); diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index 9491a3bfbafd5..1048c3333e11c 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -21,7 +21,7 @@ import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; import websession from 'teleport/services/websession'; import { storageService } from '../storageService'; -import { WebauthnAssertionResponse } from '../mfa'; +import { MfaChallengeResponse } from '../mfa'; import parseError, { ApiError } from './parseError'; @@ -32,7 +32,7 @@ const api = { return api.fetchJsonWithMfaAuthnRetry(url, { signal: abortSignal }); }, - post(url, data?, abortSignal?, webauthnResponse?: WebauthnAssertionResponse) { + post(url, data?, abortSignal?, mfaResponse?: MfaChallengeResponse) { return api.fetchJsonWithMfaAuthnRetry( url, { @@ -40,11 +40,11 @@ const api = { method: 'POST', signal: abortSignal, }, - webauthnResponse + mfaResponse ); }, - postFormData(url, formData, webauthnResponse?: WebauthnAssertionResponse) { + postFormData(url, formData, mfaResponse?: MfaChallengeResponse) { if (formData instanceof FormData) { return api.fetchJsonWithMfaAuthnRetry( url, @@ -60,21 +60,21 @@ const api = { // 2) https://stackoverflow.com/a/64653976 }, }, - webauthnResponse + mfaResponse ); } throw new Error('data for body is not a type of FormData'); }, - delete(url, data?, webauthnResponse?: WebauthnAssertionResponse) { + delete(url, data?, mfaResponse?: MfaChallengeResponse) { return api.fetchJsonWithMfaAuthnRetry( url, { body: JSON.stringify(data), method: 'DELETE', }, - webauthnResponse + mfaResponse ); }, @@ -82,7 +82,7 @@ const api = { url, headers?: Record, signal?, - webauthnResponse?: WebauthnAssertionResponse + mfaResponse?: MfaChallengeResponse ) { return api.fetchJsonWithMfaAuthnRetry( url, @@ -91,19 +91,19 @@ const api = { headers, signal, }, - webauthnResponse + mfaResponse ); }, // TODO (avatus) add abort signal to this - put(url, data, webauthnResponse?: WebauthnAssertionResponse) { + put(url, data, mfaResponse?: MfaChallengeResponse) { return api.fetchJsonWithMfaAuthnRetry( url, { body: JSON.stringify(data), method: 'PUT', }, - webauthnResponse + mfaResponse ); }, @@ -111,7 +111,7 @@ const api = { url, data, headers?: Record, - webauthnResponse?: WebauthnAssertionResponse + mfaResponse?: MfaChallengeResponse ) { return api.fetchJsonWithMfaAuthnRetry( url, @@ -120,7 +120,7 @@ const api = { method: 'PUT', headers, }, - webauthnResponse + mfaResponse ); }, @@ -140,9 +140,9 @@ const api = { async fetchJsonWithMfaAuthnRetry( url: string, customOptions: RequestInit, - webauthnResponse?: WebauthnAssertionResponse + mfaResponse?: MfaChallengeResponse ): Promise { - const response = await api.fetch(url, customOptions, webauthnResponse); + const response = await api.fetch(url, customOptions, mfaResponse); let json; try { @@ -174,26 +174,27 @@ const api = { const isAdminActionMfaError = isAdminActionRequiresMfaError( parseError(json) ); - const shouldRetry = isAdminActionMfaError && !webauthnResponse; + const shouldRetry = isAdminActionMfaError && !mfaResponse; if (!shouldRetry) { throw new ApiError(parseError(json), response, undefined, json.messages); } - let webauthnResponseForRetry; + let mfaResponseForRetry; try { - webauthnResponseForRetry = await auth.getWebauthnResponse( - MfaChallengeScope.ADMIN_ACTION - ); - } catch (err) { + const challenge = await auth.getMfaChallenge({ + scope: MfaChallengeScope.ADMIN_ACTION, + }); + mfaResponseForRetry = await auth.getMfaChallengeResponse(challenge); + } catch { throw new Error( - 'Failed to fetch webauthn credentials, please connect a registered hardware key and try again. If you do not have a hardware key registered, you can add one from your account settings page.' + 'Failed to fetch MFA challenge. Please connect a registered hardware key and try again. If you do not have a hardware key registered, you can add one from your account settings page.' ); } return api.fetchJsonWithMfaAuthnRetry( url, customOptions, - webauthnResponseForRetry + mfaResponseForRetry ); }, @@ -242,7 +243,7 @@ const api = { fetch( url: string, customOptions: RequestInit = {}, - webauthnResponse?: WebauthnAssertionResponse + mfaResponse?: MfaChallengeResponse ) { url = window.location.origin + url; const options = { @@ -255,9 +256,10 @@ const api = { ...getAuthHeaders(), }; - if (webauthnResponse) { + if (mfaResponse) { options.headers[MFA_HEADER] = JSON.stringify({ - webauthnAssertionResponse: webauthnResponse, + // TODO(Joerger): Handle non-webauthn response. + webauthnAssertionResponse: mfaResponse.webauthn_response, }); } diff --git a/web/packages/teleport/src/services/apps/apps.ts b/web/packages/teleport/src/services/apps/apps.ts index a301147f8730f..d64f37414a872 100644 --- a/web/packages/teleport/src/services/apps/apps.ts +++ b/web/packages/teleport/src/services/apps/apps.ts @@ -49,20 +49,23 @@ const service = { }; // Prompt for MFA if per-session MFA is required for this app. - const webauthnResponse = await auth.getWebauthnResponse( - MfaChallengeScope.USER_SESSION, - false, - { + const challenge = await auth.getMfaChallenge({ + scope: MfaChallengeScope.USER_SESSION, + allowReuse: false, + isMfaRequiredRequest: { app: resolveApp, - } - ); + }, + }); + + const resp = await auth.getMfaChallengeResponse(challenge); const createAppSession = { ...resolveApp, arn: params.arn, - mfa_response: webauthnResponse + // TODO(Joerger): Handle non-webauthn response. + mfa_response: resp ? JSON.stringify({ - webauthnAssertionResponse: webauthnResponse, + webauthnAssertionResponse: resp.webauthn_response, }) : null, }; diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index 0d126151e20bf..f0f30f7356b6d 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -18,7 +18,12 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; -import { DeviceType, DeviceUsage } from 'teleport/services/mfa'; +import { + DeviceType, + DeviceUsage, + MfaAuthenticateChallenge, + MfaChallengeResponse, +} from 'teleport/services/mfa'; import { CaptureEvent, userEventService } from 'teleport/services/userEvent'; @@ -207,14 +212,13 @@ const auth = { oldPassword, newPassword, secondFactorToken, - credential, + webauthnResponse, }: ChangePasswordReq) { const data = { old_password: base64EncodeUnicode(oldPassword), new_password: base64EncodeUnicode(newPassword), second_factor_token: secondFactorToken, - webauthnAssertionResponse: - credential && makeWebauthnAssertionResponse(credential), + webauthnAssertionResponse: webauthnResponse, }; return api.put(cfg.api.changeUserPasswordPath, data); @@ -235,11 +239,12 @@ const auth = { headlessSSOAccept(transactionId: string) { return auth - .fetchWebAuthnChallenge({ scope: MfaChallengeScope.HEADLESS_LOGIN }) + .getMfaChallenge({ scope: MfaChallengeScope.HEADLESS_LOGIN }) + .then(challenge => auth.getMfaChallengeResponse(challenge, 'webauthn')) .then(res => { const request = { action: 'accept', - webauthnAssertionResponse: makeWebauthnAssertionResponse(res), + webauthnAssertionResponse: res.webauthn_response, }; return api.put(cfg.getHeadlessSsoPath(transactionId), request); @@ -258,7 +263,9 @@ const auth = { return api.post(cfg.api.createPrivilegeTokenPath, { secondFactorToken }); }, - async getChallenge( + // getChallenge gets an MFA challenge for the provided parameters. If is_mfa_required_req + // is provided and it is found that MFA is not required, returns null instead. + async getMfaChallenge( req: CreateAuthenticateChallengeRequest, abortSignal?: AbortSignal ) { @@ -266,47 +273,75 @@ const auth = { .post( cfg.api.mfaAuthnChallengePath, { + is_mfa_required_req: req.isMfaRequiredRequest, challenge_scope: req.scope, + challenge_allow_reuse: req.allowReuse, + user_verification_requirement: req.userVerificationRequirement, }, abortSignal ) .then(parseMfaChallengeJson); }, - async fetchWebAuthnChallenge( - req: CreateAuthenticateChallengeRequest, - abortSignal?: AbortSignal - ) { - return auth - .checkWebauthnSupport() - .then(() => - api - .post( - cfg.api.mfaAuthnChallengePath, - { - is_mfa_required_req: req.isMfaRequiredRequest, - challenge_scope: req.scope, - challenge_allow_reuse: req.allowReuse, - user_verification_requirement: req.userVerificationRequirement, - }, - abortSignal - ) - .then(parseMfaChallengeJson) - ) - .then(res => - navigator.credentials.get({ - publicKey: res.webauthnPublicKey, + // getChallengeResponse gets an MFA challenge response for the provided parameters. + // If is_mfa_required_req is provided and it is found that MFA is not required, returns null instead. + async getMfaChallengeResponse( + challenge: MfaAuthenticateChallenge, + mfaType?: DeviceType, + totpCode?: string + ): Promise { + // TODO(Joerger): If mfaType is not provided by a parent component, use some global context + // to display a component, similar to the one used in useMfa. For now we just default to + // whichever method we can succeed with first. + if (!mfaType) { + if (totpCode) { + mfaType = 'totp'; + } else if (challenge.webauthnPublicKey) { + mfaType = 'webauthn'; + } + } + + if (mfaType === 'webauthn') { + return auth.getWebAuthnChallengeResponse(challenge.webauthnPublicKey); + } + + if (mfaType === 'totp') { + return { + totp_code: totpCode, + }; + } + + // No viable challenge, return empty response. + return null; + }, + + async getWebAuthnChallengeResponse( + webauthnPublicKey: PublicKeyCredentialRequestOptions + ): Promise { + return auth.checkWebauthnSupport().then(() => + navigator.credentials + .get({ + publicKey: webauthnPublicKey, }) - ); + .then(cred => { + return makeWebauthnAssertionResponse(cred); + }) + .then(resp => { + return { webauthn_response: resp }; + }) + ); }, + // TODO(Joerger): Combine with otp endpoint. createPrivilegeTokenWithWebauthn() { // Creating privilege tokens always expects the MANAGE_DEVICES webauthn scope. return auth - .fetchWebAuthnChallenge({ scope: MfaChallengeScope.MANAGE_DEVICES }) + .getMfaChallenge({ scope: MfaChallengeScope.MANAGE_DEVICES }) + .then(auth.getMfaChallengeResponse) .then(res => api.post(cfg.api.createPrivilegeTokenPath, { - webauthnAssertionResponse: makeWebauthnAssertionResponse(res), + // TODO(Joerger): Handle non-webauthn challenges. + webauthnAssertionResponse: res.webauthn_response, }) ); }, @@ -315,6 +350,7 @@ const auth = { return api.post(cfg.api.createPrivilegeTokenPath, {}); }, + // TODO(Joerger): Remove once /e is no longer using it. async getWebauthnResponse( scope: MfaChallengeScope, allowReuse?: boolean, @@ -349,27 +385,32 @@ const auth = { } return auth - .fetchWebAuthnChallenge( - { scope, allowReuse, isMfaRequiredRequest }, - abortSignal - ) - .then(res => makeWebauthnAssertionResponse(res)); + .getMfaChallenge({ scope, allowReuse, isMfaRequiredRequest }, abortSignal) + .then(challenge => auth.getMfaChallengeResponse(challenge, 'webauthn')) + .then(res => res.webauthn_response); }, - getWebauthnResponseForAdminAction(allowReuse?: boolean) { + getMfaChallengeResponseForAdminAction(allowReuse?: boolean) { // If the client is checking if MFA is required for an admin action, // but we know admin action MFA is not enforced, return early. if (!cfg.isAdminActionMfaEnforced()) { return; } - return auth.getWebauthnResponse( - MfaChallengeScope.ADMIN_ACTION, - allowReuse, - { - admin_action: {}, - } - ); + return auth + .getMfaChallenge({ + scope: MfaChallengeScope.ADMIN_ACTION, + allowReuse: allowReuse, + isMfaRequiredRequest: { + admin_action: {}, + }, + }) + .then(auth.getMfaChallengeResponse); + }, + + // TODO(Joerger): Delete in favor of getMfaChallengeResponseForAdminAction once /e is updated. + getWebauthnResponseForAdminAction(allowReuse?: boolean) { + return auth.getMfaChallengeResponseForAdminAction(allowReuse); }, }; diff --git a/web/packages/teleport/src/services/auth/types.ts b/web/packages/teleport/src/services/auth/types.ts index ae9818ef2ebb7..7c74f666d9db1 100644 --- a/web/packages/teleport/src/services/auth/types.ts +++ b/web/packages/teleport/src/services/auth/types.ts @@ -18,7 +18,7 @@ import { EventMeta } from 'teleport/services/userEvent'; -import { DeviceUsage } from '../mfa'; +import { DeviceUsage, WebauthnAssertionResponse } from '../mfa'; import { IsMfaRequiredRequest, MfaChallengeScope } from './auth'; @@ -74,7 +74,7 @@ export type ChangePasswordReq = { oldPassword: string; newPassword: string; secondFactorToken: string; - credential?: Credential; + webauthnResponse?: WebauthnAssertionResponse; }; export type CreateNewHardwareDeviceRequest = { diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 139b684bd0796..3d15372a1b17d 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -20,7 +20,7 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; import makeNode from '../nodes/makeNode'; -import auth from '../auth/auth'; +import auth, { MfaChallengeScope } from '../auth/auth'; import { App } from '../apps'; import makeApp from '../apps/makeApps'; @@ -271,14 +271,22 @@ export const integrationService = { integrationName, req: AwsOidcDeployServiceRequest ): Promise { - const webauthnResponse = await auth.getWebauthnResponseForAdminAction(true); + const challenge = await auth.getMfaChallenge({ + scope: MfaChallengeScope.ADMIN_ACTION, + allowReuse: true, + isMfaRequiredRequest: { + admin_action: {}, + }, + }); + + const response = await auth.getMfaChallengeResponse(challenge); return api .post( cfg.getAwsDeployTeleportServiceUrl(integrationName), req, null, - webauthnResponse + response ) .then(resp => resp.serviceDashboardUrl); }, @@ -293,14 +301,14 @@ export const integrationService = { integrationName, req: AwsOidcDeployDatabaseServicesRequest ): Promise { - const webauthnResponse = await auth.getWebauthnResponseForAdminAction(true); + const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); return api .post( cfg.getAwsRdsDbsDeployServicesUrl(integrationName), req, null, - webauthnResponse + mfaResponse ) .then(resp => resp.clusterDashboardUrl); }, @@ -309,13 +317,13 @@ export const integrationService = { integrationName: string, req: EnrollEksClustersRequest ): Promise { - const webauthnResponse = await auth.getWebauthnResponseForAdminAction(true); + const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); return api.post( cfg.getEnrollEksClusterUrl(integrationName), req, null, - webauthnResponse + mfaResponse ); }, diff --git a/web/packages/teleport/src/services/mfa/makeMfa.ts b/web/packages/teleport/src/services/mfa/makeMfa.ts index 0ec7c28f88071..505a972fe33e5 100644 --- a/web/packages/teleport/src/services/mfa/makeMfa.ts +++ b/web/packages/teleport/src/services/mfa/makeMfa.ts @@ -64,6 +64,14 @@ export function parseMfaRegistrationChallengeJson( export function parseMfaChallengeJson( challenge: MfaAuthenticateChallengeJson ): MfaAuthenticateChallenge { + if ( + !challenge.sso_challenge && + !challenge.webauthn_challenge && + !challenge.totp_challenge + ) { + return null; + } + // WebAuthn challenge contains Base64URL(byte) fields that needs to // be converted to ArrayBuffer expected by navigator.credentials.get: // - challenge diff --git a/web/packages/teleport/src/services/user/user.ts b/web/packages/teleport/src/services/user/user.ts index fc897d31fbcdc..ce2d07c22034e 100644 --- a/web/packages/teleport/src/services/user/user.ts +++ b/web/packages/teleport/src/services/user/user.ts @@ -20,7 +20,7 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; import session from 'teleport/services/websession'; -import { WebauthnAssertionResponse } from '../mfa'; +import { MfaChallengeResponse } from '../mfa'; import makeUserContext from './makeUserContext'; import { makeResetToken } from './makeResetToken'; @@ -86,14 +86,14 @@ const service = { createUser( user: User, excludeUserField: ExcludeUserField, - webauthnResponse?: WebauthnAssertionResponse + mfaResponse?: MfaChallengeResponse ) { return api .post( cfg.getUsersUrl(), withExcludedField(user, excludeUserField), null, - webauthnResponse + mfaResponse ) .then(makeUser); }, @@ -101,15 +101,10 @@ const service = { createResetPasswordToken( name: string, type: ResetPasswordType, - webauthnResponse?: WebauthnAssertionResponse + mfaResponse?: MfaChallengeResponse ) { return api - .post( - cfg.api.resetPasswordTokenPath, - { name, type }, - null, - webauthnResponse - ) + .post(cfg.api.resetPasswordTokenPath, { name, type }, null, mfaResponse) .then(makeResetToken); },