diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 2bc02110e1f..73eae18ecbd 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add passkey and email OTP enrollment, verification, credential-list, and elevated-token exchange SDK methods ([#10265](https://github.com/MetaMask/core/pull/10265)) - Add validated MFA domain types and structured `MfaError` classes with a serialization-safe `mfaCode` ([#10264](https://github.com/MetaMask/core/pull/10264)) - Add `rampsOrders` to `USER_STORAGE_FEATURE_NAMES` ([#10227](https://github.com/MetaMask/core/pull/10227)) diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts index fa0f2766210..3363ee59f06 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/auth.ts @@ -20,6 +20,16 @@ import { MOCK_CUSTOMER_SERVICE_TOKEN_RESPONSE, MOCK_PARTNER_IDENTITY_TOKEN_URL, MOCK_PARTNER_IDENTITY_TOKEN_RESPONSE, + MOCK_MFA_CREDENTIALS_RESPONSE, + MOCK_MFA_CREDENTIALS_URL, + MOCK_MFA_ENROLL_COMPLETE_RESPONSE, + MOCK_MFA_ENROLL_COMPLETE_URL, + MOCK_MFA_ENROLL_PASSKEY_RESPONSE, + MOCK_MFA_ENROLL_URL, + MOCK_MFA_VERIFY_COMPLETE_RESPONSE, + MOCK_MFA_VERIFY_COMPLETE_URL, + MOCK_MFA_VERIFY_PASSKEY_RESPONSE, + MOCK_MFA_VERIFY_URL, } from '../mocks/auth.js'; type MockReply = { @@ -121,6 +131,65 @@ export const handleMockOAuth2Token = (mockReply?: MockReply): nock.Scope => { return mockTokenEndpoint; }; +export const handleMockMfaEnroll = (mockReply?: MockReply): nock.Scope => { + const reply = mockReply ?? { + status: 200, + body: MOCK_MFA_ENROLL_PASSKEY_RESPONSE, + }; + return nock(MOCK_MFA_ENROLL_URL) + .persist() + .post('') + .reply(reply.status, reply.body); +}; + +export const handleMockMfaEnrollComplete = ( + mockReply?: MockReply, +): nock.Scope => { + const reply = mockReply ?? { + status: 200, + body: MOCK_MFA_ENROLL_COMPLETE_RESPONSE, + }; + return nock(MOCK_MFA_ENROLL_COMPLETE_URL) + .persist() + .post('') + .reply(reply.status, reply.body); +}; + +export const handleMockMfaVerify = (mockReply?: MockReply): nock.Scope => { + const reply = mockReply ?? { + status: 200, + body: MOCK_MFA_VERIFY_PASSKEY_RESPONSE, + }; + return nock(MOCK_MFA_VERIFY_URL) + .persist() + .post('') + .reply(reply.status, reply.body); +}; + +export const handleMockMfaVerifyComplete = ( + mockReply?: MockReply, +): nock.Scope => { + const reply = mockReply ?? { + status: 200, + body: MOCK_MFA_VERIFY_COMPLETE_RESPONSE, + }; + return nock(MOCK_MFA_VERIFY_COMPLETE_URL) + .persist() + .post('') + .reply(reply.status, reply.body); +}; + +export const handleMockMfaCredentials = (mockReply?: MockReply): nock.Scope => { + const reply = mockReply ?? { + status: 200, + body: MOCK_MFA_CREDENTIALS_RESPONSE, + }; + return nock(MOCK_MFA_CREDENTIALS_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +}; + export const handleMockUserProfileLineage = ( mockReply?: MockReply, ): nock.Scope => { @@ -178,6 +247,11 @@ export const arrangeAuthAPIs = (options?: { mockUserProfileLineageUrl?: MockReply; mockCustomerServiceTokenUrl?: MockReply; mockPartnerIdentityTokenUrl?: MockReply; + mockMfaEnrollUrl?: MockReply; + mockMfaEnrollCompleteUrl?: MockReply; + mockMfaVerifyUrl?: MockReply; + mockMfaVerifyCompleteUrl?: MockReply; + mockMfaCredentialsUrl?: MockReply; onSrpLoginBody?: (body: unknown) => void; onPairSocialIdentifierBody?: (body: unknown) => void; mockPairProfilesDelayMs?: number; @@ -192,6 +266,11 @@ export const arrangeAuthAPIs = (options?: { mockUserProfileLineageUrl: nock.Scope; mockCustomerServiceTokenUrl: nock.Scope; mockPartnerIdentityTokenUrl: nock.Scope; + mockMfaEnrollUrl: nock.Scope; + mockMfaEnrollCompleteUrl: nock.Scope; + mockMfaVerifyUrl: nock.Scope; + mockMfaVerifyCompleteUrl: nock.Scope; + mockMfaCredentialsUrl: nock.Scope; } => { const mockNonceUrl = handleMockNonce(options?.mockNonceUrl); const mockOAuth2TokenUrl = handleMockOAuth2Token(options?.mockOAuth2TokenUrl); @@ -220,6 +299,17 @@ export const arrangeAuthAPIs = (options?: { const mockPartnerIdentityTokenUrl = handleMockPartnerIdentityToken( options?.mockPartnerIdentityTokenUrl, ); + const mockMfaEnrollUrl = handleMockMfaEnroll(options?.mockMfaEnrollUrl); + const mockMfaEnrollCompleteUrl = handleMockMfaEnrollComplete( + options?.mockMfaEnrollCompleteUrl, + ); + const mockMfaVerifyUrl = handleMockMfaVerify(options?.mockMfaVerifyUrl); + const mockMfaVerifyCompleteUrl = handleMockMfaVerifyComplete( + options?.mockMfaVerifyCompleteUrl, + ); + const mockMfaCredentialsUrl = handleMockMfaCredentials( + options?.mockMfaCredentialsUrl, + ); return { mockNonceUrl, @@ -232,5 +322,10 @@ export const arrangeAuthAPIs = (options?: { mockUserProfileLineageUrl, mockCustomerServiceTokenUrl, mockPartnerIdentityTokenUrl, + mockMfaEnrollUrl, + mockMfaEnrollCompleteUrl, + mockMfaVerifyUrl, + mockMfaVerifyCompleteUrl, + mockMfaCredentialsUrl, }; }; diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts index 96371ded6d1..d8b4e43cb34 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.test.ts @@ -23,6 +23,11 @@ const mockAuthorizeOIDC = jest.fn(); const mockPairProfiles = jest.fn(); const mockGetCustomerServiceToken = jest.fn(); const mockGetPartnerIdentityToken = jest.fn(); +const mockMfaEnroll = jest.fn(); +const mockMfaEnrollComplete = jest.fn(); +const mockMfaVerify = jest.fn(); +const mockMfaVerifyComplete = jest.fn(); +const mockGetMfaCredentials = jest.fn(); jest.mock('./services', () => ({ authenticate: (...args: unknown[]): unknown => mockAuthenticate(...args), @@ -36,6 +41,17 @@ jest.mock('./services', () => ({ pairProfiles: (...args: unknown[]): unknown => mockPairProfiles(...args), })); +jest.mock('./mfa/services', () => ({ + mfaEnroll: (...args: unknown[]): unknown => mockMfaEnroll(...args), + mfaEnrollComplete: (...args: unknown[]): unknown => + mockMfaEnrollComplete(...args), + mfaVerify: (...args: unknown[]): unknown => mockMfaVerify(...args), + mfaVerifyComplete: (...args: unknown[]): unknown => + mockMfaVerifyComplete(...args), + getMfaCredentials: (...args: unknown[]): unknown => + mockGetMfaCredentials(...args), +})); + // Mock computeIdentifierId const MOCK_COMPUTED_IDENTIFIER_ID = 'computed-identifier-hash'; const mockComputeIdentifierId = jest.fn(); @@ -286,6 +302,261 @@ describe('SRPJwtBearerAuth rate limit handling', () => { }); }); +describe('SRP MFA methods', () => { + const accessToken = 'eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.signature'; + const config: AuthConfig & { type: AuthType.SRP } = { + type: AuthType.SRP, + env: Env.DEV, + platform: Platform.MOBILE, + }; + + function createAuth(): SRPJwtBearerAuth { + return new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse: async (): Promise => ({ + token: { + accessToken, + expiresIn: 3600, + obtainedAt: Date.now(), + }, + profile: { + profileId: 'profile-id', + canonicalProfileId: 'profile-id', + metaMetricsId: 'metametrics-id', + identifierId: 'identifier-id', + }, + }), + setLoginResponse: async (): Promise => undefined, + }, + signing: { + getIdentifier: async (): Promise => 'identifier', + signMessage: async (): Promise => 'signature', + }, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('begins passkey and email enrollment with the access token', async () => { + const auth = createAuth(); + mockMfaEnroll + .mockResolvedValueOnce({ + flowId: 'passkey-flow', + expiresAt: 1000, + publicKey: { challenge: 'challenge' }, + }) + .mockResolvedValueOnce({ + flowId: 'email-flow', + expiresAt: 2000, + }); + + expect(await auth.beginMfaEnrollment('passkey')).toMatchObject({ + type: 'passkey', + flowId: 'passkey-flow', + }); + expect( + await auth.beginMfaEnrollment('email_otp', { + email: 'user@example.com', + }), + ).toStrictEqual({ + type: 'email_otp', + flowId: 'email-flow', + expiresAt: 2000, + }); + expect(mockMfaEnroll).toHaveBeenLastCalledWith(Env.DEV, accessToken, { + credential_type: 'email_otp', + identifier: 'user@example.com', + }); + }); + + it('does not conflate email with entropySourceId', async () => { + const getLoginResponse = jest.fn( + async (): Promise => ({ + token: { accessToken, expiresIn: 3600, obtainedAt: Date.now() }, + profile: { + profileId: 'profile-id', + canonicalProfileId: 'profile-id', + metaMetricsId: 'metametrics-id', + identifierId: 'identifier-id', + }, + }), + ); + const auth = new SRPJwtBearerAuth(config, { + storage: { + getLoginResponse, + setLoginResponse: async (): Promise => undefined, + }, + signing: { + getIdentifier: async (): Promise => 'identifier', + signMessage: async (): Promise => 'signature', + }, + }); + mockMfaEnroll.mockResolvedValueOnce({ flowId: 'flow-id', expiresAt: 1000 }); + + await auth.beginMfaEnrollment('email_otp', { + email: 'user@example.com', + entropySourceId: 'secondary-source', + }); + + expect(getLoginResponse).toHaveBeenCalledWith('secondary-source'); + expect(mockMfaEnroll).toHaveBeenCalledWith(Env.DEV, accessToken, { + credential_type: 'email_otp', + identifier: 'user@example.com', + }); + }); + + it('rejects a passkey enrollment without creation data', async () => { + const auth = createAuth(); + mockMfaEnroll.mockResolvedValue({ + flowId: 'flow-id', + expiresAt: 1000, + }); + + await expect(auth.beginMfaEnrollment('passkey')).rejects.toMatchObject({ + mfaCode: 'invalid_response', + }); + }); + + it('completes both enrollment proof types', async () => { + const auth = createAuth(); + mockMfaEnrollComplete.mockResolvedValue(undefined); + const attestation = { + id: 'id', + rawId: 'raw-id', + type: 'public-key', + response: { + attestationObject: 'attestation', + clientDataJSON: 'client-data', + }, + } as const; + + await auth.completeMfaEnrollment('flow-id', { + type: 'passkey', + attestation, + }); + await auth.completeMfaEnrollment('flow-id', { + type: 'email_otp', + code: '123456', + }); + + expect(mockMfaEnrollComplete).toHaveBeenNthCalledWith( + 1, + Env.DEV, + accessToken, + { + credential_type: 'passkey', + flow_id: 'flow-id', + passkey_attestation: attestation, + }, + ); + expect(mockMfaEnrollComplete).toHaveBeenNthCalledWith( + 2, + Env.DEV, + accessToken, + { + credential_type: 'email_otp', + flow_id: 'flow-id', + otp_code: '123456', + }, + ); + }); + + it('begins passkey and email verification', async () => { + const auth = createAuth(); + mockMfaVerify + .mockResolvedValueOnce({ + flowId: 'passkey-flow', + expiresAt: 1000, + publicKey: { challenge: 'challenge' }, + }) + .mockResolvedValueOnce({ + flowId: 'email-flow', + expiresAt: 2000, + }); + + expect(await auth.beginMfaVerification('passkey')).toMatchObject({ + type: 'passkey', + flowId: 'passkey-flow', + }); + expect(await auth.beginMfaVerification('email_otp')).toStrictEqual({ + type: 'email_otp', + flowId: 'email-flow', + expiresAt: 2000, + }); + }); + + it('rejects passkey verification without request data', async () => { + const auth = createAuth(); + mockMfaVerify.mockResolvedValue({ + flowId: 'flow-id', + expiresAt: 1000, + }); + + await expect(auth.beginMfaVerification('passkey')).rejects.toMatchObject({ + mfaCode: 'invalid_response', + }); + }); + + it('completes verification and lists credentials', async () => { + const auth = createAuth(); + const completion = { token: 'assertion' }; + mockMfaVerifyComplete.mockResolvedValue(completion); + mockGetMfaCredentials.mockResolvedValue([ + { type: 'passkey', status: 'active' }, + ]); + + expect( + await auth.completeMfaVerification('flow-id', { + type: 'email_otp', + code: '123456', + }), + ).toBe(completion); + expect(await auth.getMfaCredentials()).toStrictEqual([ + { type: 'passkey', status: 'active' }, + ]); + }); + + it('forwards a passkey assertion and exchanges the resulting JWT', async () => { + const auth = createAuth(); + const assertion = { + id: 'id', + rawId: 'raw-id', + type: 'public-key', + response: { + authenticatorData: 'authenticator-data', + clientDataJSON: 'client-data', + signature: 'signature', + }, + } as const; + mockMfaVerifyComplete.mockResolvedValue({ token: 'assertion-jwt' }); + mockAuthorizeOIDC.mockResolvedValue({ + accessToken: 'elevated-token', + expiresIn: 900, + obtainedAt: 1000, + }); + + await auth.completeMfaVerification('flow-id', { + type: 'passkey', + assertion, + }); + expect(await auth.exchangeMfaAssertion('assertion-jwt')).toMatchObject({ + accessToken: 'elevated-token', + }); + expect(mockMfaVerifyComplete).toHaveBeenCalledWith(Env.DEV, accessToken, { + credential_type: 'passkey', + flow_id: 'flow-id', + passkey_assertion: assertion, + }); + expect(mockAuthorizeOIDC).toHaveBeenCalledWith( + 'assertion-jwt', + Env.DEV, + Platform.MOBILE, + ); + }); +}); + describe('SRPJwtBearerAuth profileId resolution', () => { const config: AuthConfig & { type: AuthType.SRP } = { type: AuthType.SRP, diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts index 6d569a4b3fa..3fbc0ffed5b 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/flow-srp.ts @@ -1,7 +1,7 @@ import type { Eip1193Provider } from 'ethers'; import type { MetaMetricsAuth } from '../../shared/types/services.js'; -import { ValidationError, RateLimitedError } from '../errors.js'; +import { MfaError, ValidationError, RateLimitedError } from '../errors.js'; import { getMetaMaskProviderEIP6963 } from '../utils/eip-6963-metamask-provider.js'; import { MESSAGE_SIGNING_SNAP, @@ -10,6 +10,22 @@ import { isSnapConnected, } from '../utils/messaging-signing-snap-requests.js'; import { validateLoginResponse } from '../utils/validate-login-response.js'; +import { + getMfaCredentials, + mfaEnroll, + mfaEnrollComplete, + mfaVerify, + mfaVerifyComplete, +} from './mfa/services.js'; +import type { MfaStepUpAssertion } from './mfa/services.js'; +import type { + EnrolledCredential, + EnrollmentChallenge, + EnrollmentProof, + MfaCredentialType, + StepUpChallenge, + StepUpProof, +} from './mfa/types.js'; import { authenticate, authorizeOIDC, @@ -23,6 +39,7 @@ import { import type { PairProfilesResponse } from './services.js'; import type { AuthConfig, + AccessToken, AuthSigningOptions, AuthStorageOptions, AuthType, @@ -212,6 +229,157 @@ export class SRPJwtBearerAuth implements IBaseAuth { ); } + /** + * Begins enrollment of an MFA credential for the primary profile. + * + * @param type - Credential type to enroll. + * @param options - Enrollment options. + * @param options.email - Email address, required for email OTP. + * @param options.entropySourceId - Entropy source whose profile owns the + * credential. + * @returns Enrollment challenge for the client ceremony. + */ + async beginMfaEnrollment( + type: MfaCredentialType, + options?: { email?: string; entropySourceId?: string }, + ): Promise { + const { email, entropySourceId } = options ?? {}; + const accessToken = await this.getAccessToken(entropySourceId); + const result = await mfaEnroll(this.#config.env, accessToken, { + credential_type: type, + ...(email ? { identifier: email } : {}), + }); + + if (type === 'passkey') { + if (!result.publicKey) { + throw new MfaError( + 'invalid_response', + 'Passkey enrollment response is missing creation data', + ); + } + return { + type, + flowId: result.flowId, + expiresAt: result.expiresAt, + publicKey: result.publicKey, + }; + } + return { + type, + flowId: result.flowId, + expiresAt: result.expiresAt, + }; + } + + /** + * Completes enrollment of an MFA credential. + * + * @param flowId - Identifier returned by the begin call. + * @param proof - Platform attestation or email code. + * @param entropySourceId - Entropy source whose profile owns the credential. + */ + async completeMfaEnrollment( + flowId: string, + proof: EnrollmentProof, + entropySourceId?: string, + ): Promise { + const accessToken = await this.getAccessToken(entropySourceId); + await mfaEnrollComplete(this.#config.env, accessToken, { + credential_type: proof.type, + flow_id: flowId, + ...(proof.type === 'passkey' + ? { passkey_attestation: proof.attestation } + : { otp_code: proof.code }), + }); + } + + /** + * Begins step-up verification with an enrolled credential. + * + * @param type - Credential type to verify. + * @param entropySourceId - Entropy source whose profile owns the credential. + * @returns Verification challenge for the client ceremony. + */ + async beginMfaVerification( + type: MfaCredentialType, + entropySourceId?: string, + ): Promise { + const accessToken = await this.getAccessToken(entropySourceId); + const result = await mfaVerify(this.#config.env, accessToken, { + credential_type: type, + }); + + if (type === 'passkey') { + if (!result.publicKey) { + throw new MfaError( + 'invalid_response', + 'Passkey verification response is missing request data', + ); + } + return { + type, + flowId: result.flowId, + expiresAt: result.expiresAt, + publicKey: result.publicKey, + }; + } + return { + type, + flowId: result.flowId, + expiresAt: result.expiresAt, + }; + } + + /** + * Completes step-up verification with an enrolled credential. + * + * @param flowId - Identifier returned by the begin call. + * @param proof - Platform assertion or email code. + * @param entropySourceId - Entropy source whose profile owns the credential. + * @returns AAL2 assertion issued after verification. + */ + async completeMfaVerification( + flowId: string, + proof: StepUpProof, + entropySourceId?: string, + ): Promise { + const accessToken = await this.getAccessToken(entropySourceId); + return await mfaVerifyComplete(this.#config.env, accessToken, { + credential_type: proof.type, + flow_id: flowId, + ...(proof.type === 'passkey' + ? { passkey_assertion: proof.assertion } + : { otp_code: proof.code }), + }); + } + + /** + * Gets credentials enrolled on the primary profile. + * + * @param entropySourceId - Entropy source whose profile owns the credentials. + * @returns Supported enrolled credentials. + */ + async getMfaCredentials( + entropySourceId?: string, + ): Promise { + const accessToken = await this.getAccessToken(entropySourceId); + return await getMfaCredentials(this.#config.env, accessToken); + } + + /** + * Exchanges an MFA assertion for an elevated access token. + * + * @param assertionJwt - AAL2 authentication assertion. + * @returns Elevated access token. + */ + async exchangeMfaAssertion(assertionJwt: string): Promise { + return await authorizeOIDC( + assertionJwt, + this.#config.env, + this.#config.platform, + ); + } + async pairSocialIdentifier( params: PairSocialIdentifierParams, authAccessToken: string, diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.ts index 3010bfa26bd..3add3dec249 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.ts @@ -11,6 +11,7 @@ import { optional, pattern, refine, + sensitive, size, string, StructError, @@ -20,6 +21,7 @@ import { import type { Struct } from '@metamask/superstruct'; import { ElevatedTokenInvalidError, MfaError } from '../../errors.js'; +import { asRecord } from '../../utils/as-record.js'; export const MFA_CREDENTIAL_TYPES = ['passkey', 'email_otp'] as const; @@ -39,11 +41,11 @@ export const PublicKeyCredentialCreationOptionsJSONStruct = type({ name: string(), }), user: type({ - id: string(), - name: string(), - displayName: string(), + id: sensitive(string()), + name: sensitive(string()), + displayName: sensitive(string()), }), - challenge: string(), + challenge: sensitive(string()), pubKeyCredParams: array( type({ type: literal('public-key'), @@ -65,7 +67,7 @@ export const PublicKeyCredentialCreationOptionsJSONStruct = type({ }); export const PublicKeyCredentialRequestOptionsJSONStruct = type({ - challenge: string(), + challenge: sensitive(string()), rpId: optional(string()), allowCredentials: optional(array(CredentialDescriptorStruct)), userVerification: optional(string()), @@ -81,42 +83,46 @@ export const PasskeyRequestDataStruct = type({ publicKey: PublicKeyCredentialRequestOptionsJSONStruct, }); -export const RegistrationResponseJSONStruct = type({ - id: string(), - rawId: string(), - type: literal('public-key'), - response: type({ - attestationObject: string(), - clientDataJSON: string(), - transports: optional(array(string())), - publicKeyAlgorithm: optional(integer()), - publicKey: optional(string()), - authenticatorData: optional(string()), +export const RegistrationResponseJSONStruct = sensitive( + type({ + id: string(), + rawId: string(), + type: literal('public-key'), + response: type({ + attestationObject: string(), + clientDataJSON: string(), + transports: optional(array(string())), + publicKeyAlgorithm: optional(integer()), + publicKey: optional(string()), + authenticatorData: optional(string()), + }), + authenticatorAttachment: optional(nullable(string())), + clientExtensionResults: optional(ExtensionsStruct), }), - authenticatorAttachment: optional(nullable(string())), - clientExtensionResults: optional(ExtensionsStruct), -}); +); -export const AuthenticationResponseJSONStruct = type({ - id: string(), - rawId: string(), - type: literal('public-key'), - response: type({ - authenticatorData: string(), - clientDataJSON: string(), - signature: string(), - userHandle: optional(nullable(string())), +export const AuthenticationResponseJSONStruct = sensitive( + type({ + id: string(), + rawId: string(), + type: literal('public-key'), + response: type({ + authenticatorData: string(), + clientDataJSON: string(), + signature: string(), + userHandle: optional(nullable(string())), + }), + authenticatorAttachment: optional(nullable(string())), + clientExtensionResults: optional(ExtensionsStruct), }), - authenticatorAttachment: optional(nullable(string())), - clientExtensionResults: optional(ExtensionsStruct), -}); +); /** * Only the assertion JWT is consumed from the verification response; the * profile fields on the wire duplicate what the login flow already resolved. */ export const MfaVerifyCompleteResponseStruct = type({ - token: string(), + token: sensitive(string()), expires_in: integer(), }); @@ -126,7 +132,7 @@ export const MfaEnrollResponseStruct = type({ expires_at: string(), - passkey_create_data: optional(string()), + passkey_create_data: optional(sensitive(string())), }); export const MfaEnrollCompleteResponseStruct = type({ @@ -138,17 +144,17 @@ export const MfaVerifyResponseStruct = type({ expires_at: string(), - passkey_request_data: optional(string()), + passkey_request_data: optional(sensitive(string())), }); export const MfaPasskeyDetailStruct = type({ - display_name: optional(string()), + display_name: optional(sensitive(string())), added_at: optional(string()), }); export const MfaEmailDetailStruct = type({ - address: optional(string()), + address: optional(sensitive(string())), verified: optional(boolean()), }); @@ -180,7 +186,7 @@ export const TokenReasonStruct = object({ export const BeginEnrollmentRequestStruct = refine( object({ type: MfaCredentialTypeStruct, - email: optional(size(string(), 3, 254)), + email: optional(sensitive(size(string(), 3, 254))), reason: TokenReasonStruct, }), 'BeginEnrollmentRequest', @@ -195,7 +201,7 @@ export const BeginEnrollmentRequestStruct = refine( }, ); -const EmailOtpCodeStruct = pattern(string(), /^\d{6}$/u); +const EmailOtpCodeStruct = sensitive(pattern(string(), /^\d{6}$/u)); const EnrollmentProofStruct = union([ object({ @@ -241,7 +247,7 @@ export const GetElevatedTokenRequestStruct = object({ }); export const ElevatedTokenClaimsStruct = type({ - sub: string(), + sub: sensitive(string()), aal: literal(2), exp: integer(), amr: union([MfaCredentialTypeStruct, array(MfaCredentialTypeStruct)]), @@ -298,12 +304,6 @@ export function assertValidMfaRequest( } } -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null - ? (value as Record) - : undefined; -} - /** * Validates and normalizes claims from an elevated access token. * diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.test.ts new file mode 100644 index 00000000000..6b152e9aaf6 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.test.ts @@ -0,0 +1,524 @@ +import { Env } from '../../../shared/env.js'; +import { + CredentialAlreadyEnrolledError, + CredentialNotEnrolledError, + MaxIdentifiersReachedError, + MaxPasskeysReachedError, + MfaError, + MfaFlowExpiredError, + MfaIdentityMissingError, + MfaRateLimitedError, + MfaUnavailableError, + MfaVerificationFailedError, + TooManyAttemptsError, +} from '../../errors.js'; +import { + MOCK_MFA_CREDENTIALS_RESPONSE, + MOCK_MFA_ENROLL_COMPLETE_RESPONSE, + MOCK_MFA_ENROLL_PASSKEY_RESPONSE, + MOCK_MFA_VERIFY_COMPLETE_RESPONSE, + MOCK_MFA_VERIFY_PASSKEY_RESPONSE, +} from '../../mocks/auth.js'; +import { + getMfaCredentials, + mfaEnroll, + mfaEnrollComplete, + mfaVerify, + mfaVerifyComplete, + parsePasskeyCreateData, + parsePasskeyRequestData, + toEnrolledCredential, +} from './services.js'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const registration = { + id: 'credential-id', + rawId: 'credential-id', + type: 'public-key', + response: { + attestationObject: 'attestation', + clientDataJSON: 'client-data', + }, +} as const; + +const assertion = { + id: 'credential-id', + rawId: 'credential-id', + type: 'public-key', + response: { + authenticatorData: 'authenticator-data', + clientDataJSON: 'client-data', + signature: 'signature', + }, +} as const; + +function response( + body: unknown, + options?: { status?: number; headers?: Record }, +): Response { + return new globalThis.Response(JSON.stringify(body), { + status: options?.status ?? 200, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); +} + +describe('MFA services', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('begins passkey enrollment and parses creation options', async () => { + mockFetch.mockResolvedValue(response(MOCK_MFA_ENROLL_PASSKEY_RESPONSE)); + + const result = await mfaEnroll(Env.PRD, 'access-token', { + credential_type: 'passkey', + }); + + expect(result).toStrictEqual({ + flowId: 'enroll-passkey-flow-id', + expiresAt: Date.parse('2099-09-07T14:30:00Z'), + publicKey: expect.objectContaining({ + challenge: 'Y3JlYXRlLWNoYWxsZW5nZQ', + }), + }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v2/mfa/enroll'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }), + ); + }); + + it('completes passkey and email enrollment', async () => { + mockFetch + .mockResolvedValueOnce(response(MOCK_MFA_ENROLL_COMPLETE_RESPONSE)) + .mockResolvedValueOnce(response(MOCK_MFA_ENROLL_COMPLETE_RESPONSE)); + + await mfaEnrollComplete(Env.PRD, 'access-token', { + credential_type: 'passkey', + flow_id: 'flow-id', + passkey_attestation: registration, + }); + await mfaEnrollComplete(Env.PRD, 'access-token', { + credential_type: 'email_otp', + flow_id: 'flow-id', + otp_code: '123456', + }); + + const firstBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(firstBody.passkey_attestation).toBe(JSON.stringify(registration)); + expect(secondBody.otp_code).toBe('123456'); + }); + + it('begins and completes passkey verification', async () => { + mockFetch + .mockResolvedValueOnce(response(MOCK_MFA_VERIFY_PASSKEY_RESPONSE)) + .mockResolvedValueOnce(response(MOCK_MFA_VERIFY_COMPLETE_RESPONSE)); + + const challenge = await mfaVerify(Env.PRD, 'access-token', { + credential_type: 'passkey', + }); + const completion = await mfaVerifyComplete(Env.PRD, 'access-token', { + credential_type: 'passkey', + flow_id: 'flow-id', + passkey_assertion: assertion, + }); + + expect(challenge.publicKey).toStrictEqual( + expect.objectContaining({ + challenge: 'dmVyaWZ5LWNoYWxsZW5nZQ', + }), + ); + expect(completion).toStrictEqual({ + token: MOCK_MFA_VERIFY_COMPLETE_RESPONSE.token, + expiresIn: 900, + }); + const body = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(body.passkey_assertion).toBe(JSON.stringify(assertion)); + }); + + it('gets and maps supported credentials', async () => { + mockFetch.mockResolvedValue(response(MOCK_MFA_CREDENTIALS_RESPONSE)); + + expect(await getMfaCredentials(Env.PRD, 'access-token')).toStrictEqual([ + { + type: 'passkey', + status: 'active', + enrolledAt: Date.parse('2026-09-15T10:00:00Z'), + displayName: 'MetaMask 3f9a1c2b', + }, + { + type: 'email_otp', + status: 'pending', + enrolledAt: Date.parse('2026-09-15T10:05:00Z'), + email: 'user@example.com', + verified: false, + }, + ]); + }); + + it('keeps supported credentials when a row has an unparsable enrollment date', async () => { + mockFetch.mockResolvedValue( + response({ + credentials: [ + { + credential_type: 'passkey', + status: 'active', + enrolled_at: 'not-a-date', + passkey: { display_name: 'MetaMask 3f9a1c2b' }, + }, + { + credential_type: 'future_factor', + status: 'active', + enrolled_at: 'also-not-a-date', + }, + { + credential_type: 'email_otp', + status: 'active', + enrolled_at: '2026-09-15T10:05:00Z', + email: { address: 'user@example.com', verified: true }, + }, + ], + }), + ); + + expect(await getMfaCredentials(Env.PRD, 'access-token')).toStrictEqual([ + { + type: 'passkey', + status: 'active', + displayName: 'MetaMask 3f9a1c2b', + }, + { + type: 'email_otp', + status: 'active', + enrolledAt: Date.parse('2026-09-15T10:05:00Z'), + email: 'user@example.com', + verified: true, + }, + ]); + }); + + it('maps sparse credentials and ignores unsupported ones', () => { + expect( + toEnrolledCredential({ + credential_type: 'passkey', + status: 'pending', + }), + ).toStrictEqual({ type: 'passkey', status: 'pending' }); + expect( + toEnrolledCredential({ + credential_type: 'totp', + status: 'active', + }), + ).toBeNull(); + expect( + toEnrolledCredential({ + credential_type: 'passkey', + status: 'revoked', + }), + ).toBeNull(); + expect( + toEnrolledCredential({ + credential_type: 'email_otp', + status: 'pending', + }), + ).toBeNull(); + }); + + it('derives email verification from status when the server omits it', () => { + expect( + toEnrolledCredential({ + credential_type: 'email_otp', + status: 'active', + email: { address: 'user@example.com' }, + }), + ).toStrictEqual({ + type: 'email_otp', + status: 'active', + email: 'user@example.com', + verified: true, + }); + expect( + toEnrolledCredential({ + credential_type: 'email_otp', + status: 'pending', + email: { address: 'user@example.com' }, + }), + ).toMatchObject({ verified: false }); + }); + + it.each([ + ['credential_already_enrolled', CredentialAlreadyEnrolledError, 409], + ['email_already_enrolled', CredentialAlreadyEnrolledError, 409], + ['credential_not_enrolled', CredentialNotEnrolledError, 409], + ['flow_expired', MfaFlowExpiredError, 400], + ['invalid_flow', MfaFlowExpiredError, 400], + ['mfa_identity_missing', MfaIdentityMissingError, 409], + ['invalid_code', MfaVerificationFailedError, 400], + ['invalid_attestation', MfaVerificationFailedError, 400], + ['invalid_assertion', MfaVerificationFailedError, 400], + ['too_many_attempts', TooManyAttemptsError, 400], + ['max_passkeys_reached', MaxPasskeysReachedError, 422], + ['max_identifiers_reached', MaxIdentifiersReachedError, 409], + ['kratos_unavailable', MfaUnavailableError, 502], + ] as const)('maps %s to a domain error', async (code, ErrorClass, status) => { + mockFetch.mockResolvedValue( + response({ code, message: 'Server message' }, { status }), + ); + + await expect( + mfaEnroll(Env.PRD, 'access-token', { + credential_type: 'passkey', + }), + ).rejects.toBeInstanceOf(ErrorClass); + }); + + it('maps resend cooldown and parses a Retry-After delay or date', async () => { + const retryAt = new Date(Date.now() + 30_000); + mockFetch + .mockResolvedValueOnce( + response( + { code: 'otp_resend_cooldown', message: 'Wait before retrying' }, + { status: 429, headers: { 'Retry-After': '12' } }, + ), + ) + .mockResolvedValueOnce( + response( + { code: 'otp_resend_cooldown', message: 'Wait before retrying' }, + { status: 429, headers: { 'Retry-After': retryAt.toUTCString() } }, + ), + ) + .mockResolvedValueOnce( + response( + { code: 'otp_resend_cooldown', message: 'Wait before retrying' }, + { status: 429 }, + ), + ); + + const verifyEmail = async (): Promise => + await mfaVerify(Env.PRD, 'access-token', { + credential_type: 'email_otp', + }); + + await expect(verifyEmail()).rejects.toMatchObject({ + mfaCode: 'otp_resend_cooldown', + retryAfterMs: 12_000, + }); + const dated = await verifyEmail().catch((error) => error); + expect(dated.retryAfterMs).toBeGreaterThan(0); + expect(dated.retryAfterMs).toBeLessThanOrEqual(30_000); + // The human-readable message is never parsed for a delay. + await expect(verifyEmail()).rejects.toMatchObject({ + mfaCode: 'otp_resend_cooldown', + retryAfterMs: undefined, + }); + }); + + it('maps code-less 429 and 502 responses by status', async () => { + mockFetch + .mockResolvedValueOnce( + response( + { message: 'Slow down' }, + { status: 429, headers: { 'Retry-After': '8' } }, + ), + ) + .mockResolvedValueOnce( + response({ message: 'Unavailable' }, { status: 502 }), + ); + + const rateLimited = await mfaVerify(Env.PRD, 'access-token', { + credential_type: 'email_otp', + }).catch((error) => error); + expect(rateLimited).toBeInstanceOf(MfaRateLimitedError); + expect(rateLimited).toMatchObject({ + mfaCode: 'rate_limited', + retryAfterMs: 8_000, + }); + await expect( + mfaVerify(Env.PRD, 'access-token', { + credential_type: 'email_otp', + }), + ).rejects.toBeInstanceOf(MfaUnavailableError); + }); + + it('distinguishes authentication and unknown server errors from malformed responses', async () => { + mockFetch + .mockResolvedValueOnce( + response({ message: 'Access token expired' }, { status: 401 }), + ) + .mockResolvedValueOnce( + response( + { code: 'new_server_code', message: 'New condition' }, + { status: 400 }, + ), + ) + .mockResolvedValueOnce( + response({ message: 'Unclassified failure' }, { status: 400 }), + ); + + await expect( + getMfaCredentials(Env.PRD, 'access-token'), + ).rejects.toMatchObject({ mfaCode: 'authentication_required' }); + await expect( + getMfaCredentials(Env.PRD, 'access-token'), + ).rejects.toMatchObject({ mfaCode: 'new_server_code' }); + await expect( + getMfaCredentials(Env.PRD, 'access-token'), + ).rejects.toMatchObject({ mfaCode: 'server_error' }); + }); + + it('ignores a non-integer Retry-After header', async () => { + mockFetch.mockResolvedValueOnce( + new globalThis.Response('', { + status: 429, + headers: { 'Content-Type': 'text/plain', 'Retry-After': '20.5' }, + }), + ); + + const throttled = await getMfaCredentials(Env.PRD, 'access-token').catch( + (error) => error, + ); + expect(throttled).toBeInstanceOf(MfaRateLimitedError); + expect(throttled).toMatchObject({ + mfaCode: 'rate_limited', + status: 429, + retryAfterMs: undefined, + }); + }); + + it('classifies gateway 429 and 502 responses that carry no JSON body', async () => { + mockFetch + .mockResolvedValueOnce( + new globalThis.Response('throttled', { + status: 429, + headers: { 'Content-Type': 'text/html', 'Retry-After': '20' }, + }), + ) + .mockResolvedValueOnce( + new globalThis.Response('', { + status: 502, + headers: { 'Content-Type': 'text/plain' }, + }), + ); + + const throttled = await getMfaCredentials(Env.PRD, 'access-token').catch( + (error) => error, + ); + expect(throttled).toBeInstanceOf(MfaRateLimitedError); + expect(throttled).toMatchObject({ + mfaCode: 'rate_limited', + retryAfterMs: 20_000, + status: 429, + }); + await expect( + getMfaCredentials(Env.PRD, 'access-token'), + ).rejects.toMatchObject({ mfaCode: 'kratos_unavailable', status: 502 }); + }); + + it('recognises a rejected token even without a JSON error body', async () => { + mockFetch.mockResolvedValue( + new globalThis.Response('', { + status: 401, + headers: { 'Content-Type': 'text/plain' }, + }), + ); + + await expect( + getMfaCredentials(Env.PRD, 'access-token'), + ).rejects.toMatchObject({ + mfaCode: 'authentication_required', + status: 401, + }); + }); + + it('rejects malformed success and error responses', async () => { + mockFetch + .mockResolvedValueOnce(response({ flow_id: 'missing-expiration' })) + .mockResolvedValueOnce( + new globalThis.Response('not-json', { + status: 500, + headers: { 'Content-Type': 'text/plain' }, + }), + ) + .mockResolvedValueOnce( + new globalThis.Response('not-json', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), + ); + + await expect( + mfaEnroll(Env.PRD, 'access-token', { + credential_type: 'passkey', + }), + ).rejects.toMatchObject({ mfaCode: 'invalid_response' }); + await expect( + mfaEnroll(Env.PRD, 'access-token', { + credential_type: 'passkey', + }), + ).rejects.toMatchObject({ mfaCode: 'invalid_response', status: 500 }); + await expect( + mfaEnroll(Env.PRD, 'access-token', { + credential_type: 'passkey', + }), + ).rejects.toMatchObject({ mfaCode: 'invalid_response', status: 200 }); + }); + + it('salvages a message from an error body that fails strict validation', async () => { + mockFetch.mockResolvedValueOnce( + response( + { code: 123, message: 'Something broke upstream' }, + { status: 400 }, + ), + ); + + await expect( + mfaEnroll(Env.PRD, 'access-token', { credential_type: 'passkey' }), + ).rejects.toMatchObject({ + mfaCode: 'server_error', + message: expect.stringContaining('Something broke upstream'), + }); + }); + + it('rejects malformed embedded JSON and expiration dates', async () => { + expect(() => parsePasskeyCreateData('{')).toThrow( + /MFA\[invalid_response\]/u, + ); + expect(() => + parsePasskeyCreateData('{"publicKey":{"challenge":"only"}}'), + ).toThrow(/MFA\[invalid_response\].*\[publicKey\.rp\]/u); + expect(() => + parsePasskeyRequestData('{"publicKey":{"challenge":1}}'), + ).toThrow(/MFA\[invalid_response\]/u); + + mockFetch.mockResolvedValue( + response({ + ...MOCK_MFA_ENROLL_PASSKEY_RESPONSE, + expires_at: 'not-a-date', + }), + ); + await expect( + mfaEnroll(Env.PRD, 'access-token', { + credential_type: 'passkey', + }), + ).rejects.toBeInstanceOf(MfaError); + }); + + it('maps network failures to an unavailable error without a status', async () => { + mockFetch.mockRejectedValue(new Error('offline')); + const error = await getMfaCredentials(Env.PRD, 'access-token').catch( + (caught) => caught, + ); + expect(error).toBeInstanceOf(MfaUnavailableError); + expect(error.status).toBeUndefined(); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.ts new file mode 100644 index 00000000000..d8f63a05797 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.ts @@ -0,0 +1,525 @@ +import type { Json } from '@metamask/utils'; +import log from 'loglevel'; + +import type { Env } from '../../../shared/env.js'; +import { getEnvUrls } from '../../../shared/env.js'; +import { HTTP_STATUS_CODES } from '../../constants.js'; +import { + CredentialAlreadyEnrolledError, + CredentialNotEnrolledError, + MaxIdentifiersReachedError, + MaxPasskeysReachedError, + MfaError, + MfaFlowExpiredError, + MfaIdentityMissingError, + MfaRateLimitedError, + MfaUnavailableError, + MfaVerificationFailedError, + OtpResendCooldownError, + TooManyAttemptsError, +} from '../../errors.js'; +import { asRecord } from '../../utils/as-record.js'; +import { toErrorMessage } from '../../utils/to-error-message.js'; +import { + AuthenticationResponseJSONStruct, + MfaCredentialsResponseStruct, + MfaEnrollCompleteResponseStruct, + MfaEnrollResponseStruct, + MfaErrorResponseStruct, + MfaVerifyCompleteResponseStruct, + MfaVerifyResponseStruct, + PasskeyCreateDataStruct, + PasskeyRequestDataStruct, + RegistrationResponseJSONStruct, + assertValidMfaRequest, + assertValidMfaResponse, +} from './schemas.js'; +import type { + AuthenticationResponseJSON, + EnrolledCredential, + MfaCredential, + MfaCredentialStatus, + MfaCredentialType, + MfaEnrollCompleteRequest, + MfaEnrollRequest, + MfaVerifyCompleteRequest, + MfaVerifyRequest, + PublicKeyCredentialCreationOptionsJSON as PasskeyCreationOptions, + PublicKeyCredentialRequestOptionsJSON as PasskeyRequestOptions, + RegistrationResponseJSON, +} from './types.js'; + +export const MFA_ENROLL_URL = (env: Env): string => + `${getEnvUrls(env).authApiUrl}/api/v2/mfa/enroll`; + +export const MFA_ENROLL_COMPLETE_URL = (env: Env): string => + `${getEnvUrls(env).authApiUrl}/api/v2/mfa/enroll/complete`; + +export const MFA_VERIFY_URL = (env: Env): string => + `${getEnvUrls(env).authApiUrl}/api/v2/mfa/verify`; + +export const MFA_VERIFY_COMPLETE_URL = (env: Env): string => + `${getEnvUrls(env).authApiUrl}/api/v2/mfa/verify/complete`; + +export const MFA_CREDENTIALS_URL = (env: Env): string => + `${getEnvUrls(env).authApiUrl}/api/v2/mfa/credentials`; + +type EnrollmentServiceResult = { + flowId: string; + expiresAt: number; + publicKey?: PasskeyCreationOptions; +}; + +type VerificationServiceResult = { + flowId: string; + expiresAt: number; + publicKey?: PasskeyRequestOptions; +}; + +export type MfaStepUpAssertion = { + /** AAL2 assertion JWT to exchange at Hydra for an elevated access token. */ + token: string; + /** Assertion lifetime in seconds. */ + expiresIn: number; +}; + +type EnrollmentCompletionParams = { + // eslint-disable-next-line @typescript-eslint/naming-convention + credential_type: MfaCredentialType; + // eslint-disable-next-line @typescript-eslint/naming-convention + flow_id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + otp_code?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + passkey_attestation?: RegistrationResponseJSON; +}; + +type VerificationCompletionParams = { + // eslint-disable-next-line @typescript-eslint/naming-convention + credential_type: MfaCredentialType; + // eslint-disable-next-line @typescript-eslint/naming-convention + flow_id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + otp_code?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + passkey_assertion?: AuthenticationResponseJSON; +}; + +/** + * Reads a credential's enrollment date. Unlike flow deadlines, this is display + * metadata, so an unparsable value is dropped instead of rejecting the whole + * credential list. + * + * @param value - The raw `enrolled_at` value, if the server sent one. + * @returns The epoch milliseconds, or undefined when absent or unparsable. + */ +function parseEnrolledAt(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const enrolledAt = Date.parse(value); + if (Number.isNaN(enrolledAt)) { + log.warn(`Ignoring unparsable MFA credential enrolled_at: ${value}`); + return undefined; + } + return enrolledAt; +} + +function parseExpiresAt(value: string): number { + const expiresAt = Date.parse(value); + if (Number.isNaN(expiresAt)) { + throw new MfaError('invalid_response', `Invalid expires_at: ${value}`); + } + return expiresAt; +} + +/** + * Parses passkey creation data embedded in an enrollment response. + * + * @param value - JSON-encoded creation options. + * @returns Validated public-key creation options. + */ +export function parsePasskeyCreateData(value: string): PasskeyCreationOptions { + try { + const parsed: unknown = JSON.parse(value); + assertValidMfaResponse(parsed, PasskeyCreateDataStruct); + return parsed.publicKey; + } catch (error) { + if (error instanceof MfaError) { + throw error; + } + const message = toErrorMessage(error); + throw new MfaError('invalid_response', message); + } +} + +/** + * Parses passkey request data embedded in a verification response. + * + * @param value - JSON-encoded request options. + * @returns Validated public-key request options. + */ +export function parsePasskeyRequestData(value: string): PasskeyRequestOptions { + try { + const parsed: unknown = JSON.parse(value); + assertValidMfaResponse(parsed, PasskeyRequestDataStruct); + return parsed.publicKey; + } catch (error) { + if (error instanceof MfaError) { + throw error; + } + const message = toErrorMessage(error); + throw new MfaError('invalid_response', message); + } +} + +/** + * Reads a `Retry-After` header as a delay. The human-readable error message is + * deliberately not parsed: the API documents it as unstable. + * + * @param response - The throttled response. + * @returns The delay in milliseconds, or undefined without a usable header. + */ +function parseRetryAfter(response: Response): number | undefined { + const header = response.headers.get('Retry-After'); + if (!header) { + return undefined; + } + const seconds = Number(header); + if (Number.isInteger(seconds)) { + return Math.max(0, seconds * 1000); + } + const date = Date.parse(header); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} + +/** + * Reads and validates the JSON error body of a failed MFA request. + * + * When the body parses as JSON but does not match the documented error + * shape, a `message` field is salvaged if present so callers still get a + * useful diagnostic instead of a content-free fallback. + * + * @param response - The failed response. + * @returns The validated error body, a best-effort salvage of it, or + * undefined when the body could not be parsed as JSON at all. + */ +async function readErrorBody( + response: Response, +): Promise<{ code?: string; message: string } | undefined> { + let body: unknown; + try { + body = await response.json(); + } catch { + return undefined; + } + + try { + assertValidMfaResponse(body, MfaErrorResponseStruct); + return body; + } catch { + const message = asRecord(body)?.message; + return typeof message === 'string' ? { message } : undefined; + } +} + +async function throwMfaError( + response: Response, + errorPrefix: string, +): Promise { + const { status } = response; + const body = await readErrorBody(response); + const message = `${errorPrefix}: ${body?.message ?? `HTTP ${status}`}`; + + // Status-only classification first: gateways and load balancers answer + // 401/429/502 without the service's JSON body, and the caller still needs + // to invalidate its session, honour Retry-After, or back off. + if (status === HTTP_STATUS_CODES.UNAUTHORIZED) { + throw new MfaError('authentication_required', message, { status }); + } + + const code = body?.code; + switch (code) { + case 'credential_already_enrolled': + case 'email_already_enrolled': + throw new CredentialAlreadyEnrolledError(code, message, status); + case 'credential_not_enrolled': + throw new CredentialNotEnrolledError(message, status); + case 'flow_expired': + case 'invalid_flow': + throw new MfaFlowExpiredError(code, message, status); + case 'mfa_identity_missing': + throw new MfaIdentityMissingError(message, status); + case 'invalid_code': + case 'invalid_attestation': + case 'invalid_assertion': + throw new MfaVerificationFailedError(code, message, status); + case 'too_many_attempts': + throw new TooManyAttemptsError(message, status); + case 'max_passkeys_reached': + throw new MaxPasskeysReachedError(message, status); + case 'max_identifiers_reached': + throw new MaxIdentifiersReachedError(message, status); + case 'otp_resend_cooldown': + throw new OtpResendCooldownError( + message, + parseRetryAfter(response), + status, + ); + case 'kratos_unavailable': + throw new MfaUnavailableError(message, status); + default: + break; + } + + if (status === HTTP_STATUS_CODES.TOO_MANY_REQUESTS) { + throw new MfaRateLimitedError(message, parseRetryAfter(response), status); + } + if (status === HTTP_STATUS_CODES.BAD_GATEWAY) { + throw new MfaUnavailableError(message, status); + } + if (!body) { + throw new MfaError( + 'invalid_response', + `${errorPrefix}: Unexpected error response (HTTP ${status})`, + { status }, + ); + } + throw new MfaError(code ?? 'server_error', message, { status }); +} + +async function requestJson( + url: string, + accessToken: string, + init?: { method?: 'GET' | 'POST'; body?: Json }, +): Promise { + let response: Response; + try { + response = await fetch(url, { + method: init?.method ?? 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + ...(init?.body === undefined + ? {} + : { 'Content-Type': 'application/json' }), + }, + ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + } catch (error) { + const message = toErrorMessage(error); + throw new MfaUnavailableError(`MFA request failed: ${message}`); + } + + if (!response.ok) { + return await throwMfaError(response, 'MFA request failed'); + } + + try { + return await response.json(); + } catch (error) { + const message = toErrorMessage(error); + throw new MfaError('invalid_response', message, { + status: response.status, + }); + } +} + +/** + * Begins enrollment of an MFA credential. + * + * @param env - Authentication environment. + * @param accessToken - Primary profile access token. + * @param body - Enrollment request. + * @returns Validated enrollment flow data. + */ +export async function mfaEnroll( + env: Env, + accessToken: string, + body: MfaEnrollRequest, +): Promise { + const json = await requestJson(MFA_ENROLL_URL(env), accessToken, { + method: 'POST', + body, + }); + assertValidMfaResponse(json, MfaEnrollResponseStruct); + + return { + flowId: json.flow_id, + expiresAt: parseExpiresAt(json.expires_at), + ...(json.passkey_create_data + ? { publicKey: parsePasskeyCreateData(json.passkey_create_data) } + : {}), + }; +} + +/** + * Completes enrollment of an MFA credential. + * + * @param env - Authentication environment. + * @param accessToken - Primary profile access token. + * @param params - Flow identifier and enrollment proof. + */ +export async function mfaEnrollComplete( + env: Env, + accessToken: string, + params: EnrollmentCompletionParams, +): Promise { + let body: MfaEnrollCompleteRequest = { + credential_type: params.credential_type, + flow_id: params.flow_id, + ...(params.otp_code ? { otp_code: params.otp_code } : {}), + }; + if (params.passkey_attestation) { + assertValidMfaRequest( + params.passkey_attestation, + RegistrationResponseJSONStruct, + ); + body = { + ...body, + passkey_attestation: JSON.stringify(params.passkey_attestation), + }; + } + + const json = await requestJson(MFA_ENROLL_COMPLETE_URL(env), accessToken, { + method: 'POST', + body, + }); + assertValidMfaResponse(json, MfaEnrollCompleteResponseStruct); +} + +/** + * Begins step-up verification with an enrolled credential. + * + * @param env - Authentication environment. + * @param accessToken - Primary profile access token. + * @param body - Verification request. + * @returns Validated verification flow data. + */ +export async function mfaVerify( + env: Env, + accessToken: string, + body: MfaVerifyRequest, +): Promise { + const json = await requestJson(MFA_VERIFY_URL(env), accessToken, { + method: 'POST', + body, + }); + assertValidMfaResponse(json, MfaVerifyResponseStruct); + + return { + flowId: json.flow_id, + expiresAt: parseExpiresAt(json.expires_at), + ...(json.passkey_request_data + ? { publicKey: parsePasskeyRequestData(json.passkey_request_data) } + : {}), + }; +} + +/** + * Completes step-up verification with an enrolled credential. + * + * @param env - Authentication environment. + * @param accessToken - Primary profile access token. + * @param params - Flow identifier and verification proof. + * @returns The AAL2 assertion JWT and its lifetime in seconds. + */ +export async function mfaVerifyComplete( + env: Env, + accessToken: string, + params: VerificationCompletionParams, +): Promise { + let body: MfaVerifyCompleteRequest = { + credential_type: params.credential_type, + flow_id: params.flow_id, + ...(params.otp_code ? { otp_code: params.otp_code } : {}), + }; + if (params.passkey_assertion) { + assertValidMfaRequest( + params.passkey_assertion, + AuthenticationResponseJSONStruct, + ); + body = { + ...body, + passkey_assertion: JSON.stringify(params.passkey_assertion), + }; + } + + const json = await requestJson(MFA_VERIFY_COMPLETE_URL(env), accessToken, { + method: 'POST', + body, + }); + assertValidMfaResponse(json, MfaVerifyCompleteResponseStruct); + return { token: json.token, expiresIn: json.expires_in }; +} + +function isSupportedStatus(status: string): status is MfaCredentialStatus { + return status === 'active' || status === 'pending'; +} + +/** + * Maps a server credential to its public controller representation. + * + * Unknown credential types or statuses are dropped so the server can extend + * either without invalidating the whole list. + * + * @param credential - Validated server credential. + * @returns A supported credential, or null when it cannot be represented. + */ +export function toEnrolledCredential( + credential: MfaCredential, +): EnrolledCredential | null { + const { credential_type: type, status } = credential; + if (!isSupportedStatus(status)) { + log.warn(`Ignoring MFA credential with unsupported status: ${status}`); + return null; + } + + const enrolledAt = parseEnrolledAt(credential.enrolled_at); + const base = { + status, + ...(enrolledAt === undefined ? {} : { enrolledAt }), + }; + + if (type === 'passkey') { + return { + type, + ...base, + ...(credential.passkey?.display_name + ? { displayName: credential.passkey.display_name } + : {}), + }; + } + + if (type === 'email_otp' && credential.email?.address !== undefined) { + return { + type, + ...base, + email: credential.email.address, + // The spec marks `verified` optional; an active row is verified. + verified: credential.email.verified ?? status === 'active', + }; + } + + log.warn(`Ignoring unsupported or incomplete MFA credential: ${type}`); + return null; +} + +/** + * Gets the credentials enrolled on the canonical profile. + * + * @param env - Authentication environment. + * @param accessToken - Primary profile access token. + * @returns Supported enrolled credentials. + */ +export async function getMfaCredentials( + env: Env, + accessToken: string, +): Promise { + const json = await requestJson(MFA_CREDENTIALS_URL(env), accessToken); + assertValidMfaResponse(json, MfaCredentialsResponseStruct); + return json.credentials + .map(toEnrolledCredential) + .filter((credential): credential is EnrolledCredential => + Boolean(credential), + ); +} diff --git a/packages/profile-sync-controller/src/sdk/authentication.test.ts b/packages/profile-sync-controller/src/sdk/authentication.test.ts index 6fa019b78ba..2da8e022ad0 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.test.ts @@ -733,6 +733,66 @@ describe('Authentication - rejects when calling unrelated methods', () => { }); }); +describe('MFA authentication facade', () => { + it('forwards MFA operations to the SRP implementation', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const endpoints = arrangeAuthAPIs(); + const registration = { + id: 'credential-id', + rawId: 'credential-id', + type: 'public-key', + response: { + attestationObject: 'attestation', + clientDataJSON: 'client-data', + }, + } as const; + const assertion = { + id: 'credential-id', + rawId: 'credential-id', + type: 'public-key', + response: { + authenticatorData: 'authenticator-data', + clientDataJSON: 'client-data', + signature: 'signature', + }, + } as const; + + expect(await auth.beginMfaEnrollment('passkey')).toMatchObject({ + type: 'passkey', + flowId: 'enroll-passkey-flow-id', + }); + expect( + await auth.completeMfaEnrollment('flow-id', { + type: 'passkey', + attestation: registration, + }), + ).toBeUndefined(); + expect(await auth.beginMfaVerification('passkey')).toMatchObject({ + type: 'passkey', + flowId: 'verify-passkey-flow-id', + }); + expect( + await auth.completeMfaVerification('flow-id', { + type: 'passkey', + assertion, + }), + ).toMatchObject({ + token: expect.any(String), + expiresIn: 900, + }); + expect(await auth.getMfaCredentials()).toHaveLength(2); + expect(await auth.exchangeMfaAssertion('assertion-jwt')).toMatchObject({ + accessToken: MOCK_ACCESS_JWT, + }); + + expect(endpoints.mockMfaEnrollUrl.isDone()).toBe(true); + expect(endpoints.mockMfaEnrollCompleteUrl.isDone()).toBe(true); + expect(endpoints.mockMfaVerifyUrl.isDone()).toBe(true); + expect(endpoints.mockMfaVerifyCompleteUrl.isDone()).toBe(true); + expect(endpoints.mockMfaCredentialsUrl.isDone()).toBe(true); + }); +}); + /** * Mock Utility to create a mock stored profile * diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 1bb712dcc54..3dae0faa2b2 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -4,12 +4,22 @@ import type { Eip1193Provider } from 'ethers'; import type { Env } from '../shared/env.js'; import { SIWEJwtBearerAuth } from './authentication-jwt-bearer/flow-siwe.js'; import { SRPJwtBearerAuth } from './authentication-jwt-bearer/flow-srp.js'; +import type { MfaStepUpAssertion } from './authentication-jwt-bearer/mfa/services.js'; +import type { + EnrolledCredential, + EnrollmentChallenge, + EnrollmentProof, + MfaCredentialType, + StepUpChallenge, + StepUpProof, +} from './authentication-jwt-bearer/mfa/types.js'; import { getNonce, pairIdentifiers, } from './authentication-jwt-bearer/services.js'; import type { PairProfilesResponse } from './authentication-jwt-bearer/services.js'; import type { + AccessToken, UserProfile, Pair, PairSocialIdentifierParams, @@ -102,6 +112,56 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { ); } + async beginMfaEnrollment( + type: MfaCredentialType, + options?: { email?: string; entropySourceId?: string }, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.beginMfaEnrollment(type, options); + } + + async completeMfaEnrollment( + flowId: string, + proof: EnrollmentProof, + entropySourceId?: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + await this.#sdk.completeMfaEnrollment(flowId, proof, entropySourceId); + } + + async beginMfaVerification( + type: MfaCredentialType, + entropySourceId?: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.beginMfaVerification(type, entropySourceId); + } + + async completeMfaVerification( + flowId: string, + proof: StepUpProof, + entropySourceId?: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.completeMfaVerification( + flowId, + proof, + entropySourceId, + ); + } + + async getMfaCredentials( + entropySourceId?: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.getMfaCredentials(entropySourceId); + } + + async exchangeMfaAssertion(assertionJwt: string): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.exchangeMfaAssertion(assertionJwt); + } + async pairSrpProfiles( accessTokens: string[], authAccessToken: string, diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index e240bddc0b1..d22604fa268 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -1,16 +1,9 @@ import type { MfaErrorCode } from './authentication-jwt-bearer/mfa/types.js'; import { HTTP_STATUS_CODES } from './constants.js'; +import { asRecord } from './utils/as-record.js'; type ExtensibleMfaErrorCode = MfaErrorCode | (string & {}); -type ErrorRecord = Record; - -function asRecord(value: unknown): ErrorRecord | undefined { - return typeof value === 'object' && value !== null - ? (value as ErrorRecord) - : undefined; -} - /** * Base error for MFA operations. * diff --git a/packages/profile-sync-controller/src/sdk/index.ts b/packages/profile-sync-controller/src/sdk/index.ts index 63d0734c920..78772c79a63 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -1,4 +1,27 @@ export * from './authentication.js'; +export { MFA_CREDENTIAL_TYPES } from './authentication-jwt-bearer/mfa/types.js'; +export type { + MfaCredentialType, + MfaCredentialStatus, + MfaErrorCode, + EnrolledCredential, + TokenReason, + EnrollmentChallenge, + EnrollmentProof, + StepUpChallenge, + StepUpProof, + ElevatedProfileToken, + BeginEnrollmentRequest, + CompleteEnrollmentRequest, + BeginStepUpRequest, + CompleteStepUpRequest, + GetElevatedTokenRequest, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, + AuthenticationResponseJSON, +} from './authentication-jwt-bearer/mfa/types.js'; +export type { MfaStepUpAssertion } from './authentication-jwt-bearer/mfa/services.js'; export * from './user-storage.js'; export * from './errors.js'; export * from './utils/messaging-signing-snap-requests.js'; diff --git a/packages/profile-sync-controller/src/sdk/mocks/auth.ts b/packages/profile-sync-controller/src/sdk/mocks/auth.ts index 4a707ecd4c5..66c09c34397 100644 --- a/packages/profile-sync-controller/src/sdk/mocks/auth.ts +++ b/packages/profile-sync-controller/src/sdk/mocks/auth.ts @@ -1,4 +1,11 @@ import { Env, Platform } from '../../shared/env.js'; +import { + MFA_CREDENTIALS_URL, + MFA_ENROLL_COMPLETE_URL, + MFA_ENROLL_URL, + MFA_VERIFY_COMPLETE_URL, + MFA_VERIFY_URL, +} from '../authentication-jwt-bearer/mfa/services.js'; import { NONCE_URL, SIWE_LOGIN_URL, @@ -28,6 +35,110 @@ export const MOCK_CUSTOMER_SERVICE_TOKEN_URL = CUSTOMER_SERVICE_TOKEN_URL( export const MOCK_PARTNER_IDENTITY_TOKEN_URL = PARTNER_IDENTITY_TOKEN_URL( Env.PRD, ); +export const MOCK_MFA_ENROLL_URL = MFA_ENROLL_URL(Env.PRD); +export const MOCK_MFA_ENROLL_COMPLETE_URL = MFA_ENROLL_COMPLETE_URL(Env.PRD); +export const MOCK_MFA_VERIFY_URL = MFA_VERIFY_URL(Env.PRD); +export const MOCK_MFA_VERIFY_COMPLETE_URL = MFA_VERIFY_COMPLETE_URL(Env.PRD); +export const MOCK_MFA_CREDENTIALS_URL = MFA_CREDENTIALS_URL(Env.PRD); + +const MOCK_PASSKEY_CREATE_DATA = { + publicKey: { + rp: { + name: 'MetaMask', + id: 'authentication.api.cx.metamask.io', + }, + user: { + name: 'MetaMask 3f9a1c2b', + id: 'cHJvZmlsZS1pZA', + displayName: 'MetaMask 3f9a1c2b', + }, + challenge: 'Y3JlYXRlLWNoYWxsZW5nZQ', + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: [], + attestation: 'none', + }, +}; + +const MOCK_PASSKEY_REQUEST_DATA = { + publicKey: { + rpId: 'authentication.api.cx.metamask.io', + challenge: 'dmVyaWZ5LWNoYWxsZW5nZQ', + allowCredentials: [{ type: 'public-key', id: 'Y3JlZGVudGlhbC1pZA' }], + userVerification: 'required', + }, +}; + +export const MOCK_MFA_ENROLL_PASSKEY_RESPONSE = { + flow_id: 'enroll-passkey-flow-id', + expires_at: '2099-09-07T14:30:00Z', + passkey_create_data: JSON.stringify(MOCK_PASSKEY_CREATE_DATA), +}; + +export const MOCK_MFA_ENROLL_EMAIL_RESPONSE = { + flow_id: 'enroll-email-flow-id', + expires_at: '2099-09-07T14:30:00Z', +}; + +export const MOCK_MFA_ENROLL_COMPLETE_RESPONSE = { + status: 'enrolled', +}; + +export const MOCK_MFA_VERIFY_PASSKEY_RESPONSE = { + flow_id: 'verify-passkey-flow-id', + expires_at: '2099-09-07T14:30:00Z', + passkey_request_data: JSON.stringify(MOCK_PASSKEY_REQUEST_DATA), +}; + +export const MOCK_MFA_VERIFY_EMAIL_RESPONSE = { + flow_id: 'verify-email-flow-id', + expires_at: '2099-09-07T14:30:00Z', +}; + +export const MOCK_MFA_ASSERTION_JWT = + 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJmODgyMjdiZC1iNjE1LTQxYTMtYjBiZS00NjdkZDc4MWE0YWQiLCJhYWwiOjIsImFtciI6InBhc3NrZXkiLCJleHAiOjQxMDI0NDQ4MDB9.signature'; + +export const MOCK_MFA_VERIFY_COMPLETE_RESPONSE = { + token: MOCK_MFA_ASSERTION_JWT, + expires_in: 900, + profile: { + profile_id: 'f88227bd-b615-41a3-b0be-467dd781a4ad', + identifier_id: + 'da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb', + identifier_type: 'SRP', + }, + profile_aliases: [], +}; + +export const MOCK_MFA_CREDENTIALS_RESPONSE = { + credentials: [ + { + credential_type: 'passkey', + status: 'active', + enrolled_at: '2026-09-15T10:00:00Z', + passkey: { + display_name: 'MetaMask 3f9a1c2b', + added_at: '2026-09-15T10:00:00Z', + }, + }, + { + credential_type: 'email_otp', + status: 'pending', + enrolled_at: '2026-09-15T10:05:00Z', + email: { + address: 'user@example.com', + verified: false, + }, + }, + ], +}; + +export const MOCK_ELEVATED_ACCESS_JWT = + 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJmODgyMjdiZC1iNjE1LTQxYTMtYjBiZS00NjdkZDc4MWE0YWQiLCJhYWwiOjIsImFtciI6WyJwYXNza2V5Il0sImV4cCI6NDEwMjQ0NDgwMH0.signature'; + +export const MOCK_ELEVATED_ACCESS_TOKEN_RESPONSE = { + access_token: MOCK_ELEVATED_ACCESS_JWT, + expires_in: 900, +}; export const MOCK_JWT = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImIwNzE2N2U2LWJjNWUtNDgyZC1hNjRhLWU1MjQ0MjY2MGU3NyJ9.eyJzdWIiOiI1MzE0ODc5YWM2NDU1OGI3OTQ5ZmI4NWIzMjg2ZjZjNjUwODAzYmFiMTY0Y2QyOWNmMmM3YzdmMjMzMWMwZTRlIiwiaWF0IjoxNzA2MTEzMDYyLCJleHAiOjE3NjkxODUwNjMsImlzcyI6ImF1dGgubWV0YW1hc2suaW8iLCJhdWQiOiJwb3J0Zm9saW8ubWV0YW1hc2suaW8ifQ.E5UL6oABNweS8t5a6IBTqTf7NLOJbrhJSmEcsr7kwLp4bGvcENJzACwnsHDkA6PlzfDV09ZhAGU_F3hlS0j-erbY0k0AFR-GAtyS7E9N02D8RgUDz5oDR65CKmzM8JilgFA8UvruJ6OJGogroaOSOqzRES_s8MjHpP47RJ9lXrUesajsbOudXbuksXWg5QmWip6LLvjwr8UUzcJzNQilyIhiEpo4WdzWM4R3VtTwr4rHnWEvtYnYCov1jmI2w3YQ48y0M-3Y9IOO0ov_vlITRrOnR7Y7fRUGLUFmU5msD8mNWRywjQFLHfJJ1yNP5aJ8TkuCK3sC6kcUH335IVvukQ'; diff --git a/packages/profile-sync-controller/src/sdk/utils/as-record.test.ts b/packages/profile-sync-controller/src/sdk/utils/as-record.test.ts new file mode 100644 index 00000000000..79bd1272513 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/as-record.test.ts @@ -0,0 +1,15 @@ +import { asRecord } from './as-record.js'; + +describe('asRecord()', () => { + it('returns objects and arrays unchanged', () => { + expect(asRecord({ foo: 'bar' })).toStrictEqual({ foo: 'bar' }); + expect(asRecord([1, 2, 3])).toStrictEqual([1, 2, 3]); + }); + + it.each([null, undefined, 'string', 123, true])( + 'returns undefined for non-object value %p', + (value) => { + expect(asRecord(value)).toBeUndefined(); + }, + ); +}); diff --git a/packages/profile-sync-controller/src/sdk/utils/as-record.ts b/packages/profile-sync-controller/src/sdk/utils/as-record.ts new file mode 100644 index 00000000000..3cf1efda595 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/as-record.ts @@ -0,0 +1,13 @@ +/** + * Narrows an unknown value to a plain record, for reading optional fields off + * untrusted data (parsed JSON, decoded JWT payloads) before it has been + * validated against a struct. + * + * @param value - The value to narrow. + * @returns The value as a record, or undefined if it is not an object. + */ +export function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined; +} diff --git a/packages/profile-sync-controller/src/sdk/utils/to-error-message.test.ts b/packages/profile-sync-controller/src/sdk/utils/to-error-message.test.ts new file mode 100644 index 00000000000..42a44b2755c --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/to-error-message.test.ts @@ -0,0 +1,16 @@ +import { toErrorMessage } from './to-error-message.js'; + +describe('toErrorMessage()', () => { + it('returns the message of an Error', () => { + expect(toErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it.each([ + ['string', 'plain', 'plain'], + ['number', 42, '42'], + ['null', null, 'null'], + ['undefined', undefined, 'undefined'], + ])('stringifies a non-Error %s', (_name, value, expected) => { + expect(toErrorMessage(value)).toBe(expected); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/utils/to-error-message.ts b/packages/profile-sync-controller/src/sdk/utils/to-error-message.ts new file mode 100644 index 00000000000..9dc859204b0 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/to-error-message.ts @@ -0,0 +1,9 @@ +/** + * Extracts a human-readable message from a caught value. + * + * @param error - The caught value. + * @returns The error's message, or its string form when it is not an Error. + */ +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +}