From d80c4d5443d3f474fb2c2cd63c879671c434fc59 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 22 Sep 2026 22:08:06 +0200 Subject: [PATCH 1/2] feat: support aal2 gated enrollment --- packages/profile-sync-controller/CHANGELOG.md | 7 ++ packages/profile-sync-controller/README.md | 6 +- .../AuthenticationController.test.ts | 71 +++++++++++++++++++ .../AuthenticationController.ts | 4 ++ .../flow-srp.test.ts | 16 +++++ .../sdk/authentication-jwt-bearer/flow-srp.ts | 11 ++- .../authentication-jwt-bearer/mfa/schemas.ts | 1 + .../mfa/services.test.ts | 36 ++++++++++ .../authentication-jwt-bearer/mfa/services.ts | 38 +++++++++- .../authentication-jwt-bearer/mfa/types.ts | 3 + .../src/sdk/authentication.ts | 6 +- .../src/sdk/constants.ts | 1 + .../profile-sync-controller/src/sdk/errors.ts | 24 ++++++- 13 files changed, 216 insertions(+), 8 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index a1f8ca24c50..3930c1b7eca 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support AAL2-gated MFA enrollment and sync the MFA SDK with the latest authentication API spec ([#0000](https://github.com/MetaMask/core/pull/0000)) + - `beginMfaEnrollment` accepts an `accessToken` option so enrollment can begin with an elevated token + - `AuthenticationController.beginCredentialEnrollment` sends the elevated token while a step-up session is live, since enrolling additional credentials requires AAL2 + - Add the `email_socially_verified`, `multi_primary_srp` and `aal2_required` MFA error codes, a `StepUpRequiredError` class, and support for the `retry_after_seconds` error field when computing `retryAfterMs` + ## [32.2.0] ### Added diff --git a/packages/profile-sync-controller/README.md b/packages/profile-sync-controller/README.md index 86320a78183..ca2660b3a9e 100644 --- a/packages/profile-sync-controller/README.md +++ b/packages/profile-sync-controller/README.md @@ -49,7 +49,11 @@ email OTP enrollment and step-up verification: - `refreshEnrolledCredentials()` refreshes the in-memory credential list. - `beginCredentialEnrollment()` and `completeCredentialEnrollment()` surround - a client-owned passkey ceremony or email-code screen. + a client-owned passkey ceremony or email-code screen. Beginning enrollment + of a second or later credential requires an elevated session: + `beginCredentialEnrollment()` sends the elevated token while one is live, + and the server otherwise rejects it with `aal2_required`, so clients should + step up and retry. - `beginStepUp()` and `completeStepUp()` verify an enrolled credential and return an elevated profile token. - `getElevatedProfileToken()` reuses a live elevated session when it satisfies diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts index 07566f8c658..6c7117a57f1 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -3321,6 +3321,77 @@ describe('MFA step-up verification', () => { expect(controller.state.stepUpSessionExpiresAt).toBeUndefined(); }); + it('sends the elevated token when beginning enrollment while a session is live', async () => { + mockSuccessfulCompletion(); + const { controller } = createController(); + await completeStepUp(controller); + const elevated = controller.getElevatedProfileToken()?.accessToken; + expect(elevated).toBeDefined(); + + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new globalThis.Response(JSON.stringify(MOCK_MFA_ENROLL_EMAIL_RESPONSE), { + status: 200, + }), + ); + try { + await controller.beginCredentialEnrollment({ + type: 'email_otp', + email: 'user@example.com', + reason: { operation: 'settings.addEmail' }, + }); + + expect( + new globalThis.Headers(fetchSpy.mock.calls[0][1]?.headers).get( + 'Authorization', + ), + ).toBe(`Bearer ${elevated}`); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('sends the base token on enrollment calls without a live session', async () => { + const { controller } = createController(); + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new globalThis.Response(JSON.stringify(MOCK_MFA_ENROLL_EMAIL_RESPONSE), { + status: 200, + }), + ); + try { + await controller.beginCredentialEnrollment({ + type: 'email_otp', + email: 'user@example.com', + reason: { operation: 'settings.addEmail' }, + }); + + const base = + controller.state.srpSessionData?.[MOCK_ENTROPY_SOURCE_IDS[0]].token + .accessToken; + expect( + new globalThis.Headers(fetchSpy.mock.calls[0][1]?.headers).get( + 'Authorization', + ), + ).toBe(`Bearer ${base}`); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('surfaces aal2_required from enrollment', async () => { + mockEndpointMfaEnroll({ + status: 403, + body: { code: 'aal2_required', message: 'Elevated session required' }, + }); + const { controller } = createController(); + + await expect( + controller.beginCredentialEnrollment({ + type: 'passkey', + reason: { operation: 'settings.addPasskey' }, + }), + ).rejects.toMatchObject({ mfaCode: 'aal2_required', status: 403 }); + }); + it('clears an elevated session after successful enrollment', async () => { mockSuccessfulCompletion(); mockEndpointMfaEnrollComplete(); diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 8cda4a76f24..9b66b0b60f0 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -946,6 +946,9 @@ export class AuthenticationController extends BaseController< const sessionEpoch = this.#authSessionEpoch; assertValidMfaRequest(request, BeginEnrollmentRequestStruct); const primaryEntropySourceId = this.#getPrimaryEntropySourceId(); + // Enrolling beyond the first credential requires AAL2: send the elevated + // token while a session is live and let the server decide. + const accessToken = this.getElevatedProfileToken()?.accessToken; const challenge = await this.#runMfaRequest( 'MFA Enroll Begin', request.reason.operation, @@ -954,6 +957,7 @@ export class AuthenticationController extends BaseController< await this.#auth.beginMfaEnrollment(request.type, { email: request.email, entropySourceId: primaryEntropySourceId, + accessToken, }), ); this.#assertAuthSessionEpoch(sessionEpoch, 'beginCredentialEnrollment'); 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 d8b4e43cb34..3485b3dd9fd 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 @@ -419,6 +419,22 @@ describe('SRP MFA methods', () => { }); }); + it('uses a caller-supplied access token when beginning enrollment', async () => { + const auth = createAuth(); + mockMfaEnroll.mockResolvedValueOnce({ flowId: 'flow-id', expiresAt: 1000 }); + + await auth.beginMfaEnrollment('email_otp', { + email: 'user@example.com', + accessToken: 'elevated-token', + }); + + expect(mockMfaEnroll).toHaveBeenLastCalledWith( + Env.DEV, + 'elevated-token', + expect.anything(), + ); + }); + it('completes both enrollment proof types', async () => { const auth = createAuth(); mockMfaEnrollComplete.mockResolvedValue(undefined); 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 3fbc0ffed5b..5ab7eb5e185 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 @@ -237,14 +237,21 @@ export class SRPJwtBearerAuth implements IBaseAuth { * @param options.email - Email address, required for email OTP. * @param options.entropySourceId - Entropy source whose profile owns the * credential. + * @param options.accessToken - Bearer to use instead of the base session + * token; an elevated token is required once a credential is already enrolled. * @returns Enrollment challenge for the client ceremony. */ async beginMfaEnrollment( type: MfaCredentialType, - options?: { email?: string; entropySourceId?: string }, + options?: { + email?: string; + entropySourceId?: string; + accessToken?: string; + }, ): Promise { const { email, entropySourceId } = options ?? {}; - const accessToken = await this.getAccessToken(entropySourceId); + const accessToken = + options?.accessToken ?? (await this.getAccessToken(entropySourceId)); const result = await mfaEnroll(this.#config.env, accessToken, { credential_type: type, ...(email ? { identifier: email } : {}), 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 03dcc652922..e16eaae7a4a 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 @@ -177,6 +177,7 @@ export const MfaCredentialsResponseStruct = type({ export const MfaErrorResponseStruct = type({ code: optional(string()), message: string(), + retry_after_seconds: optional(integer()), }); export const TokenReasonStruct = object({ 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 6b152e9aaf6..4b4591eaca3 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 @@ -10,6 +10,7 @@ import { MfaRateLimitedError, MfaUnavailableError, MfaVerificationFailedError, + StepUpRequiredError, TooManyAttemptsError, } from '../../errors.js'; import { @@ -259,7 +260,9 @@ describe('MFA services', () => { it.each([ ['credential_already_enrolled', CredentialAlreadyEnrolledError, 409], ['email_already_enrolled', CredentialAlreadyEnrolledError, 409], + ['email_socially_verified', CredentialAlreadyEnrolledError, 409], ['credential_not_enrolled', CredentialNotEnrolledError, 409], + ['aal2_required', StepUpRequiredError, 403], ['flow_expired', MfaFlowExpiredError, 400], ['invalid_flow', MfaFlowExpiredError, 400], ['mfa_identity_missing', MfaIdentityMissingError, 409], @@ -323,6 +326,39 @@ describe('MFA services', () => { }); }); + it('prefers the retry_after_seconds field over the Retry-After header', async () => { + mockFetch.mockResolvedValueOnce( + response( + { + code: 'otp_resend_cooldown', + message: 'Wait before retrying', + retry_after_seconds: 42, + }, + { status: 429, headers: { 'Retry-After': '5' } }, + ), + ); + + await expect( + mfaVerify(Env.PRD, 'access-token', { credential_type: 'email_otp' }), + ).rejects.toMatchObject({ + mfaCode: 'otp_resend_cooldown', + retryAfterMs: 42_000, + }); + }); + + it('passes multi_primary_srp through with its status', async () => { + mockFetch.mockResolvedValueOnce( + response( + { code: 'multi_primary_srp', message: 'Enrollment blocked' }, + { status: 403 }, + ), + ); + + await expect( + mfaEnroll(Env.PRD, 'access-token', { credential_type: 'passkey' }), + ).rejects.toMatchObject({ mfaCode: 'multi_primary_srp', status: 403 }); + }); + it('maps code-less 429 and 502 responses by status', 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 d8f63a05797..2dd9399f022 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 @@ -16,6 +16,7 @@ import { MfaUnavailableError, MfaVerificationFailedError, OtpResendCooldownError, + StepUpRequiredError, TooManyAttemptsError, } from '../../errors.js'; import { asRecord } from '../../utils/as-record.js'; @@ -204,9 +205,33 @@ function parseRetryAfter(response: Response): number | undefined { * @returns The validated error body, a best-effort salvage of it, or * undefined when the body could not be parsed as JSON at all. */ +type MfaErrorBody = { + code?: string; + message: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + retry_after_seconds?: number; +}; + +/** + * Prefers the documented `retry_after_seconds` error field over the + * `Retry-After` header. + * + * @param body - The parsed error body, if any. + * @param response - The throttled response. + * @returns The delay in milliseconds, or undefined when neither is usable. + */ +function resolveRetryAfterMs( + body: MfaErrorBody | undefined, + response: Response, +): number | undefined { + return body?.retry_after_seconds === undefined + ? parseRetryAfter(response) + : body.retry_after_seconds * 1000; +} + async function readErrorBody( response: Response, -): Promise<{ code?: string; message: string } | undefined> { +): Promise { let body: unknown; try { body = await response.json(); @@ -242,9 +267,12 @@ async function throwMfaError( switch (code) { case 'credential_already_enrolled': case 'email_already_enrolled': + case 'email_socially_verified': throw new CredentialAlreadyEnrolledError(code, message, status); case 'credential_not_enrolled': throw new CredentialNotEnrolledError(message, status); + case 'aal2_required': + throw new StepUpRequiredError(message, status); case 'flow_expired': case 'invalid_flow': throw new MfaFlowExpiredError(code, message, status); @@ -263,7 +291,7 @@ async function throwMfaError( case 'otp_resend_cooldown': throw new OtpResendCooldownError( message, - parseRetryAfter(response), + resolveRetryAfterMs(body, response), status, ); case 'kratos_unavailable': @@ -273,7 +301,11 @@ async function throwMfaError( } if (status === HTTP_STATUS_CODES.TOO_MANY_REQUESTS) { - throw new MfaRateLimitedError(message, parseRetryAfter(response), status); + throw new MfaRateLimitedError( + message, + resolveRetryAfterMs(body, response), + status, + ); } if (status === HTTP_STATUS_CODES.BAD_GATEWAY) { throw new MfaUnavailableError(message, status); diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/types.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/types.ts index 1d6d622b08a..42d52511a77 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/types.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/types.ts @@ -205,7 +205,10 @@ export type MfaErrorCode = | 'email_already_enrolled' | 'credential_already_enrolled' | 'credential_not_enrolled' + | 'email_socially_verified' | 'mfa_identity_missing' + | 'multi_primary_srp' + | 'aal2_required' | 'otp_resend_cooldown' | 'rate_limited' | 'kratos_unavailable' diff --git a/packages/profile-sync-controller/src/sdk/authentication.ts b/packages/profile-sync-controller/src/sdk/authentication.ts index 3dae0faa2b2..6f6c2fbb315 100644 --- a/packages/profile-sync-controller/src/sdk/authentication.ts +++ b/packages/profile-sync-controller/src/sdk/authentication.ts @@ -114,7 +114,11 @@ export class JwtBearerAuth implements SIWEInterface, SRPInterface { async beginMfaEnrollment( type: MfaCredentialType, - options?: { email?: string; entropySourceId?: string }, + options?: { + email?: string; + entropySourceId?: string; + accessToken?: string; + }, ): Promise { this.#assertSRP(this.#type, this.#sdk); return await this.#sdk.beginMfaEnrollment(type, options); diff --git a/packages/profile-sync-controller/src/sdk/constants.ts b/packages/profile-sync-controller/src/sdk/constants.ts index 22215e0d01e..1e818af1326 100644 --- a/packages/profile-sync-controller/src/sdk/constants.ts +++ b/packages/profile-sync-controller/src/sdk/constants.ts @@ -1,5 +1,6 @@ export const HTTP_STATUS_CODES = { UNAUTHORIZED: 401, + FORBIDDEN: 403, CONFLICT: 409, UNPROCESSABLE_ENTITY: 422, TOO_MANY_REQUESTS: 429, diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index d22604fa268..ee525606b82 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -30,9 +30,19 @@ export class MfaError extends Error { } } +/** + * The credential cannot be enrolled because an equivalent one already exists: + * `email_already_enrolled` (this profile already has a verified email), + * `email_socially_verified` (the email is verified through a social login), or + * `credential_already_enrolled` (the email or passkey is enrolled on another + * profile; the profiles must be paired or the credential removed). + */ export class CredentialAlreadyEnrolledError extends MfaError { constructor( - code: 'credential_already_enrolled' | 'email_already_enrolled', + code: + | 'credential_already_enrolled' + | 'email_already_enrolled' + | 'email_socially_verified', message: string, status = HTTP_STATUS_CODES.CONFLICT, ) { @@ -41,6 +51,18 @@ export class CredentialAlreadyEnrolledError extends MfaError { } } +/** + * The profile already has MFA credentials, so enrolling another requires an + * `aal:2` access token; the client must complete a step-up and retry with the + * same flow. + */ +export class StepUpRequiredError extends MfaError { + constructor(message: string, status = HTTP_STATUS_CODES.FORBIDDEN) { + super('aal2_required', message, { status }); + this.name = 'StepUpRequiredError'; + } +} + export class CredentialNotEnrolledError extends MfaError { constructor(message: string, status = HTTP_STATUS_CODES.CONFLICT) { super('credential_not_enrolled', message, { status }); From aff0fb24ae04144c2808af3ff958fb5f587810c1 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 22 Sep 2026 22:10:37 +0200 Subject: [PATCH 2/2] fix: update CHANGELOG --- packages/profile-sync-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 3930c1b7eca..0f1405b674e 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Support AAL2-gated MFA enrollment and sync the MFA SDK with the latest authentication API spec ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Support AAL2-gated MFA enrollment and sync the MFA SDK with the latest authentication API spec ([#10374](https://github.com/MetaMask/core/pull/10374)) - `beginMfaEnrollment` accepts an `accessToken` option so enrollment can begin with an elevated token - `AuthenticationController.beginCredentialEnrollment` sends the elevated token while a step-up session is live, since enrolling additional credentials requires AAL2 - Add the `email_socially_verified`, `multi_primary_srp` and `aal2_required` MFA error codes, a `StepUpRequiredError` class, and support for the `retry_after_seconds` error field when computing `retryAfterMs`