From 82e3d3132e05c8455cc8afc46f3599389e89e037 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 16 Sep 2026 13:47:22 +0200 Subject: [PATCH 1/8] feat(profile-sync): add MFA authentication services Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 1 + .../src/sdk/__fixtures__/auth.ts | 95 ++++ .../flow-srp.test.ts | 235 ++++++++ .../sdk/authentication-jwt-bearer/flow-srp.ts | 173 +++++- .../mfa/services.test.ts | 375 +++++++++++++ .../authentication-jwt-bearer/mfa/services.ts | 524 ++++++++++++++++++ .../src/sdk/authentication.test.ts | 60 ++ .../src/sdk/authentication.ts | 63 +++ .../profile-sync-controller/src/sdk/index.ts | 1 + .../src/sdk/mocks/auth.ts | 112 ++++ 10 files changed, 1638 insertions(+), 1 deletion(-) create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.ts 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..ff039c7ba8e 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,225 @@ 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', 'user@example.com'), + ).toStrictEqual({ + type: 'email_otp', + flowId: 'email-flow', + expiresAt: 2000, + emailSent: true, + }); + expect(mockMfaEnroll).toHaveBeenLastCalledWith(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('passkey', 'flow-id', { + type: 'passkey', + attestation, + }); + await auth.completeMfaEnrollment('email_otp', '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, + deliverySent: true, + }); + }); + + 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('email_otp', '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('passkey', '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..5fd936339b1 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,21 @@ 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 { + EnrolledCredential, + EnrollmentChallenge, + EnrollmentProof, + MfaCredentialType, + StepUpChallenge, + StepUpProof, +} from './mfa/types.js'; import { authenticate, authorizeOIDC, @@ -23,6 +38,7 @@ import { import type { PairProfilesResponse } from './services.js'; import type { AuthConfig, + AccessToken, AuthSigningOptions, AuthStorageOptions, AuthType, @@ -212,6 +228,161 @@ export class SRPJwtBearerAuth implements IBaseAuth { ); } + /** + * Begins enrollment of an MFA credential for the primary profile. + * + * @param type - Credential type to enroll. + * @param email - Email address, required for email OTP. + * @param entropySourceId - Entropy source whose profile owns the credential. + * @returns Enrollment challenge for the client ceremony. + */ + async beginMfaEnrollment( + type: MfaCredentialType, + email?: string, + entropySourceId?: string, + ): Promise { + 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, + emailSent: true, + }; + } + + /** + * Completes enrollment of an MFA credential. + * + * @param type - Credential type being enrolled. + * @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( + type: MfaCredentialType, + flowId: string, + proof: EnrollmentProof, + entropySourceId?: string, + ): Promise { + const accessToken = await this.getAccessToken(entropySourceId); + await mfaEnrollComplete(this.#config.env, accessToken, { + credential_type: 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, + deliverySent: true, + }; + } + + /** + * Completes step-up verification with an enrolled credential. + * + * @param type - Credential type being verified. + * @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 Authentication assertion issued after verification. + */ + async completeMfaVerification( + type: MfaCredentialType, + flowId: string, + proof: StepUpProof, + entropySourceId?: string, + ): ReturnType { + const accessToken = await this.getAccessToken(entropySourceId); + return await mfaVerifyComplete(this.#config.env, accessToken, { + credential_type: 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/services.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.test.ts new file mode 100644 index 00000000000..7e3bbc658ea --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.test.ts @@ -0,0 +1,375 @@ +import { Env } from '../../../shared/env.js'; +import { + CredentialAlreadyEnrolledError, + CredentialNotEnrolledError, + MaxIdentifiersReachedError, + MaxPasskeysReachedError, + MfaError, + MfaFlowExpiredError, + 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( + expect.objectContaining({ + token: MOCK_MFA_VERIFY_COMPLETE_RESPONSE.token, + expiresIn: 900, + profile: expect.objectContaining({ + canonicalProfileId: + MOCK_MFA_VERIFY_COMPLETE_RESPONSE.profile.profile_id, + }), + }), + ); + 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: 'email_otp', + status: 'pending', + }), + ).toBeNull(); + }); + + 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', MfaFlowExpiredError, 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 Retry-After', async () => { + mockFetch.mockResolvedValue( + response( + { code: 'otp_resend_cooldown', message: 'Wait before retrying' }, + { status: 429, headers: { 'Retry-After': '12' } }, + ), + ); + + await expect( + mfaVerify(Env.PRD, 'access-token', { + credential_type: 'email_otp', + }), + ).rejects.toMatchObject({ + mfaCode: 'otp_resend_cooldown', + retryAfterMs: 12_000, + }); + }); + + it('falls back to status and message cooldown mappings', async () => { + mockFetch + .mockResolvedValueOnce( + response({ message: 'Retry after 8 seconds' }, { status: 429 }), + ) + .mockResolvedValueOnce( + response({ message: 'Unavailable' }, { status: 502 }), + ); + + await expect( + mfaVerify(Env.PRD, 'access-token', { + credential_type: 'email_otp', + }), + ).rejects.toMatchObject({ 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('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' }, + }), + ); + + 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' }); + }); + + it('rejects malformed embedded JSON and expiration dates', async () => { + expect(() => parsePasskeyCreateData('{')).toThrow( + /MFA\[invalid_response\]/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', async () => { + mockFetch.mockRejectedValue(new Error('offline')); + await expect( + getMfaCredentials(Env.PRD, 'access-token'), + ).rejects.toBeInstanceOf(MfaUnavailableError); + }); +}); 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..c06638f35dc --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/services.ts @@ -0,0 +1,524 @@ +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, + MfaUnavailableError, + MfaVerificationFailedError, + OtpResendCooldownError, + TooManyAttemptsError, +} from '../../errors.js'; +import { + AuthenticationResponseJSONStruct, + AuthenticationResponseStruct, + MfaCredentialsResponseStruct, + MfaEnrollCompleteResponseStruct, + MfaEnrollResponseStruct, + MfaErrorResponseStruct, + MfaVerifyResponseStruct, + PasskeyCreateDataStruct, + PasskeyRequestDataStruct, + RegistrationResponseJSONStruct, + assertValidMfaRequest, + assertValidMfaResponse, +} from './schemas.js'; +import type { + AuthenticationResponseJSON, + EnrolledCredential, + MfaCredential, + MfaCredentialType, + MfaEnrollCompleteRequest, + MfaEnrollRequest, + MfaVerifyCompleteRequest, + MfaVerifyCompleteResponse, + 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; +}; + +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 = error instanceof Error ? error.message : String(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 = error instanceof Error ? error.message : String(error); + throw new MfaError('invalid_response', message); + } +} + +function parseRetryAfter( + response: Response, + message: string, +): number | undefined { + const header = response.headers.get('Retry-After'); + if (header) { + const seconds = Number(header); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + const date = Date.parse(header); + if (!Number.isNaN(date)) { + return Math.max(0, date - Date.now()); + } + } + + const match = + /(?:retry|wait)(?:\s+after|\s+for)?\s+(\d+)\s*(?:s|sec|seconds?)/iu.exec( + message, + ); + return match?.[1] ? Number(match[1]) * 1000 : undefined; +} + +async function throwMfaError( + response: Response, + errorPrefix: string, +): Promise { + let body: unknown; + try { + body = await response.json(); + assertValidMfaResponse(body, MfaErrorResponseStruct); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new MfaError('invalid_response', `${errorPrefix}: ${message}`, { + status: response.status, + }); + } + + const message = `${errorPrefix}: ${body.message}`; + const { code } = body; + if (response.status === HTTP_STATUS_CODES.UNAUTHORIZED) { + throw new MfaError('authentication_required', message, { + status: response.status, + }); + } + switch (code) { + case 'credential_already_enrolled': + case 'email_already_enrolled': + throw new CredentialAlreadyEnrolledError(code, message, response.status); + case 'credential_not_enrolled': + throw new CredentialNotEnrolledError(message, response.status); + case 'flow_expired': + case 'invalid_flow': + case 'mfa_identity_missing': + throw new MfaFlowExpiredError(code, message, response.status); + case 'invalid_code': + case 'invalid_attestation': + case 'invalid_assertion': + throw new MfaVerificationFailedError(code, message, response.status); + case 'too_many_attempts': + throw new TooManyAttemptsError(message, response.status); + case 'max_passkeys_reached': + throw new MaxPasskeysReachedError(message, response.status); + case 'max_identifiers_reached': + throw new MaxIdentifiersReachedError(message, response.status); + case 'otp_resend_cooldown': + throw new OtpResendCooldownError( + message, + parseRetryAfter(response, body.message), + response.status, + ); + case 'kratos_unavailable': + throw new MfaUnavailableError(message, response.status); + default: + if (response.status === HTTP_STATUS_CODES.TOO_MANY_REQUESTS) { + throw new OtpResendCooldownError( + message, + parseRetryAfter(response, body.message), + response.status, + ); + } + if (response.status === HTTP_STATUS_CODES.BAD_GATEWAY) { + throw new MfaUnavailableError(message, response.status); + } + throw new MfaError(code ?? 'server_error', message, { + status: response.status, + }); + } +} + +async function requestJson( + url: string, + accessToken: string, + init?: { method?: 'GET' | 'POST'; body?: unknown }, +): 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 = error instanceof Error ? error.message : String(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 = error instanceof Error ? error.message : String(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 Authentication assertion and profile details. + */ +export async function mfaVerifyComplete( + env: Env, + accessToken: string, + params: VerificationCompletionParams, +): Promise<{ + token: string; + expiresIn: number; + profile: { + identifierId: string; + metaMetricsId: string; + profileId: string; + canonicalProfileId: string; + }; + profileAliases: { + aliasProfileId: string; + canonicalProfileId: string; + identifierIds: { id: string; type: string }[]; + }[]; +}> { + 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, AuthenticationResponseStruct); + return mapAuthenticationResponse(json); +} + +function mapAuthenticationResponse(json: MfaVerifyCompleteResponse): { + token: string; + expiresIn: number; + profile: { + identifierId: string; + metaMetricsId: string; + profileId: string; + canonicalProfileId: string; + }; + profileAliases: { + aliasProfileId: string; + canonicalProfileId: string; + identifierIds: { id: string; type: string }[]; + }[]; +} { + return { + token: json.token, + expiresIn: json.expires_in, + profile: { + identifierId: json.profile.identifier_id, + metaMetricsId: json.profile.metametrics_id ?? '', + profileId: json.profile.profile_id, + canonicalProfileId: json.profile.profile_id, + }, + profileAliases: (json.profile_aliases ?? []).map((alias) => ({ + aliasProfileId: alias.alias_profile_id, + canonicalProfileId: alias.canonical_profile_id, + identifierIds: alias.identifier_ids ?? [], + })), + }; +} + +/** + * Maps a server credential to its public controller representation. + * + * @param credential - Validated server credential. + * @returns A supported credential, or null for an unknown future type. + */ +export function toEnrolledCredential( + credential: MfaCredential, +): EnrolledCredential | null { + if (credential.credential_type === 'passkey') { + const enrolledAt = parseEnrolledAt(credential.enrolled_at); + return { + type: 'passkey', + status: credential.status, + ...(enrolledAt === undefined ? {} : { enrolledAt }), + ...(credential.passkey?.display_name + ? { displayName: credential.passkey.display_name } + : {}), + }; + } + + if ( + credential.credential_type === 'email_otp' && + credential.email?.address !== undefined && + credential.email.verified !== undefined + ) { + const enrolledAt = parseEnrolledAt(credential.enrolled_at); + return { + type: 'email_otp', + status: credential.status, + ...(enrolledAt === undefined ? {} : { enrolledAt }), + email: credential.email.address, + verified: credential.email.verified, + }; + } + + log.warn( + `Ignoring unsupported or incomplete MFA credential: ${credential.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..5053028ed64 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('passkey', 'flow-id', { + type: 'passkey', + attestation: registration, + }), + ).toBeUndefined(); + expect(await auth.beginMfaVerification('passkey')).toMatchObject({ + type: 'passkey', + flowId: 'verify-passkey-flow-id', + }); + expect( + await auth.completeMfaVerification('passkey', '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..95744cf20c2 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -4,12 +4,21 @@ 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 { + 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 +111,60 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { ); } + async beginMfaEnrollment( + type: MfaCredentialType, + email?: string, + entropySourceId?: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.beginMfaEnrollment(type, email, entropySourceId); + } + + async completeMfaEnrollment( + type: MfaCredentialType, + flowId: string, + proof: EnrollmentProof, + entropySourceId?: string, + ): Promise { + this.#assertSRP(this.#type, this.#sdk); + await this.#sdk.completeMfaEnrollment(type, 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( + type: MfaCredentialType, + flowId: string, + proof: StepUpProof, + entropySourceId?: string, + ): ReturnType { + this.#assertSRP(this.#type, this.#sdk); + return await this.#sdk.completeMfaVerification( + type, + 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/index.ts b/packages/profile-sync-controller/src/sdk/index.ts index 63d0734c920..97aae94ed19 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -1,4 +1,5 @@ export * from './authentication.js'; +export * from './authentication-jwt-bearer/mfa/types.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..6d7ac614f70 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,111 @@ 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: 'PASSKEY', + metametrics_id: '561ec651-a844-4b36-a451-04d6eac35740', + }, + 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'; From bf4b031eaec8a7d510348c7ef30ea602ad012041 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 16 Sep 2026 21:42:55 +0200 Subject: [PATCH 2/8] fix(profile-sync): harden MFA service error mapping - Recognise 401 before requiring a JSON error body - Map code-less 429s to rate_limited instead of otp_resend_cooldown - Stop parsing the unstable error message for a retry delay - Return only the assertion from verify/complete; drop profile mapping - Tolerate unknown credential statuses and missing email.verified - Export domain types only from the SDK entrypoint Co-authored-by: Cursor --- .../flow-srp.test.ts | 2 - .../sdk/authentication-jwt-bearer/flow-srp.ts | 7 +- .../mfa/services.test.ts | 153 +++++++++--- .../authentication-jwt-bearer/mfa/services.ts | 225 ++++++++---------- .../src/sdk/authentication.ts | 3 +- .../profile-sync-controller/src/sdk/index.ts | 25 +- .../src/sdk/mocks/auth.ts | 3 +- 7 files changed, 253 insertions(+), 165 deletions(-) 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 ff039c7ba8e..37e983d66eb 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 @@ -362,7 +362,6 @@ describe('SRP MFA methods', () => { type: 'email_otp', flowId: 'email-flow', expiresAt: 2000, - emailSent: true, }); expect(mockMfaEnroll).toHaveBeenLastCalledWith(Env.DEV, accessToken, { credential_type: 'email_otp', @@ -447,7 +446,6 @@ describe('SRP MFA methods', () => { type: 'email_otp', flowId: 'email-flow', expiresAt: 2000, - deliverySent: true, }); }); 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 5fd936339b1..32ca628f1c2 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 @@ -17,6 +17,7 @@ import { mfaVerify, mfaVerifyComplete, } from './mfa/services.js'; +import type { MfaAssertion } from './mfa/services.js'; import type { EnrolledCredential, EnrollmentChallenge, @@ -265,7 +266,6 @@ export class SRPJwtBearerAuth implements IBaseAuth { type, flowId: result.flowId, expiresAt: result.expiresAt, - emailSent: true, }; } @@ -327,7 +327,6 @@ export class SRPJwtBearerAuth implements IBaseAuth { type, flowId: result.flowId, expiresAt: result.expiresAt, - deliverySent: true, }; } @@ -338,14 +337,14 @@ export class SRPJwtBearerAuth implements IBaseAuth { * @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 Authentication assertion issued after verification. + * @returns AAL2 assertion issued after verification. */ async completeMfaVerification( type: MfaCredentialType, flowId: string, proof: StepUpProof, entropySourceId?: string, - ): ReturnType { + ): Promise { const accessToken = await this.getAccessToken(entropySourceId); return await mfaVerifyComplete(this.#config.env, accessToken, { credential_type: type, 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 index 7e3bbc658ea..7db6266773a 100644 --- 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 @@ -6,6 +6,8 @@ import { MaxPasskeysReachedError, MfaError, MfaFlowExpiredError, + MfaIdentityMissingError, + MfaRateLimitedError, MfaUnavailableError, MfaVerificationFailedError, TooManyAttemptsError, @@ -136,16 +138,10 @@ describe('MFA services', () => { challenge: 'dmVyaWZ5LWNoYWxsZW5nZQ', }), ); - expect(completion).toStrictEqual( - expect.objectContaining({ - token: MOCK_MFA_VERIFY_COMPLETE_RESPONSE.token, - expiresIn: 900, - profile: expect.objectContaining({ - canonicalProfileId: - MOCK_MFA_VERIFY_COMPLETE_RESPONSE.profile.profile_id, - }), - }), - ); + 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)); }); @@ -224,6 +220,12 @@ describe('MFA services', () => { status: 'active', }), ).toBeNull(); + expect( + toEnrolledCredential({ + credential_type: 'passkey', + status: 'revoked', + }), + ).toBeNull(); expect( toEnrolledCredential({ credential_type: 'email_otp', @@ -232,13 +234,35 @@ describe('MFA services', () => { ).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', MfaFlowExpiredError, 409], + ['mfa_identity_missing', MfaIdentityMissingError, 409], ['invalid_code', MfaVerificationFailedError, 400], ['invalid_attestation', MfaVerificationFailedError, 400], ['invalid_assertion', MfaVerificationFailedError, 400], @@ -258,38 +282,65 @@ describe('MFA services', () => { ).rejects.toBeInstanceOf(ErrorClass); }); - it('maps resend cooldown and parses Retry-After', async () => { - mockFetch.mockResolvedValue( - response( - { code: 'otp_resend_cooldown', message: 'Wait before retrying' }, - { status: 429, headers: { 'Retry-After': '12' } }, - ), - ); + 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 }, + ), + ); - await expect( - mfaVerify(Env.PRD, 'access-token', { - credential_type: 'email_otp', - }), - ).rejects.toMatchObject({ + 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('falls back to status and message cooldown mappings', async () => { + it('maps code-less 429 and 502 responses by status', async () => { mockFetch .mockResolvedValueOnce( - response({ message: 'Retry after 8 seconds' }, { status: 429 }), + response( + { message: 'Slow down' }, + { status: 429, headers: { 'Retry-After': '8' } }, + ), ) .mockResolvedValueOnce( response({ message: 'Unavailable' }, { status: 502 }), ); - await expect( - mfaVerify(Env.PRD, 'access-token', { - credential_type: 'email_otp', - }), - ).rejects.toMatchObject({ retryAfterMs: 8_000 }); + 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', @@ -323,6 +374,22 @@ describe('MFA services', () => { ).rejects.toMatchObject({ mfaCode: 'server_error' }); }); + 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' })) @@ -331,6 +398,12 @@ describe('MFA services', () => { status: 500, headers: { 'Content-Type': 'text/plain' }, }), + ) + .mockResolvedValueOnce( + new globalThis.Response('not-json', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), ); await expect( @@ -342,13 +415,21 @@ describe('MFA services', () => { mfaEnroll(Env.PRD, 'access-token', { credential_type: 'passkey', }), - ).rejects.toMatchObject({ mfaCode: 'invalid_response' }); + ).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('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); @@ -366,10 +447,12 @@ describe('MFA services', () => { ).rejects.toBeInstanceOf(MfaError); }); - it('maps network failures to an unavailable error', async () => { + it('maps network failures to an unavailable error without a status', async () => { mockFetch.mockRejectedValue(new Error('offline')); - await expect( - getMfaCredentials(Env.PRD, 'access-token'), - ).rejects.toBeInstanceOf(MfaUnavailableError); + 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 index c06638f35dc..0d5f118d4a5 100644 --- 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 @@ -10,6 +10,8 @@ import { MaxPasskeysReachedError, MfaError, MfaFlowExpiredError, + MfaIdentityMissingError, + MfaRateLimitedError, MfaUnavailableError, MfaVerificationFailedError, OtpResendCooldownError, @@ -17,11 +19,11 @@ import { } from '../../errors.js'; import { AuthenticationResponseJSONStruct, - AuthenticationResponseStruct, MfaCredentialsResponseStruct, MfaEnrollCompleteResponseStruct, MfaEnrollResponseStruct, MfaErrorResponseStruct, + MfaVerifyCompleteResponseStruct, MfaVerifyResponseStruct, PasskeyCreateDataStruct, PasskeyRequestDataStruct, @@ -33,11 +35,11 @@ import type { AuthenticationResponseJSON, EnrolledCredential, MfaCredential, + MfaCredentialStatus, MfaCredentialType, MfaEnrollCompleteRequest, MfaEnrollRequest, MfaVerifyCompleteRequest, - MfaVerifyCompleteResponse, MfaVerifyRequest, PublicKeyCredentialCreationOptionsJSON as PasskeyCreationOptions, PublicKeyCredentialRequestOptionsJSON as PasskeyRequestOptions, @@ -71,6 +73,13 @@ type VerificationServiceResult = { publicKey?: PasskeyRequestOptions; }; +export type MfaAssertion = { + /** 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; @@ -161,93 +170,105 @@ export function parsePasskeyRequestData(value: string): PasskeyRequestOptions { } } -function parseRetryAfter( - response: Response, - message: string, -): number | undefined { +/** + * 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) { - const seconds = Number(header); - if (!Number.isNaN(seconds)) { - return Math.max(0, seconds * 1000); - } - const date = Date.parse(header); - if (!Number.isNaN(date)) { - return Math.max(0, date - Date.now()); - } + if (!header) { + return undefined; } + const seconds = Number(header); + if (!Number.isNaN(seconds)) { + return Math.max(0, seconds * 1000); + } + const date = Date.parse(header); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} - const match = - /(?:retry|wait)(?:\s+after|\s+for)?\s+(\d+)\s*(?:s|sec|seconds?)/iu.exec( - message, - ); - return match?.[1] ? Number(match[1]) * 1000 : undefined; +async function readErrorBody( + response: Response, +): Promise<{ code?: string; message: string } | undefined> { + try { + const body: unknown = await response.json(); + assertValidMfaResponse(body, MfaErrorResponseStruct); + return body; + } catch { + return undefined; + } } async function throwMfaError( response: Response, errorPrefix: string, ): Promise { - let body: unknown; - try { - body = await response.json(); - assertValidMfaResponse(body, MfaErrorResponseStruct); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new MfaError('invalid_response', `${errorPrefix}: ${message}`, { - status: response.status, - }); + const { status } = response; + const body = await readErrorBody(response); + + // A rejected token must be recognised even when a gateway answers without + // the service's JSON error body, so the caller can invalidate its session. + if (status === HTTP_STATUS_CODES.UNAUTHORIZED) { + throw new MfaError( + 'authentication_required', + `${errorPrefix}: ${body?.message ?? 'Unauthorized'}`, + { status }, + ); + } + if (!body) { + throw new MfaError( + 'invalid_response', + `${errorPrefix}: Unexpected error response (HTTP ${status})`, + { status }, + ); } const message = `${errorPrefix}: ${body.message}`; const { code } = body; - if (response.status === HTTP_STATUS_CODES.UNAUTHORIZED) { - throw new MfaError('authentication_required', message, { - status: response.status, - }); - } switch (code) { case 'credential_already_enrolled': case 'email_already_enrolled': - throw new CredentialAlreadyEnrolledError(code, message, response.status); + throw new CredentialAlreadyEnrolledError(code, message, status); case 'credential_not_enrolled': - throw new CredentialNotEnrolledError(message, response.status); + throw new CredentialNotEnrolledError(message, status); case 'flow_expired': case 'invalid_flow': + throw new MfaFlowExpiredError(code, message, status); case 'mfa_identity_missing': - throw new MfaFlowExpiredError(code, message, response.status); + throw new MfaIdentityMissingError(message, status); case 'invalid_code': case 'invalid_attestation': case 'invalid_assertion': - throw new MfaVerificationFailedError(code, message, response.status); + throw new MfaVerificationFailedError(code, message, status); case 'too_many_attempts': - throw new TooManyAttemptsError(message, response.status); + throw new TooManyAttemptsError(message, status); case 'max_passkeys_reached': - throw new MaxPasskeysReachedError(message, response.status); + throw new MaxPasskeysReachedError(message, status); case 'max_identifiers_reached': - throw new MaxIdentifiersReachedError(message, response.status); + throw new MaxIdentifiersReachedError(message, status); case 'otp_resend_cooldown': throw new OtpResendCooldownError( message, - parseRetryAfter(response, body.message), - response.status, + parseRetryAfter(response), + status, ); case 'kratos_unavailable': - throw new MfaUnavailableError(message, response.status); + throw new MfaUnavailableError(message, status); default: - if (response.status === HTTP_STATUS_CODES.TOO_MANY_REQUESTS) { - throw new OtpResendCooldownError( + if (status === HTTP_STATUS_CODES.TOO_MANY_REQUESTS) { + throw new MfaRateLimitedError( message, - parseRetryAfter(response, body.message), - response.status, + parseRetryAfter(response), + status, ); } - if (response.status === HTTP_STATUS_CODES.BAD_GATEWAY) { - throw new MfaUnavailableError(message, response.status); + if (status === HTTP_STATUS_CODES.BAD_GATEWAY) { + throw new MfaUnavailableError(message, status); } - throw new MfaError(code ?? 'server_error', message, { - status: response.status, - }); + throw new MfaError(code ?? 'server_error', message, { status }); } } @@ -384,27 +405,13 @@ export async function mfaVerify( * @param env - Authentication environment. * @param accessToken - Primary profile access token. * @param params - Flow identifier and verification proof. - * @returns Authentication assertion and profile details. + * @returns The AAL2 assertion JWT and its lifetime in seconds. */ export async function mfaVerifyComplete( env: Env, accessToken: string, params: VerificationCompletionParams, -): Promise<{ - token: string; - expiresIn: number; - profile: { - identifierId: string; - metaMetricsId: string; - profileId: string; - canonicalProfileId: string; - }; - profileAliases: { - aliasProfileId: string; - canonicalProfileId: string; - identifierIds: { id: string; type: string }[]; - }[]; -}> { +): Promise { let body: MfaVerifyCompleteRequest = { credential_type: params.credential_type, flow_id: params.flow_id, @@ -425,81 +432,59 @@ export async function mfaVerifyComplete( method: 'POST', body, }); - assertValidMfaResponse(json, AuthenticationResponseStruct); - return mapAuthenticationResponse(json); + assertValidMfaResponse(json, MfaVerifyCompleteResponseStruct); + return { token: json.token, expiresIn: json.expires_in }; } -function mapAuthenticationResponse(json: MfaVerifyCompleteResponse): { - token: string; - expiresIn: number; - profile: { - identifierId: string; - metaMetricsId: string; - profileId: string; - canonicalProfileId: string; - }; - profileAliases: { - aliasProfileId: string; - canonicalProfileId: string; - identifierIds: { id: string; type: string }[]; - }[]; -} { - return { - token: json.token, - expiresIn: json.expires_in, - profile: { - identifierId: json.profile.identifier_id, - metaMetricsId: json.profile.metametrics_id ?? '', - profileId: json.profile.profile_id, - canonicalProfileId: json.profile.profile_id, - }, - profileAliases: (json.profile_aliases ?? []).map((alias) => ({ - aliasProfileId: alias.alias_profile_id, - canonicalProfileId: alias.canonical_profile_id, - identifierIds: alias.identifier_ids ?? [], - })), - }; +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 for an unknown future type. + * @returns A supported credential, or null when it cannot be represented. */ export function toEnrolledCredential( credential: MfaCredential, ): EnrolledCredential | null { - if (credential.credential_type === 'passkey') { - const enrolledAt = parseEnrolledAt(credential.enrolled_at); + 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: 'passkey', - status: credential.status, - ...(enrolledAt === undefined ? {} : { enrolledAt }), + type, + ...base, ...(credential.passkey?.display_name ? { displayName: credential.passkey.display_name } : {}), }; } - if ( - credential.credential_type === 'email_otp' && - credential.email?.address !== undefined && - credential.email.verified !== undefined - ) { - const enrolledAt = parseEnrolledAt(credential.enrolled_at); + if (type === 'email_otp' && credential.email?.address !== undefined) { return { - type: 'email_otp', - status: credential.status, - ...(enrolledAt === undefined ? {} : { enrolledAt }), + type, + ...base, email: credential.email.address, - verified: credential.email.verified, + // The spec marks `verified` optional; an active row is verified. + verified: credential.email.verified ?? status === 'active', }; } - log.warn( - `Ignoring unsupported or incomplete MFA credential: ${credential.credential_type}`, - ); + log.warn(`Ignoring unsupported or incomplete MFA credential: ${type}`); return null; } diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 95744cf20c2..789358e168c 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -4,6 +4,7 @@ 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 { MfaAssertion } from './authentication-jwt-bearer/mfa/services.js'; import type { EnrolledCredential, EnrollmentChallenge, @@ -143,7 +144,7 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { flowId: string, proof: StepUpProof, entropySourceId?: string, - ): ReturnType { + ): Promise { this.#assertSRP(this.#type, this.#sdk); return await this.#sdk.completeMfaVerification( type, diff --git a/packages/profile-sync-controller/src/sdk/index.ts b/packages/profile-sync-controller/src/sdk/index.ts index 97aae94ed19..9121d5ce567 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -1,5 +1,28 @@ export * from './authentication.js'; -export * from './authentication-jwt-bearer/mfa/types.js'; +// Domain types only; the snake_case wire DTOs stay internal to the services. +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 { MfaAssertion } 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 6d7ac614f70..66c09c34397 100644 --- a/packages/profile-sync-controller/src/sdk/mocks/auth.ts +++ b/packages/profile-sync-controller/src/sdk/mocks/auth.ts @@ -104,8 +104,7 @@ export const MOCK_MFA_VERIFY_COMPLETE_RESPONSE = { profile_id: 'f88227bd-b615-41a3-b0be-467dd781a4ad', identifier_id: 'da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb', - identifier_type: 'PASSKEY', - metametrics_id: '561ec651-a844-4b36-a451-04d6eac35740', + identifier_type: 'SRP', }, profile_aliases: [], }; From 73357085836010b23f6adb1e571823c926c7ba84 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 16 Sep 2026 22:30:34 +0200 Subject: [PATCH 3/8] fix: lint and cursor feedback --- .../mfa/services.test.ts | 33 ++++++++++++- .../authentication-jwt-bearer/mfa/services.ts | 49 +++++++++---------- 2 files changed, 54 insertions(+), 28 deletions(-) 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 index 7db6266773a..17cf133550d 100644 --- 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 @@ -305,7 +305,9 @@ describe('MFA services', () => { ); const verifyEmail = async (): Promise => - await mfaVerify(Env.PRD, 'access-token', { credential_type: 'email_otp' }); + await mfaVerify(Env.PRD, 'access-token', { + credential_type: 'email_otp', + }); await expect(verifyEmail()).rejects.toMatchObject({ mfaCode: 'otp_resend_cooldown', @@ -374,6 +376,35 @@ describe('MFA services', () => { ).rejects.toMatchObject({ mfaCode: 'server_error' }); }); + 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('', { 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 index 0d5f118d4a5..8735ebae1da 100644 --- 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 @@ -208,26 +208,16 @@ async function throwMfaError( ): Promise { const { status } = response; const body = await readErrorBody(response); + const message = `${errorPrefix}: ${body?.message ?? `HTTP ${status}`}`; - // A rejected token must be recognised even when a gateway answers without - // the service's JSON error body, so the caller can invalidate its session. + // 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', - `${errorPrefix}: ${body?.message ?? 'Unauthorized'}`, - { status }, - ); - } - if (!body) { - throw new MfaError( - 'invalid_response', - `${errorPrefix}: Unexpected error response (HTTP ${status})`, - { status }, - ); + throw new MfaError('authentication_required', message, { status }); } - const message = `${errorPrefix}: ${body.message}`; - const { code } = body; + const code = body?.code; switch (code) { case 'credential_already_enrolled': case 'email_already_enrolled': @@ -258,18 +248,23 @@ async function throwMfaError( case 'kratos_unavailable': throw new MfaUnavailableError(message, status); default: - 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); - } - throw new MfaError(code ?? 'server_error', message, { status }); + 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( From 16ea2f9016711e25af26e9f1e1684204fe36cc7f Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Thu, 17 Sep 2026 16:16:05 +0200 Subject: [PATCH 4/8] fix: remove unneeded comment --- packages/profile-sync-controller/src/sdk/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/profile-sync-controller/src/sdk/index.ts b/packages/profile-sync-controller/src/sdk/index.ts index 9121d5ce567..6f33d2b8255 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -1,5 +1,4 @@ export * from './authentication.js'; -// Domain types only; the snake_case wire DTOs stay internal to the services. export { MFA_CREDENTIAL_TYPES } from './authentication-jwt-bearer/mfa/types.js'; export type { MfaCredentialType, From 6f660ff6d7dc50d616d450a169b89cddcd336270 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Thu, 17 Sep 2026 16:34:18 +0200 Subject: [PATCH 5/8] fix: change beginMfaEnrollment shape for two optional args --- .../flow-srp.test.ts | 37 ++++++++++++++++++- .../sdk/authentication-jwt-bearer/flow-srp.ts | 10 +++-- .../src/sdk/authentication.ts | 5 +-- 3 files changed, 44 insertions(+), 8 deletions(-) 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 37e983d66eb..2c60515cb0c 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 @@ -357,7 +357,9 @@ describe('SRP MFA methods', () => { flowId: 'passkey-flow', }); expect( - await auth.beginMfaEnrollment('email_otp', 'user@example.com'), + await auth.beginMfaEnrollment('email_otp', { + email: 'user@example.com', + }), ).toStrictEqual({ type: 'email_otp', flowId: 'email-flow', @@ -369,6 +371,39 @@ describe('SRP MFA methods', () => { }); }); + 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 () => 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({ 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 32ca628f1c2..231f742708f 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 @@ -233,15 +233,17 @@ export class SRPJwtBearerAuth implements IBaseAuth { * Begins enrollment of an MFA credential for the primary profile. * * @param type - Credential type to enroll. - * @param email - Email address, required for email OTP. - * @param entropySourceId - Entropy source whose profile owns the credential. + * @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, - email?: string, - entropySourceId?: string, + 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, diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 789358e168c..19545cef5a5 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -114,11 +114,10 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { async beginMfaEnrollment( type: MfaCredentialType, - email?: string, - entropySourceId?: string, + options?: { email?: string; entropySourceId?: string }, ): Promise { this.#assertSRP(this.#type, this.#sdk); - return await this.#sdk.beginMfaEnrollment(type, email, entropySourceId); + return await this.#sdk.beginMfaEnrollment(type, options); } async completeMfaEnrollment( From 3621d32700108974e80242cdf3115906bd9c0333 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Thu, 17 Sep 2026 18:32:24 +0200 Subject: [PATCH 6/8] fix: add sensitive fields to schemas + fix lint --- .../flow-srp.test.ts | 5 +- .../authentication-jwt-bearer/mfa/schemas.ts | 65 ++++++++++--------- 2 files changed, 39 insertions(+), 31 deletions(-) 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 2c60515cb0c..39f2126e176 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 @@ -384,7 +384,10 @@ describe('SRP MFA methods', () => { }), ); const auth = new SRPJwtBearerAuth(config, { - storage: { getLoginResponse, setLoginResponse: async () => undefined }, + storage: { + getLoginResponse, + setLoginResponse: async (): Promise => undefined, + }, signing: { getIdentifier: async (): Promise => 'identifier', signMessage: async (): Promise => 'signature', 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..1bf35ce9e4f 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, @@ -81,42 +82,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(), }); @@ -148,7 +153,7 @@ export const MfaPasskeyDetailStruct = type({ }); export const MfaEmailDetailStruct = type({ - address: optional(string()), + address: optional(sensitive(string())), verified: optional(boolean()), }); @@ -180,7 +185,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 +200,7 @@ export const BeginEnrollmentRequestStruct = refine( }, ); -const EmailOtpCodeStruct = pattern(string(), /^\d{6}$/u); +const EmailOtpCodeStruct = sensitive(pattern(string(), /^\d{6}$/u)); const EnrollmentProofStruct = union([ object({ From 32dce2351510510f0dd32520860315b959e46d4a Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 18 Sep 2026 09:23:13 +0200 Subject: [PATCH 7/8] fix: rename type and return error message fallback --- .../sdk/authentication-jwt-bearer/flow-srp.ts | 4 +-- .../authentication-jwt-bearer/mfa/schemas.ts | 7 +---- .../mfa/services.test.ts | 16 +++++++++++ .../authentication-jwt-bearer/mfa/services.ts | 27 ++++++++++++++++--- .../src/sdk/authentication.ts | 4 +-- .../profile-sync-controller/src/sdk/errors.ts | 9 +------ .../profile-sync-controller/src/sdk/index.ts | 2 +- .../src/sdk/utils/as-record.test.ts | 15 +++++++++++ .../src/sdk/utils/as-record.ts | 13 +++++++++ 9 files changed, 74 insertions(+), 23 deletions(-) create mode 100644 packages/profile-sync-controller/src/sdk/utils/as-record.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/utils/as-record.ts 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 231f742708f..22e7a85fa1e 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 @@ -17,7 +17,7 @@ import { mfaVerify, mfaVerifyComplete, } from './mfa/services.js'; -import type { MfaAssertion } from './mfa/services.js'; +import type { MfaStepUpAssertion } from './mfa/services.js'; import type { EnrolledCredential, EnrollmentChallenge, @@ -346,7 +346,7 @@ export class SRPJwtBearerAuth implements IBaseAuth { flowId: string, proof: StepUpProof, entropySourceId?: string, - ): Promise { + ): Promise { const accessToken = await this.getAccessToken(entropySourceId); return await mfaVerifyComplete(this.#config.env, accessToken, { credential_type: type, 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 1bf35ce9e4f..e57a0fb02d1 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 @@ -21,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; @@ -303,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 index 17cf133550d..90bb857475d 100644 --- 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 @@ -454,6 +454,22 @@ describe('MFA services', () => { ).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, 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 index 8735ebae1da..fcd57a38093 100644 --- 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 @@ -17,6 +17,7 @@ import { OtpResendCooldownError, TooManyAttemptsError, } from '../../errors.js'; +import { asRecord } from '../../utils/as-record.js'; import { AuthenticationResponseJSONStruct, MfaCredentialsResponseStruct, @@ -73,7 +74,7 @@ type VerificationServiceResult = { publicKey?: PasskeyRequestOptions; }; -export type MfaAssertion = { +export type MfaStepUpAssertion = { /** AAL2 assertion JWT to exchange at Hydra for an elevated access token. */ token: string; /** Assertion lifetime in seconds. */ @@ -190,15 +191,33 @@ function parseRetryAfter(response: Response): number | undefined { 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 { - const body: unknown = await response.json(); assertValidMfaResponse(body, MfaErrorResponseStruct); return body; } catch { - return undefined; + const message = asRecord(body)?.message; + return typeof message === 'string' ? { message } : undefined; } } @@ -406,7 +425,7 @@ export async function mfaVerifyComplete( env: Env, accessToken: string, params: VerificationCompletionParams, -): Promise { +): Promise { let body: MfaVerifyCompleteRequest = { credential_type: params.credential_type, flow_id: params.flow_id, diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 19545cef5a5..ceced9425cc 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -4,7 +4,7 @@ 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 { MfaAssertion } from './authentication-jwt-bearer/mfa/services.js'; +import type { MfaStepUpAssertion } from './authentication-jwt-bearer/mfa/services.js'; import type { EnrolledCredential, EnrollmentChallenge, @@ -143,7 +143,7 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { flowId: string, proof: StepUpProof, entropySourceId?: string, - ): Promise { + ): Promise { this.#assertSRP(this.#type, this.#sdk); return await this.#sdk.completeMfaVerification( type, 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 6f33d2b8255..78772c79a63 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -21,7 +21,7 @@ export type { RegistrationResponseJSON, AuthenticationResponseJSON, } from './authentication-jwt-bearer/mfa/types.js'; -export type { MfaAssertion } from './authentication-jwt-bearer/mfa/services.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/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; +} From 56b3671d3dc866eb7f624cdd2f055f42a938aafe Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 18 Sep 2026 12:13:58 +0200 Subject: [PATCH 8/8] fix: address PR feedbacks --- .../flow-srp.test.ts | 8 ++++---- .../sdk/authentication-jwt-bearer/flow-srp.ts | 8 ++------ .../authentication-jwt-bearer/mfa/schemas.ts | 18 +++++++++--------- .../mfa/services.test.ts | 19 +++++++++++++++++++ .../authentication-jwt-bearer/mfa/services.ts | 16 +++++++++------- .../src/sdk/authentication.test.ts | 4 ++-- .../src/sdk/authentication.ts | 5 +---- .../src/sdk/utils/to-error-message.test.ts | 16 ++++++++++++++++ .../src/sdk/utils/to-error-message.ts | 9 +++++++++ 9 files changed, 71 insertions(+), 32 deletions(-) create mode 100644 packages/profile-sync-controller/src/sdk/utils/to-error-message.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/utils/to-error-message.ts 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 39f2126e176..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 @@ -432,11 +432,11 @@ describe('SRP MFA methods', () => { }, } as const; - await auth.completeMfaEnrollment('passkey', 'flow-id', { + await auth.completeMfaEnrollment('flow-id', { type: 'passkey', attestation, }); - await auth.completeMfaEnrollment('email_otp', 'flow-id', { + await auth.completeMfaEnrollment('flow-id', { type: 'email_otp', code: '123456', }); @@ -508,7 +508,7 @@ describe('SRP MFA methods', () => { ]); expect( - await auth.completeMfaVerification('email_otp', 'flow-id', { + await auth.completeMfaVerification('flow-id', { type: 'email_otp', code: '123456', }), @@ -537,7 +537,7 @@ describe('SRP MFA methods', () => { obtainedAt: 1000, }); - await auth.completeMfaVerification('passkey', 'flow-id', { + await auth.completeMfaVerification('flow-id', { type: 'passkey', assertion, }); 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 22e7a85fa1e..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 @@ -274,20 +274,18 @@ export class SRPJwtBearerAuth implements IBaseAuth { /** * Completes enrollment of an MFA credential. * - * @param type - Credential type being enrolled. * @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( - type: MfaCredentialType, flowId: string, proof: EnrollmentProof, entropySourceId?: string, ): Promise { const accessToken = await this.getAccessToken(entropySourceId); await mfaEnrollComplete(this.#config.env, accessToken, { - credential_type: type, + credential_type: proof.type, flow_id: flowId, ...(proof.type === 'passkey' ? { passkey_attestation: proof.attestation } @@ -335,21 +333,19 @@ export class SRPJwtBearerAuth implements IBaseAuth { /** * Completes step-up verification with an enrolled credential. * - * @param type - Credential type being verified. * @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( - type: MfaCredentialType, flowId: string, proof: StepUpProof, entropySourceId?: string, ): Promise { const accessToken = await this.getAccessToken(entropySourceId); return await mfaVerifyComplete(this.#config.env, accessToken, { - credential_type: type, + credential_type: proof.type, flow_id: flowId, ...(proof.type === 'passkey' ? { passkey_assertion: proof.assertion } 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 e57a0fb02d1..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 @@ -41,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'), @@ -67,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()), @@ -132,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({ @@ -144,11 +144,11 @@ 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()), }); @@ -247,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)]), 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 index 90bb857475d..6b152e9aaf6 100644 --- 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 @@ -376,6 +376,25 @@ describe('MFA services', () => { ).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( 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 index fcd57a38093..d8f63a05797 100644 --- 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 @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/utils'; import log from 'loglevel'; import type { Env } from '../../../shared/env.js'; @@ -18,6 +19,7 @@ import { TooManyAttemptsError, } from '../../errors.js'; import { asRecord } from '../../utils/as-record.js'; +import { toErrorMessage } from '../../utils/to-error-message.js'; import { AuthenticationResponseJSONStruct, MfaCredentialsResponseStruct, @@ -146,7 +148,7 @@ export function parsePasskeyCreateData(value: string): PasskeyCreationOptions { if (error instanceof MfaError) { throw error; } - const message = error instanceof Error ? error.message : String(error); + const message = toErrorMessage(error); throw new MfaError('invalid_response', message); } } @@ -166,7 +168,7 @@ export function parsePasskeyRequestData(value: string): PasskeyRequestOptions { if (error instanceof MfaError) { throw error; } - const message = error instanceof Error ? error.message : String(error); + const message = toErrorMessage(error); throw new MfaError('invalid_response', message); } } @@ -184,7 +186,7 @@ function parseRetryAfter(response: Response): number | undefined { return undefined; } const seconds = Number(header); - if (!Number.isNaN(seconds)) { + if (Number.isInteger(seconds)) { return Math.max(0, seconds * 1000); } const date = Date.parse(header); @@ -289,8 +291,8 @@ async function throwMfaError( async function requestJson( url: string, accessToken: string, - init?: { method?: 'GET' | 'POST'; body?: unknown }, -): Promise { + init?: { method?: 'GET' | 'POST'; body?: Json }, +): Promise { let response: Response; try { response = await fetch(url, { @@ -304,7 +306,7 @@ async function requestJson( ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }), }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = toErrorMessage(error); throw new MfaUnavailableError(`MFA request failed: ${message}`); } @@ -315,7 +317,7 @@ async function requestJson( try { return await response.json(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = toErrorMessage(error); throw new MfaError('invalid_response', message, { status: response.status, }); diff --git a/packages/profile-sync-controller/src/sdk/authentication.test.ts b/packages/profile-sync-controller/src/sdk/authentication.test.ts index 5053028ed64..2da8e022ad0 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.test.ts @@ -762,7 +762,7 @@ describe('MFA authentication facade', () => { flowId: 'enroll-passkey-flow-id', }); expect( - await auth.completeMfaEnrollment('passkey', 'flow-id', { + await auth.completeMfaEnrollment('flow-id', { type: 'passkey', attestation: registration, }), @@ -772,7 +772,7 @@ describe('MFA authentication facade', () => { flowId: 'verify-passkey-flow-id', }); expect( - await auth.completeMfaVerification('passkey', 'flow-id', { + await auth.completeMfaVerification('flow-id', { type: 'passkey', assertion, }), diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index ceced9425cc..3dae0faa2b2 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -121,13 +121,12 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { } async completeMfaEnrollment( - type: MfaCredentialType, flowId: string, proof: EnrollmentProof, entropySourceId?: string, ): Promise { this.#assertSRP(this.#type, this.#sdk); - await this.#sdk.completeMfaEnrollment(type, flowId, proof, entropySourceId); + await this.#sdk.completeMfaEnrollment(flowId, proof, entropySourceId); } async beginMfaVerification( @@ -139,14 +138,12 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { } async completeMfaVerification( - type: MfaCredentialType, flowId: string, proof: StepUpProof, entropySourceId?: string, ): Promise { this.#assertSRP(this.#type, this.#sdk); return await this.#sdk.completeMfaVerification( - type, flowId, proof, entropySourceId, 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); +}