From 21cdf9e1d130a6cf262195add583cc26e5de1d55 Mon Sep 17 00:00:00 2001 From: Brian Joerger Date: Mon, 9 Dec 2024 12:57:12 -0800 Subject: [PATCH] WebUI MFA types refactor (#49678) * Unify mfa types. * Move DeviceUsage to mfa/types. * Refactor mfa options and move to services/mfa. * Lint fix. * Fix mfa challenge json. * Add MFA Option constants. * Fix lint; Fix test. * Address comments. * Add license. --- web/packages/shared/utils/createMfaOptions.ts | 2 + web/packages/teleport/src/Account/Account.tsx | 4 +- .../Account/ManageDevices/useManageDevices.ts | 4 +- .../wizards/AddAuthDeviceWizard.story.tsx | 3 +- .../wizards/AddAuthDeviceWizard.tsx | 3 +- .../TestConnection/TestConnection.tsx | 4 +- .../TestConnection/TestConnection.tsx | 4 +- .../TestConnection/useTestConnection.ts | 4 +- .../Server/TestConnection/TestConnection.tsx | 4 +- .../useConnectionDiagnostic.ts | 4 +- .../src/Welcome/NewCredentials/types.ts | 3 +- web/packages/teleport/src/Welcome/useToken.ts | 2 +- .../AuthnDialog/AuthnDialog.test.tsx | 3 +- .../ReAuthenticate/useReAuthenticate.ts | 4 +- web/packages/teleport/src/config.ts | 2 +- .../teleport/src/lib/EventEmitterMfaSender.ts | 2 +- web/packages/teleport/src/lib/tdp/client.ts | 2 +- web/packages/teleport/src/lib/term/tty.ts | 7 +- web/packages/teleport/src/lib/useMfa.ts | 13 +- .../teleport/src/services/agents/types.ts | 4 +- web/packages/teleport/src/services/api/api.ts | 2 +- .../teleport/src/services/auth/auth.ts | 24 +-- .../teleport/src/services/auth/index.ts | 2 +- .../teleport/src/services/auth/makeMfa.ts | 167 ------------------ .../teleport/src/services/auth/types.ts | 34 +--- .../teleport/src/services/mfa/index.ts | 1 + .../teleport/src/services/mfa/makeMfa.ts | 159 +++++++++++++++++ .../src/services/mfa/mfaOptions.test.ts | 105 +++++++++++ .../teleport/src/services/mfa/mfaOptions.ts | 78 ++++++++ .../teleport/src/services/mfa/types.ts | 92 +++++++++- .../teleport/src/services/user/user.ts | 2 +- 31 files changed, 489 insertions(+), 255 deletions(-) delete mode 100644 web/packages/teleport/src/services/auth/makeMfa.ts create mode 100644 web/packages/teleport/src/services/mfa/makeMfa.ts create mode 100644 web/packages/teleport/src/services/mfa/mfaOptions.test.ts create mode 100644 web/packages/teleport/src/services/mfa/mfaOptions.ts diff --git a/web/packages/shared/utils/createMfaOptions.ts b/web/packages/shared/utils/createMfaOptions.ts index ecfec092a326d..da5d47541ffef 100644 --- a/web/packages/shared/utils/createMfaOptions.ts +++ b/web/packages/shared/utils/createMfaOptions.ts @@ -18,6 +18,8 @@ import { Auth2faType, PreferredMfaType } from 'shared/services/types'; +// Deprecated: use getMfaRegisterOptions or getMfaChallengeOptions instead. +// TODO(Joerger): Delete once no longer used. export default function createMfaOptions(opts: Options) { const { auth2faType, required = false } = opts; const mfaOptions: MfaOption[] = []; diff --git a/web/packages/teleport/src/Account/Account.tsx b/web/packages/teleport/src/Account/Account.tsx index ca86669ac0f1c..9cd8c47720e2b 100644 --- a/web/packages/teleport/src/Account/Account.tsx +++ b/web/packages/teleport/src/Account/Account.tsx @@ -38,10 +38,10 @@ import { import cfg from 'teleport/config'; -import { DeviceUsage } from 'teleport/services/auth'; - import { PasswordState } from 'teleport/services/user'; +import { DeviceUsage } from 'teleport/services/mfa'; + import { AuthDeviceList } from './ManageDevices/AuthDeviceList/AuthDeviceList'; import useManageDevices, { State as ManageDevicesState, diff --git a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts index 95929bf9d9714..287bb9a77afc6 100644 --- a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts +++ b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts @@ -21,8 +21,8 @@ import useAttempt from 'shared/hooks/useAttemptNext'; import Ctx from 'teleport/teleportContext'; import cfg from 'teleport/config'; -import auth, { DeviceUsage } from 'teleport/services/auth'; -import { MfaDevice } from 'teleport/services/mfa'; +import auth from 'teleport/services/auth'; +import { DeviceUsage, MfaDevice } from 'teleport/services/mfa'; import { MfaChallengeScope } from 'teleport/services/auth/auth'; export default function useManageDevices(ctx: Ctx) { diff --git a/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.story.tsx b/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.story.tsx index 2598c48acd9b4..46b2bfc235e02 100644 --- a/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.story.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.story.tsx @@ -24,12 +24,13 @@ import Dialog from 'design/Dialog'; import { http, HttpResponse, delay } from 'msw'; -import { DeviceUsage } from 'teleport/services/auth'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { ContextProvider } from 'teleport/index'; import cfg from 'teleport/config'; +import { DeviceUsage } from 'teleport/services/mfa'; + import { AddAuthDeviceWizardStepProps, CreateDeviceStep, diff --git a/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx index 800d7078da822..79cabebb57a1d 100644 --- a/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/wizards/AddAuthDeviceWizard.tsx @@ -40,10 +40,9 @@ import { StepHeader } from 'design/StepSlider'; import { P } from 'design/Text/Text'; import auth from 'teleport/services/auth/auth'; -import { DeviceUsage } from 'teleport/services/auth'; import useTeleport from 'teleport/useTeleport'; -import { MfaDevice } from 'teleport/services/mfa'; +import { DeviceUsage, MfaDevice } from 'teleport/services/mfa'; import { PasskeyBlurb } from '../../../components/Passkeys/PasskeyBlurb'; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx index 3c8084966bd5c..1b5ae9e6a780a 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx @@ -57,7 +57,7 @@ import { NodeMeta } from '../../useDiscover'; import type { Option } from 'shared/components/Select'; import type { AgentStepProps } from '../../types'; -import type { MfaAuthnResponse } from 'teleport/services/mfa'; +import type { MfaChallengeResponse } from 'teleport/services/mfa'; import type { ConnectionDiagnosticRequest } from 'teleport/services/agents'; export function TestConnection(props: AgentStepProps) { @@ -144,7 +144,7 @@ export function TestConnection(props: AgentStepProps) { function testConnection(args: { login: string; sshPrincipalSelectionMode: ConnectionDiagnosticRequest['sshPrincipalSelectionMode']; - mfaResponse?: MfaAuthnResponse; + mfaResponse?: MfaChallengeResponse; }) { return runConnectionDiagnostic( { diff --git a/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx index 20ce37bbea8c0..5faaf84b6080a 100644 --- a/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Database/TestConnection/TestConnection.tsx @@ -32,7 +32,7 @@ import { CustomInputFieldForAsterisks } from 'teleport/Discover/Shared/CustomInp import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; -import { MfaAuthnResponse } from 'teleport/services/mfa'; +import { MfaChallengeResponse } from 'teleport/services/mfa'; import { WILD_CARD } from 'teleport/Discover/Shared/const'; import { @@ -93,7 +93,7 @@ export function TestConnection() { function testConnection( validator: Validator, - mfaResponse?: MfaAuthnResponse + mfaResponse?: MfaChallengeResponse ) { if (!validator.validate()) { return; diff --git a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts index 4c182604a85af..2ceeeba7cecd1 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts +++ b/web/packages/teleport/src/Discover/Kubernetes/TestConnection/useTestConnection.ts @@ -22,7 +22,7 @@ import { KubeMeta } from '../../useDiscover'; import type { KubeImpersonation } from 'teleport/services/agents'; import type { AgentStepProps } from '../../types'; -import type { MfaAuthnResponse } from 'teleport/services/mfa'; +import type { MfaChallengeResponse } from 'teleport/services/mfa'; /** * @deprecated Refactor Discover/Kubernetes/TestConnection away from the container component @@ -34,7 +34,7 @@ export function useTestConnection(props: AgentStepProps) { function testConnection( impersonate: KubeImpersonation, - mfaResponse?: MfaAuthnResponse + mfaResponse?: MfaChallengeResponse ) { runConnectionDiagnostic( { diff --git a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx index 0df0fd0c42a09..54195cb3e6cc1 100644 --- a/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/Server/TestConnection/TestConnection.tsx @@ -39,7 +39,7 @@ import { NodeMeta } from '../../useDiscover'; import type { Option } from 'shared/components/Select'; import type { AgentStepProps } from '../../types'; -import type { MfaAuthnResponse } from 'teleport/services/mfa'; +import type { MfaChallengeResponse } from 'teleport/services/mfa'; export function TestConnection(props: AgentStepProps) { const { @@ -65,7 +65,7 @@ export function TestConnection(props: AgentStepProps) { openNewTab(url); } - function testConnection(login: string, mfaResponse?: MfaAuthnResponse) { + function testConnection(login: string, mfaResponse?: MfaChallengeResponse) { runConnectionDiagnostic( { resourceKind: 'node', diff --git a/web/packages/teleport/src/Discover/Shared/ConnectionDiagnostic/useConnectionDiagnostic.ts b/web/packages/teleport/src/Discover/Shared/ConnectionDiagnostic/useConnectionDiagnostic.ts index ddedd3ffb7a42..3f5b7fc95097b 100644 --- a/web/packages/teleport/src/Discover/Shared/ConnectionDiagnostic/useConnectionDiagnostic.ts +++ b/web/packages/teleport/src/Discover/Shared/ConnectionDiagnostic/useConnectionDiagnostic.ts @@ -30,7 +30,7 @@ import type { ConnectionDiagnostic, ConnectionDiagnosticRequest, } from 'teleport/services/agents'; -import type { MfaAuthnResponse } from 'teleport/services/mfa'; +import type { MfaChallengeResponse } from 'teleport/services/mfa'; import type { ResourceSpec } from 'teleport/Discover/SelectResource'; export function useConnectionDiagnostic() { @@ -60,7 +60,7 @@ export function useConnectionDiagnostic() { */ async function runConnectionDiagnostic( req: ConnectionDiagnosticRequest, - mfaAuthnResponse?: MfaAuthnResponse + mfaAuthnResponse?: MfaChallengeResponse ): Promise<{ mfaRequired: boolean }> { setDiagnosis(null); // reset since user's can re-test connection. setRanDiagnosis(true); diff --git a/web/packages/teleport/src/Welcome/NewCredentials/types.ts b/web/packages/teleport/src/Welcome/NewCredentials/types.ts index 410480c318183..b88ff36b6396f 100644 --- a/web/packages/teleport/src/Welcome/NewCredentials/types.ts +++ b/web/packages/teleport/src/Welcome/NewCredentials/types.ts @@ -24,8 +24,9 @@ import { NewFlow, StepComponentProps } from 'design/StepSlider'; import { ReactElement } from 'react'; -import { DeviceUsage, RecoveryCodes, ResetToken } from 'teleport/services/auth'; +import { RecoveryCodes, ResetToken } from 'teleport/services/auth'; import { RecoveryCodesProps } from 'teleport/components/RecoveryCodes'; +import { DeviceUsage } from 'teleport/services/mfa'; export type UseTokenState = { auth2faType: Auth2faType; diff --git a/web/packages/teleport/src/Welcome/useToken.ts b/web/packages/teleport/src/Welcome/useToken.ts index cacf87ef2596d..6e00cea7e7965 100644 --- a/web/packages/teleport/src/Welcome/useToken.ts +++ b/web/packages/teleport/src/Welcome/useToken.ts @@ -23,13 +23,13 @@ import cfg from 'teleport/config'; import history from 'teleport/services/history'; import auth, { ChangedUserAuthn, - DeviceUsage, RecoveryCodes, ResetPasswordReqWithEvent, ResetPasswordWithWebauthnReqWithEvent, ResetToken, } from 'teleport/services/auth'; import { UseTokenState } from 'teleport/Welcome/NewCredentials/types'; +import { DeviceUsage } from 'teleport/services/mfa'; export default function useToken(tokenId: string): UseTokenState { const [resetToken, setResetToken] = useState(); diff --git a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx index 5066113d24400..e4752390357e7 100644 --- a/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx +++ b/web/packages/teleport/src/components/AuthnDialog/AuthnDialog.test.tsx @@ -20,7 +20,8 @@ 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 { SSOChallenge } from 'teleport/services/mfa'; import AuthnDialog from './AuthnDialog'; diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts index 3e60ee586c2dc..d6500860c8ae4 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts +++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts @@ -22,7 +22,7 @@ import cfg from 'teleport/config'; import auth from 'teleport/services/auth'; import { MfaChallengeScope } from 'teleport/services/auth/auth'; -import type { MfaAuthnResponse } from 'teleport/services/mfa'; +import type { MfaChallengeResponse } from 'teleport/services/mfa'; // useReAuthenticate will have different "submit" behaviors depending on: // - If prop field `onMfaResponse` is defined, after a user submits, the @@ -121,7 +121,7 @@ type BaseProps = { // that accepts a MFA response. No // authentication has been done at this point. type MfaResponseProps = BaseProps & { - onMfaResponse(res: MfaAuthnResponse): void; + onMfaResponse(res: MfaChallengeResponse): void; /** * The MFA challenge scope of the action to perform, as defined in webauthn.proto. */ diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 887d62c875ff5..2f1478017633d 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -34,7 +34,7 @@ import type { import type { SortType } from 'teleport/services/agents'; import type { RecordingType } from 'teleport/services/recordings'; -import type { WebauthnAssertionResponse } from './services/auth'; +import type { WebauthnAssertionResponse } from './services/mfa'; import type { PluginKind, Regions, diff --git a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts index 473ab8129221a..da30f1201e0c9 100644 --- a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts +++ b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts @@ -21,7 +21,7 @@ import { EventEmitter } from 'events'; import { MfaChallengeResponse, WebauthnAssertionResponse, -} from 'teleport/services/auth'; +} from 'teleport/services/mfa'; class EventEmitterMfaSender extends EventEmitter { constructor() { diff --git a/web/packages/teleport/src/lib/tdp/client.ts b/web/packages/teleport/src/lib/tdp/client.ts index 98e3121b5dbc7..ca18c58744124 100644 --- a/web/packages/teleport/src/lib/tdp/client.ts +++ b/web/packages/teleport/src/lib/tdp/client.ts @@ -57,7 +57,7 @@ import type { SyncKeys, SharedDirectoryTruncateResponse, } from './codec'; -import type { WebauthnAssertionResponse } from 'teleport/services/auth'; +import type { WebauthnAssertionResponse } from 'teleport/services/mfa'; export enum TdpClientEvent { TDP_CLIENT_SCREEN_SPEC = 'tdp client screen spec', diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index d5d43845b1141..6b28a592e659f 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -19,12 +19,11 @@ import Logger from 'shared/libs/logger'; import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender'; -import { - MfaChallengeResponse, - WebauthnAssertionResponse, -} from 'teleport/services/auth'; +import { WebauthnAssertionResponse } from 'teleport/services/mfa'; import { AuthenticatedWebSocket } from 'teleport/lib/AuthenticatedWebSocket'; +import { MfaChallengeResponse } from 'teleport/services/mfa'; + import { EventType, TermEvent, WebsocketCloseCode } from './enums'; import { Protobuf, MessageTypeEnum } from './protobuf'; diff --git a/web/packages/teleport/src/lib/useMfa.ts b/web/packages/teleport/src/lib/useMfa.ts index 7b4b5fc68f839..d5c82e678d3b9 100644 --- a/web/packages/teleport/src/lib/useMfa.ts +++ b/web/packages/teleport/src/lib/useMfa.ts @@ -21,10 +21,13 @@ import { useState, useEffect, useCallback } from 'react'; import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender'; import { TermEvent } from 'teleport/lib/term/enums'; import { - makeMfaAuthenticateChallenge, + parseMfaChallengeJson as parseMfaChallenge, makeWebauthnAssertionResponse, +} from 'teleport/services/mfa/makeMfa'; +import { + MfaAuthenticateChallengeJson, SSOChallenge, -} from 'teleport/services/auth'; +} from 'teleport/services/mfa'; export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { const [state, setState] = useState<{ @@ -129,8 +132,12 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { useEffect(() => { let ssoChallengeAbortController: AbortController | undefined; const challengeHandler = (challengeJson: string) => { + const challenge = JSON.parse( + challengeJson + ) as MfaAuthenticateChallengeJson; + const { webauthnPublicKey, ssoChallenge, totpChallenge } = - makeMfaAuthenticateChallenge(challengeJson); + parseMfaChallenge(challenge); setState(prevState => ({ ...prevState, diff --git a/web/packages/teleport/src/services/agents/types.ts b/web/packages/teleport/src/services/agents/types.ts index 767f43b9af79d..c96b30c6b916f 100644 --- a/web/packages/teleport/src/services/agents/types.ts +++ b/web/packages/teleport/src/services/agents/types.ts @@ -26,7 +26,7 @@ import { Desktop } from 'teleport/services/desktops'; import { UserGroup } from '../userGroups'; -import type { MfaAuthnResponse } from '../mfa'; +import type { MfaChallengeResponse } from '../mfa'; import type { Platform } from 'design/platform'; export type UnifiedResource = @@ -142,7 +142,7 @@ export type ConnectionDiagnosticRequest = { sshNodeSetupMethod?: 'script' | 'connect_my_computer'; // `json:"ssh_node_setup_method"` kubeImpersonation?: KubeImpersonation; // `json:"kubernetes_impersonation"` dbTester?: DatabaseTester; - mfaAuthnResponse?: MfaAuthnResponse; + mfaAuthnResponse?: MfaChallengeResponse; }; export type KubeImpersonation = { diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index a775cd770e14e..c48136d0023ae 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 '../auth'; +import { WebauthnAssertionResponse } from '../mfa'; import parseError, { ApiError } from './parseError'; diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index 4dec9b2ade0f0..0d126151e20bf 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -18,25 +18,25 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; -import { DeviceType } from 'teleport/services/mfa'; +import { DeviceType, DeviceUsage } from 'teleport/services/mfa'; import { CaptureEvent, userEventService } from 'teleport/services/userEvent'; -import makePasswordToken from './makePasswordToken'; -import { makeChangedUserAuthn } from './make'; import { - makeMfaAuthenticateChallenge, - makeMfaRegistrationChallenge, + parseMfaChallengeJson, + parseMfaRegistrationChallengeJson, makeWebauthnAssertionResponse, makeWebauthnCreationResponse, -} from './makeMfa'; +} from '../mfa/makeMfa'; + +import makePasswordToken from './makePasswordToken'; +import { makeChangedUserAuthn } from './make'; import { ResetPasswordReqWithEvent, ResetPasswordWithWebauthnReqWithEvent, UserCredentials, ChangePasswordReq, CreateNewHardwareDeviceRequest, - DeviceUsage, CreateAuthenticateChallengeRequest, } from './types'; @@ -65,7 +65,7 @@ const auth = { deviceType, deviceUsage, }) - .then(makeMfaRegistrationChallenge); + .then(parseMfaRegistrationChallengeJson); }, /** @@ -94,7 +94,7 @@ const auth = { createMfaAuthnChallengeWithToken(tokenId: string) { return api .post(cfg.getAuthnChallengeWithTokenUrl(tokenId)) - .then(makeMfaAuthenticateChallenge); + .then(parseMfaChallengeJson); }, // mfaLoginBegin retrieves users mfa challenges for their @@ -107,7 +107,7 @@ const auth = { user: creds?.username, pass: creds?.password, }) - .then(makeMfaAuthenticateChallenge); + .then(parseMfaChallengeJson); }, login(userId: string, password: string, otpCode: string) { @@ -270,7 +270,7 @@ const auth = { }, abortSignal ) - .then(makeMfaAuthenticateChallenge); + .then(parseMfaChallengeJson); }, async fetchWebAuthnChallenge( @@ -291,7 +291,7 @@ const auth = { }, abortSignal ) - .then(makeMfaAuthenticateChallenge) + .then(parseMfaChallengeJson) ) .then(res => navigator.credentials.get({ diff --git a/web/packages/teleport/src/services/auth/index.ts b/web/packages/teleport/src/services/auth/index.ts index 49a512d6ba553..5ad320f541ba5 100644 --- a/web/packages/teleport/src/services/auth/index.ts +++ b/web/packages/teleport/src/services/auth/index.ts @@ -18,7 +18,7 @@ import service from './auth'; -export * from './makeMfa'; +export * from '../mfa/makeMfa'; export * from './make'; export * from './types'; export default service; diff --git a/web/packages/teleport/src/services/auth/makeMfa.ts b/web/packages/teleport/src/services/auth/makeMfa.ts deleted file mode 100644 index 163bcd007b041..0000000000000 --- a/web/packages/teleport/src/services/auth/makeMfa.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * 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 . - */ - -import { base64urlToBuffer, bufferToBase64url } from 'shared/utils/base64'; - -import { MfaAuthenticateChallenge, MfaRegistrationChallenge } from './types'; - -// makeMfaRegistrationChallenge formats fetched register challenge JSON. -// Webauthn challange contains Base64URL(byte) fields that needs to -// be converted to ArrayBuffer expected by navigator.credentials.create: -// - challenge -// - user.id -// - excludeCredentials[i].id -export function makeMfaRegistrationChallenge(json): MfaRegistrationChallenge { - const webauthnPublicKey = json.webauthn?.publicKey; - if (webauthnPublicKey) { - const challenge = webauthnPublicKey.challenge || ''; - const id = webauthnPublicKey.user?.id || ''; - const excludeCredentials = webauthnPublicKey.excludeCredentials || []; - - webauthnPublicKey.challenge = base64urlToBuffer(challenge); - webauthnPublicKey.user.id = base64urlToBuffer(id); - webauthnPublicKey.excludeCredentials = excludeCredentials.map( - (credential, i) => { - excludeCredentials[i].id = base64urlToBuffer(credential.id); - return excludeCredentials[i]; - } - ); - } - - return { - qrCode: json.totp?.qrCode, - webauthnPublicKey, - }; -} - -// makeMfaAuthenticateChallenge formats fetched authenticate challenge JSON. -// Webauthn challenge contains Base64URL(byte) fields that needs to -// be converted to ArrayBuffer expected by navigator.credentials.get: -// - challenge -// - allowCredentials[i].id -export function makeMfaAuthenticateChallenge(json): MfaAuthenticateChallenge { - const challenge = typeof json === 'string' ? JSON.parse(json) : json; - const { sso_challenge, webauthn_challenge, totp_challenge } = challenge; - - const webauthnPublicKey = webauthn_challenge?.publicKey; - if (webauthnPublicKey) { - const challenge = webauthnPublicKey.challenge || ''; - const allowCredentials = webauthnPublicKey.allowCredentials || []; - - webauthnPublicKey.challenge = base64urlToBuffer(challenge); - webauthnPublicKey.allowCredentials = allowCredentials.map( - (credential, i) => { - allowCredentials[i].id = base64urlToBuffer(credential.id); - return allowCredentials[i]; - } - ); - } - - return { - ssoChallenge: sso_challenge, - totpChallenge: totp_challenge, - webauthnPublicKey: webauthnPublicKey, - }; -} - -// makeWebauthnCreationResponse takes response from navigator.credentials.create -// and creates a credential object expected by the server with ArrayBuffer -// fields converted to Base64URL: -// - rawId -// - response.attestationObject -// - response.clientDataJSON -export function makeWebauthnCreationResponse(res) { - // Response can be null if no Credential object can be created. - if (!res) { - throw new Error('error creating credential, please try again'); - } - - const clientExtentions = res.getClientExtensionResults(); - return { - id: res.id, - type: res.type, - extensions: { - appid: Boolean(clientExtentions?.appid), - credProps: clientExtentions?.credProps, - }, - rawId: bufferToBase64url(res.rawId), - response: { - attestationObject: bufferToBase64url(res.response?.attestationObject), - clientDataJSON: bufferToBase64url(res.response?.clientDataJSON), - }, - }; -} - -// makeWebauthnAssertionResponse takes response from navigator.credentials.get -// and creates a credential object expected by the server with ArrayBuffer -// fields converted to Base64URL: -// - rawId -// - response.authenticatorData -// - response.clientDataJSON -// - response.signature -// - response.userHandle -export function makeWebauthnAssertionResponse(res): WebauthnAssertionResponse { - // Response can be null if Credential cannot be unambiguously obtained. - if (!res) { - throw new Error( - 'error obtaining credential from the hardware key, please try again' - ); - } - - const clientExtentions = res.getClientExtensionResults(); - - return { - id: res.id, - type: res.type, - extensions: { - appid: Boolean(clientExtentions?.appid), - }, - rawId: bufferToBase64url(res.rawId), - response: { - authenticatorData: bufferToBase64url(res.response?.authenticatorData), - clientDataJSON: bufferToBase64url(res.response?.clientDataJSON), - signature: bufferToBase64url(res.response?.signature), - userHandle: bufferToBase64url(res.response?.userHandle), - }, - }; -} - -export type SsoChallengeResponse = { - requestId: string; - token: string; -}; - -export type WebauthnAssertionResponse = { - id: string; - type: string; - extensions: { - appid: boolean; - }; - rawId: string; - response: { - authenticatorData: string; - clientDataJSON: string; - signature: string; - 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 734cb53a53112..ae9818ef2ebb7 100644 --- a/web/packages/teleport/src/services/auth/types.ts +++ b/web/packages/teleport/src/services/auth/types.ts @@ -16,10 +16,10 @@ * along with this program. If not, see . */ -import { AuthProviderType } from 'shared/services'; - import { EventMeta } from 'teleport/services/userEvent'; +import { DeviceUsage } from '../mfa'; + import { IsMfaRequiredRequest, MfaChallengeScope } from './auth'; export type Base64urlString = string; @@ -29,33 +29,6 @@ export type UserCredentials = { password: string; }; -export type AuthnChallengeRequest = { - tokenId?: string; - userCred: UserCredentials; -}; - -export type SSOChallenge = { - channelId: string; - redirectUrl: string; - requestId: string; - device: { - connectorId: string; - connectorType: AuthProviderType; - displayName: string; - }; -}; - -export type MfaAuthenticateChallenge = { - ssoChallenge: SSOChallenge; - totpChallenge: boolean; - webauthnPublicKey: PublicKeyCredentialRequestOptions; -}; - -export type MfaRegistrationChallenge = { - qrCode: Base64urlString; - webauthnPublicKey: PublicKeyCredentialCreationOptions; -}; - export type RecoveryCodes = { codes?: string[]; createdDate: Date; @@ -108,6 +81,3 @@ export type CreateNewHardwareDeviceRequest = { tokenId: string; deviceUsage?: DeviceUsage; }; - -/** The intended usage of the device (as an MFA method or a passkey). */ -export type DeviceUsage = 'passwordless' | 'mfa'; diff --git a/web/packages/teleport/src/services/mfa/index.ts b/web/packages/teleport/src/services/mfa/index.ts index ca78004d1c6a3..724c5e785d071 100644 --- a/web/packages/teleport/src/services/mfa/index.ts +++ b/web/packages/teleport/src/services/mfa/index.ts @@ -20,3 +20,4 @@ import MfaService from './mfa'; export default MfaService; export * from './types'; +export * from './mfaOptions'; diff --git a/web/packages/teleport/src/services/mfa/makeMfa.ts b/web/packages/teleport/src/services/mfa/makeMfa.ts new file mode 100644 index 0000000000000..0ec7c28f88071 --- /dev/null +++ b/web/packages/teleport/src/services/mfa/makeMfa.ts @@ -0,0 +1,159 @@ +/** + * 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 { base64urlToBuffer, bufferToBase64url } from 'shared/utils/base64'; + +import { + MfaAuthenticateChallenge, + MfaAuthenticateChallengeJson, + MfaRegistrationChallenge, + MfaRegistrationChallengeJson, + WebauthnAssertionResponse, + WebauthnAttestationResponse, +} from './types'; + +// parseMfaRegistrationChallengeJson formats fetched register challenge JSON. +export function parseMfaRegistrationChallengeJson( + challenge: MfaRegistrationChallengeJson +): MfaRegistrationChallenge { + // WebAuthn challenge contains Base64URL(byte) fields that needs to + // be converted to ArrayBuffer expected by navigator.credentials.create: + // - challenge + // - user.id + // - excludeCredentials[i].id + const webauthnPublicKeyFromJson = ( + json: PublicKeyCredentialCreationOptionsJSON + ) => + ({ + ...json, + challenge: base64urlToBuffer(json.challenge), + user: { + ...json.user, + id: base64urlToBuffer(json.user?.id || ''), + }, + excludeCredentials: json.excludeCredentials?.map(credential => ({ + ...credential, + id: base64urlToBuffer(credential.id), + })), + }) as PublicKeyCredentialCreationOptions; + + return { + qrCode: challenge.totp?.qrCode, + webauthnPublicKey: challenge.webauthn + ? webauthnPublicKeyFromJson(challenge.webauthn.publicKey) + : null, + }; +} + +// parseMfaChallengeJson formats fetched authenticate challenge JSON. +export function parseMfaChallengeJson( + challenge: MfaAuthenticateChallengeJson +): MfaAuthenticateChallenge { + // WebAuthn challenge contains Base64URL(byte) fields that needs to + // be converted to ArrayBuffer expected by navigator.credentials.get: + // - challenge + // - allowCredentials[i].id + const webauthnPublicKeyFromJson = ( + json: PublicKeyCredentialRequestOptionsJSON + ) => + ({ + ...json, + challenge: base64urlToBuffer(json.challenge), + allowCredentials: json.allowCredentials?.map(credential => ({ + ...credential, + id: base64urlToBuffer(credential.id), + })), + }) as PublicKeyCredentialRequestOptions; + + return { + ssoChallenge: challenge.sso_challenge, + totpChallenge: challenge.totp_challenge, + webauthnPublicKey: challenge.webauthn_challenge + ? webauthnPublicKeyFromJson(challenge.webauthn_challenge.publicKey) + : null, + }; +} + +// makeWebauthnCreationResponse takes a credential returned from navigator.credentials.create +// and returns the credential attestation response. +export function makeWebauthnCreationResponse( + cred: Credential +): WebauthnAttestationResponse { + const publicKey = cred as PublicKeyCredential; + + // Response can be null if no Credential object can be created. + if (!publicKey) { + throw new Error('error creating credential, please try again'); + } + + const clientExtentions = publicKey.getClientExtensionResults(); + const attestationResponse = + publicKey.response as AuthenticatorAttestationResponse; + + return { + id: cred.id, + type: cred.type, + extensions: { + appid: Boolean(clientExtentions?.appid), + credProps: clientExtentions?.credProps, + }, + rawId: bufferToBase64url(publicKey.rawId), + response: { + attestationObject: bufferToBase64url( + attestationResponse?.attestationObject + ), + clientDataJSON: bufferToBase64url(attestationResponse?.clientDataJSON), + }, + }; +} + +// makeWebauthnAssertionResponse takes a credential returned from navigator.credentials.get +// and returns the credential assertion response. +export function makeWebauthnAssertionResponse( + cred: Credential +): WebauthnAssertionResponse { + const publicKey = cred as PublicKeyCredential; + + // Response can be null if Credential cannot be unambiguously obtained. + if (!publicKey) { + throw new Error( + 'error obtaining credential from the hardware key, please try again' + ); + } + + const clientExtentions = publicKey.getClientExtensionResults(); + const assertionResponse = + publicKey.response as AuthenticatorAssertionResponse; + + return { + id: cred.id, + type: cred.type, + extensions: { + appid: Boolean(clientExtentions?.appid), + }, + rawId: bufferToBase64url(publicKey.rawId), + response: { + authenticatorData: bufferToBase64url( + assertionResponse?.authenticatorData + ), + clientDataJSON: bufferToBase64url(assertionResponse?.clientDataJSON), + signature: bufferToBase64url(assertionResponse?.signature), + userHandle: bufferToBase64url(assertionResponse?.userHandle), + }, + }; +} diff --git a/web/packages/teleport/src/services/mfa/mfaOptions.test.ts b/web/packages/teleport/src/services/mfa/mfaOptions.test.ts new file mode 100644 index 0000000000000..5c430a05be8a8 --- /dev/null +++ b/web/packages/teleport/src/services/mfa/mfaOptions.test.ts @@ -0,0 +1,105 @@ +/** + * 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 { Auth2faType } from 'shared/services'; + +import { SSOChallenge } from 'gen-proto-ts/teleport/lib/teleterm/v1/tshd_events_service_pb'; + +import { getMfaChallengeOptions, getMfaRegisterOptions } from './mfaOptions'; +import { DeviceType, MfaAuthenticateChallenge } from './types'; + +describe('test retrieving mfa options from Auth2faType', () => { + const testCases: { + name: string; + type?: Auth2faType; + expect: DeviceType[]; + }[] = [ + { + name: 'type undefined', + expect: [], + }, + { + name: 'type on', + type: 'on', + expect: ['webauthn', 'totp'], + }, + { + name: 'type webauthn only', + type: 'webauthn', + expect: ['webauthn'], + }, + { + name: 'type otp only', + type: 'otp', + expect: ['totp'], + }, + ]; + + test.each(testCases)('$name', testCase => { + const mfa = getMfaRegisterOptions(testCase.type).map(o => o.value); + expect(mfa).toEqual(testCase.expect); + }); +}); + +describe('test retrieving mfa options from MFA Challenge', () => { + const testCases: { + name: string; + challenge?: MfaAuthenticateChallenge; + expect: DeviceType[]; + }[] = [ + { + name: 'type undefined', + expect: [], + }, + { + name: 'challenge totp', + challenge: { + totpChallenge: true, + }, + expect: ['totp'], + }, + { + name: 'challenge webauthn', + challenge: { + webauthnPublicKey: {} as PublicKeyCredentialRequestOptions, + }, + expect: ['webauthn'], + }, + { + name: 'challenge sso', + challenge: { + ssoChallenge: Object.create(SSOChallenge), + }, + expect: ['sso'], + }, + { + name: 'challenge all', + challenge: { + totpChallenge: true, + webauthnPublicKey: {} as PublicKeyCredentialRequestOptions, + ssoChallenge: Object.create(SSOChallenge), + }, + expect: ['webauthn', 'totp', 'sso'], + }, + ]; + + test.each(testCases)('$name', testCase => { + const mfa = getMfaChallengeOptions(testCase.challenge).map(o => o.value); + expect(mfa).toEqual(testCase.expect); + }); +}); diff --git a/web/packages/teleport/src/services/mfa/mfaOptions.ts b/web/packages/teleport/src/services/mfa/mfaOptions.ts new file mode 100644 index 0000000000000..4bbe1dceb65f1 --- /dev/null +++ b/web/packages/teleport/src/services/mfa/mfaOptions.ts @@ -0,0 +1,78 @@ +/** + * 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 { Auth2faType } from 'shared/services'; + +import { DeviceType, MfaAuthenticateChallenge, SSOChallenge } from './types'; + +export function getMfaChallengeOptions(mfaChallenge: MfaAuthenticateChallenge) { + const mfaOptions: MfaOption[] = []; + + if (mfaChallenge?.webauthnPublicKey) { + mfaOptions.push(MFA_OPTION_WEBAUTHN); + } + + if (mfaChallenge?.totpChallenge) { + mfaOptions.push(MFA_OPTION_TOTP); + } + + if (mfaChallenge?.ssoChallenge) { + mfaOptions.push(getSsoOption(mfaChallenge.ssoChallenge)); + } + + return mfaOptions; +} + +export function getMfaRegisterOptions(auth2faType: Auth2faType) { + const mfaOptions: MfaOption[] = []; + + if (auth2faType === 'webauthn' || auth2faType === 'on') { + mfaOptions.push(MFA_OPTION_WEBAUTHN); + } + + if (auth2faType === 'otp' || auth2faType === 'on') { + mfaOptions.push(MFA_OPTION_TOTP); + } + + return mfaOptions; +} + +export type MfaOption = { + value: DeviceType; + label: string; +}; + +const MFA_OPTION_WEBAUTHN: MfaOption = { + value: 'webauthn', + label: 'Passkey or Security Key', +}; + +const MFA_OPTION_TOTP: MfaOption = { + value: 'totp', + label: 'Authenticator App', +}; + +const getSsoOption = (ssoChallenge: SSOChallenge): MfaOption => { + return { + value: 'sso', + label: + ssoChallenge.device?.displayName || + ssoChallenge.device?.connectorId || + 'SSO', + }; +}; diff --git a/web/packages/teleport/src/services/mfa/types.ts b/web/packages/teleport/src/services/mfa/types.ts index 6d1752c4082a1..f1292c50c99cd 100644 --- a/web/packages/teleport/src/services/mfa/types.ts +++ b/web/packages/teleport/src/services/mfa/types.ts @@ -16,10 +16,16 @@ * along with this program. If not, see . */ -import { WebauthnAssertionResponse } from '../auth'; -import { DeviceUsage } from '../auth/types'; +import { AuthProviderType } from 'shared/services'; + +import { Base64urlString } from '../auth/types'; import { CreateNewHardwareDeviceRequest } from '../auth/types'; +export type DeviceType = 'totp' | 'webauthn' | 'sso'; + +/** The intended usage of the device (as an MFA method or a passkey). */ +export type DeviceUsage = 'passwordless' | 'mfa'; + export interface MfaDevice { id: string; name: string; @@ -45,9 +51,81 @@ export type SaveNewHardwareDeviceRequest = { credential: Credential; }; -export type DeviceType = 'totp' | 'webauthn' | 'sso'; +export type MfaAuthenticateChallengeJson = { + sso_challenge?: SSOChallenge; + totp_challenge?: boolean; + webauthn_challenge?: { + publicKey: PublicKeyCredentialRequestOptionsJSON; + }; +}; + +export type MfaAuthenticateChallenge = { + ssoChallenge?: SSOChallenge; + totpChallenge?: boolean; + webauthnPublicKey?: PublicKeyCredentialRequestOptions; +}; + +export type SSOChallenge = { + channelId: string; + redirectUrl: string; + requestId: string; + device: { + connectorId: string; + connectorType: AuthProviderType; + displayName: string; + }; +}; + +export type MfaRegistrationChallengeJson = { + totp?: { + qrCode: Base64urlString; + }; + webauthn?: { + publicKey: PublicKeyCredentialCreationOptionsJSON; + }; +}; -// MfaAuthnResponse is a response to a MFA device challenge. -export type MfaAuthnResponse = - | { totp_code: string } - | { webauthn_response: WebauthnAssertionResponse }; +export type MfaRegistrationChallenge = { + qrCode: Base64urlString; + webauthnPublicKey: PublicKeyCredentialCreationOptions; +}; + +export type MfaChallengeResponse = { + totp_code?: string; + webauthn_response?: WebauthnAssertionResponse; + sso_response?: SsoChallengeResponse; +}; + +export type SsoChallengeResponse = { + requestId: string; + token: string; +}; + +export type WebauthnAssertionResponse = { + id: string; + type: string; + extensions: { + appid: boolean; + }; + rawId: string; + response: { + authenticatorData: string; + clientDataJSON: string; + signature: string; + userHandle: string; + }; +}; + +export type WebauthnAttestationResponse = { + id: string; + type: string; + extensions: { + appid: boolean; + credProps: CredentialPropertiesOutput; + }; + rawId: string; + response: { + attestationObject: string; + clientDataJSON: string; + }; +}; diff --git a/web/packages/teleport/src/services/user/user.ts b/web/packages/teleport/src/services/user/user.ts index 5584159528580..fc897d31fbcdc 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 '../auth'; +import { WebauthnAssertionResponse } from '../mfa'; import makeUserContext from './makeUserContext'; import { makeResetToken } from './makeResetToken';