From b0d2e38519c34a816b35cf1641f2f9c019b7ef4c Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 16 Sep 2026 13:40:12 +0200 Subject: [PATCH 1/4] feat(profile-sync): add MFA validation foundation Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 1 + packages/profile-sync-controller/package.json | 2 + .../mfa/schemas.test.ts | 224 +++++++++++ .../authentication-jwt-bearer/mfa/schemas.ts | 358 ++++++++++++++++++ .../authentication-jwt-bearer/mfa/types.ts | 220 +++++++++++ .../src/sdk/constants.ts | 3 + .../src/sdk/errors.test.ts | 99 +++++ .../profile-sync-controller/src/sdk/errors.ts | 191 ++++++++++ .../src/sdk/utils/jwt.test.ts | 33 ++ .../src/sdk/utils/jwt.ts | 21 + .../src/sdk/utils/validate-login-response.ts | 14 +- yarn.lock | 2 + 12 files changed, 1161 insertions(+), 7 deletions(-) create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.ts create mode 100644 packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/types.ts create mode 100644 packages/profile-sync-controller/src/sdk/errors.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/utils/jwt.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/utils/jwt.ts diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index f5905136069..38ca8983dc7 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 validated MFA domain types, structured errors, and JWT claim decoding utilities ([#10264](https://github.com/MetaMask/core/pull/10264)) - Add `rampsOrders` to `USER_STORAGE_FEATURE_NAMES` ([#10227](https://github.com/MetaMask/core/pull/10227)) ## [32.1.1] diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index df5f107ffab..a3bd0cdc198 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -79,6 +79,7 @@ "@metamask/keyring-controller": "^28.0.0", "@metamask/messenger": "^3.0.0", "@metamask/seedless-onboarding-controller": "^11.0.0", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.2", @@ -95,6 +96,7 @@ "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-internal-api": "^12.0.0", "@metamask/providers": "^22.1.0", + "@metamask/rpc-errors": "^7.0.2", "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", "deepmerge": "^4.2.2", diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts new file mode 100644 index 00000000000..757920b7e14 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts @@ -0,0 +1,224 @@ +import { + AuthenticationResponseJSONStruct, + BeginEnrollmentRequestStruct, + CompleteEnrollmentRequestStruct, + MfaCredentialsResponseStruct, + MfaEnrollResponseStruct, + PasskeyCreateDataStruct, + PasskeyRequestDataStruct, + RegistrationResponseJSONStruct, + assertValidMfaRequest, + assertValidMfaResponse, + parseElevatedTokenClaims, +} from './schemas.js'; +import { MFA_CREDENTIAL_TYPES } from './types.js'; + +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; + +describe('MFA schemas', () => { + it('exposes the supported credential types', () => { + expect(MFA_CREDENTIAL_TYPES).toStrictEqual(['passkey', 'email_otp']); + }); + + it('accepts service responses and ignores additional response fields', () => { + const enrollment = { + flow_id: 'flow-id', + expires_at: '2026-09-07T14:30:00Z', + future_field: true, + }; + const credentials = { + credentials: [ + { + credential_type: 'passkey', + status: 'active', + passkey: { + display_name: 'MetaMask 3f9a1c2b', + added_at: '2024-01-15T10:30:00Z', + }, + future_field: true, + }, + { + credential_type: 'email_otp', + status: 'pending', + email: { address: 'user@example.com', verified: false }, + }, + ], + future_field: true, + }; + + expect(() => + assertValidMfaResponse(enrollment, MfaEnrollResponseStruct), + ).not.toThrow(); + expect(() => + assertValidMfaResponse(credentials, MfaCredentialsResponseStruct), + ).not.toThrow(); + }); + + it('rejects malformed responses with path details', () => { + expect(() => + assertValidMfaResponse({ flow_id: 'flow-id' }, MfaEnrollResponseStruct), + ).toThrow(/MFA\[invalid_response\].*\[expires_at\]/u); + }); + + it('accepts partial credential details from legacy and pending rows', () => { + expect(() => + assertValidMfaResponse( + { + credentials: [ + { + credential_type: 'email_otp', + status: 'pending', + email: {}, + }, + ], + }, + MfaCredentialsResponseStruct, + ), + ).not.toThrow(); + }); + + it('validates passkey creation and request options', () => { + expect(() => + assertValidMfaResponse( + { + publicKey: { + rp: { id: 'authentication.api.cx.metamask.io', name: 'MetaMask' }, + user: { + id: 'user-id', + name: 'MetaMask 3f9a1c2b', + displayName: 'MetaMask 3f9a1c2b', + }, + challenge: 'challenge', + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: [], + attestation: 'none', + }, + }, + PasskeyCreateDataStruct, + ), + ).not.toThrow(); + + expect(() => + assertValidMfaResponse( + { + publicKey: { + rpId: 'authentication.api.cx.metamask.io', + challenge: 'challenge', + allowCredentials: [{ type: 'public-key', id: 'credential-id' }], + }, + }, + PasskeyRequestDataStruct, + ), + ).not.toThrow(); + }); + + it('validates platform ceremony results', () => { + expect(() => + assertValidMfaRequest(registration, RegistrationResponseJSONStruct), + ).not.toThrow(); + expect(() => + assertValidMfaRequest(assertion, AuthenticationResponseJSONStruct), + ).not.toThrow(); + }); + + it('requires a valid operation and an email for email enrollment', () => { + expect(() => + assertValidMfaRequest( + { + type: 'email_otp', + reason: { operation: 'settings.addEmail' }, + }, + BeginEnrollmentRequestStruct, + ), + ).toThrow(/email is required/u); + + expect(() => + assertValidMfaRequest( + { + type: 'passkey', + reason: { operation: 'contains whitespace' }, + }, + BeginEnrollmentRequestStruct, + ), + ).toThrow(/MFA\[invalid_request\].*\[reason.operation\]/u); + }); + + it('rejects unknown request fields and mismatched proofs', () => { + expect(() => + assertValidMfaRequest( + { + type: 'passkey', + flowId: 'flow-id', + proof: { type: 'email_otp', code: '123456' }, + }, + CompleteEnrollmentRequestStruct, + ), + ).toThrow(/type must match/u); + + expect(() => + assertValidMfaRequest( + { + type: 'passkey', + reason: { operation: 'settings.addPasskey' }, + extra: true, + }, + BeginEnrollmentRequestStruct, + ), + ).toThrow(/extra/u); + }); + + it('normalizes elevated-token authentication methods', () => { + expect( + parseElevatedTokenClaims({ + sub: 'profile-id', + aal: 2, + exp: 2_000_000_000, + amr: 'passkey', + }), + ).toStrictEqual({ + sub: 'profile-id', + aal: 2, + exp: 2_000_000_000, + amr: ['passkey'], + }); + + expect( + parseElevatedTokenClaims({ + sub: 'profile-id', + aal: 2, + exp: 2_000_000_000, + amr: ['email_otp'], + }).amr, + ).toStrictEqual(['email_otp']); + }); + + it('rejects tokens that are not AAL2', () => { + expect(() => + parseElevatedTokenClaims({ + sub: 'profile-id', + aal: 1, + exp: 2_000_000_000, + amr: 'passkey', + }), + ).toThrow(/MFA\[elevated_token_invalid\]/u); + }); +}); 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 new file mode 100644 index 00000000000..b11052df53c --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.ts @@ -0,0 +1,358 @@ +import { + array, + assert, + boolean, + enums, + integer, + literal, + min, + nullable, + object, + optional, + pattern, + refine, + size, + string, + StructError, + type, + union, +} from '@metamask/superstruct'; +import type { Struct } from '@metamask/superstruct'; + +import { ElevatedTokenInvalidError, MfaError } from '../../errors.js'; + +export const MFA_CREDENTIAL_TYPES = ['passkey', 'email_otp'] as const; + +export const MfaCredentialTypeStruct = enums(MFA_CREDENTIAL_TYPES); + +const CredentialDescriptorStruct = type({ + type: literal('public-key'), + id: string(), + transports: optional(array(string())), +}); + +const ExtensionsStruct = type({}); + +export const PublicKeyCredentialCreationOptionsJSONStruct = type({ + rp: type({ + id: string(), + name: string(), + }), + user: type({ + id: string(), + name: string(), + displayName: string(), + }), + challenge: string(), + pubKeyCredParams: array( + type({ + type: literal('public-key'), + alg: integer(), + }), + ), + excludeCredentials: optional(array(CredentialDescriptorStruct)), + authenticatorSelection: optional( + type({ + authenticatorAttachment: optional(string()), + residentKey: optional(string()), + requireResidentKey: optional(boolean()), + userVerification: optional(string()), + }), + ), + attestation: optional(string()), + timeout: optional(integer()), + extensions: optional(ExtensionsStruct), +}); + +export const PublicKeyCredentialRequestOptionsJSONStruct = type({ + challenge: string(), + rpId: optional(string()), + allowCredentials: optional(array(CredentialDescriptorStruct)), + userVerification: optional(string()), + timeout: optional(integer()), + extensions: optional(ExtensionsStruct), +}); + +export const PasskeyCreateDataStruct = type({ + publicKey: PublicKeyCredentialCreationOptionsJSONStruct, +}); + +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()), + }), + 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())), + }), + authenticatorAttachment: optional(nullable(string())), + clientExtensionResults: optional(ExtensionsStruct), +}); + +const ProfileStruct = type({ + profile_id: string(), + + identifier_id: string(), + + identifier_type: string(), + + metametrics_id: optional(string()), +}); + +const ProfileAliasStruct = type({ + alias_profile_id: string(), + + canonical_profile_id: string(), + + identifier_ids: optional( + array( + type({ + id: string(), + type: string(), + }), + ), + ), +}); + +export const AuthenticationResponseStruct = type({ + token: string(), + + expires_in: integer(), + profile: ProfileStruct, + + profile_aliases: optional(array(ProfileAliasStruct)), +}); + +export const MfaEnrollResponseStruct = type({ + flow_id: string(), + + expires_at: string(), + + passkey_create_data: optional(string()), +}); + +export const MfaEnrollCompleteResponseStruct = type({ + status: literal('enrolled'), +}); + +export const MfaVerifyResponseStruct = type({ + flow_id: string(), + + expires_at: string(), + + passkey_request_data: optional(string()), +}); + +export const MfaPasskeyDetailStruct = type({ + display_name: optional(string()), + + added_at: optional(string()), +}); + +export const MfaEmailDetailStruct = type({ + address: optional(string()), + verified: optional(boolean()), +}); + +export const MfaCredentialStruct = type({ + // Intentionally accepts strings so future server credential types can be + // ignored without making the complete response invalid. + + credential_type: string(), + status: enums(['active', 'pending']), + + enrolled_at: optional(string()), + passkey: optional(MfaPasskeyDetailStruct), + email: optional(MfaEmailDetailStruct), +}); + +export const MfaCredentialsResponseStruct = type({ + credentials: array(MfaCredentialStruct), +}); + +export const MfaErrorResponseStruct = type({ + code: optional(string()), + message: string(), +}); + +export const TokenReasonStruct = object({ + operation: pattern(string(), /^[A-Za-z0-9_.:-]{1,64}$/u), + description: optional(string()), +}); + +export const BeginEnrollmentRequestStruct = refine( + object({ + type: MfaCredentialTypeStruct, + email: optional(size(string(), 3, 254)), + reason: TokenReasonStruct, + }), + 'BeginEnrollmentRequest', + (value) => + value.type !== 'email_otp' || value.email !== undefined + ? true + : 'email is required for email_otp enrollment', +); + +const PasskeyEnrollmentProofStruct = object({ + type: literal('passkey'), + attestation: RegistrationResponseJSONStruct, +}); + +const EmailOtpProofStruct = object({ + type: literal('email_otp'), + code: pattern(string(), /^\d{6}$/u), +}); + +export const CompleteEnrollmentRequestStruct = refine( + object({ + type: MfaCredentialTypeStruct, + flowId: string(), + proof: union([PasskeyEnrollmentProofStruct, EmailOtpProofStruct]), + }), + 'CompleteEnrollmentRequest', + (value) => + value.type === value.proof.type ? true : 'type must match the proof type', +); + +export const BeginStepUpRequestStruct = object({ + credentialType: MfaCredentialTypeStruct, + reason: TokenReasonStruct, +}); + +const PasskeyStepUpProofStruct = object({ + type: literal('passkey'), + assertion: AuthenticationResponseJSONStruct, +}); + +const EmailOtpStepUpProofStruct = object({ + type: literal('email_otp'), + code: pattern(string(), /^\d{6}$/u), +}); + +export const CompleteStepUpRequestStruct = refine( + object({ + credentialType: MfaCredentialTypeStruct, + flowId: string(), + proof: union([PasskeyStepUpProofStruct, EmailOtpStepUpProofStruct]), + }), + 'CompleteStepUpRequest', + (value) => + value.credentialType === value.proof.type + ? true + : 'credentialType must match the proof type', +); + +export const GetElevatedTokenRequestStruct = object({ + maxSessionAgeMs: optional(min(integer(), 0)), +}); + +export const ElevatedTokenClaimsStruct = type({ + sub: string(), + aal: literal(2), + exp: integer(), + amr: union([MfaCredentialTypeStruct, array(MfaCredentialTypeStruct)]), +}); + +function formatStructError(error: StructError): string { + return error + .failures() + .map(({ path, message }) => `[${path.join('.')}] ${message}`) + .join(', '); +} + +/** + * Validates data received from the authentication service. + * + * @param value - Untrusted response data. + * @param struct - Expected response structure. + * @throws MfaError if the response does not match the structure. + */ +export function assertValidMfaResponse( + value: unknown, + struct: Struct, +): asserts value is Value { + try { + assert(value, struct); + } catch (error) { + if (error instanceof StructError) { + throw new MfaError('invalid_response', formatStructError(error)); + } + /* istanbul ignore next */ + throw error; + } +} + +/** + * Validates data received from an untyped controller boundary. + * + * @param value - Untrusted request data. + * @param struct - Expected request structure. + * @throws MfaError if the request does not match the structure. + */ +export function assertValidMfaRequest( + value: unknown, + struct: Struct, +): asserts value is Value { + try { + assert(value, struct); + } catch (error) { + if (error instanceof StructError) { + throw new MfaError('invalid_request', formatStructError(error)); + } + /* istanbul ignore next */ + throw error; + } +} + +/** + * Validates and normalizes claims from an elevated access token. + * + * @param value - Decoded JWT payload. + * @returns Validated claims with `amr` represented as an array. + * @throws ElevatedTokenInvalidError if claims are missing or invalid. + */ +export function parseElevatedTokenClaims(value: unknown): { + sub: string; + aal: 2; + exp: number; + amr: ('passkey' | 'email_otp')[]; +} { + try { + assert(value, ElevatedTokenClaimsStruct); + } catch (error) { + if (error instanceof StructError) { + throw new ElevatedTokenInvalidError(formatStructError(error)); + } + /* istanbul ignore next */ + throw error; + } + + return { + sub: value.sub, + aal: value.aal, + exp: value.exp, + amr: Array.isArray(value.amr) ? value.amr : [value.amr], + }; +} 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 new file mode 100644 index 00000000000..20c321ec7c4 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/types.ts @@ -0,0 +1,220 @@ +import type { Infer } from '@metamask/superstruct'; + +import type { + AuthenticationResponseJSONStruct, + AuthenticationResponseStruct, + MfaCredentialStruct, + MfaCredentialsResponseStruct, + MfaEmailDetailStruct, + MfaEnrollCompleteResponseStruct, + MfaEnrollResponseStruct, + MfaPasskeyDetailStruct, + MfaVerifyResponseStruct, + PublicKeyCredentialCreationOptionsJSONStruct, + PublicKeyCredentialRequestOptionsJSONStruct, + RegistrationResponseJSONStruct, +} from './schemas.js'; +import { MFA_CREDENTIAL_TYPES } from './schemas.js'; + +export { MFA_CREDENTIAL_TYPES }; + +export type MfaCredentialType = (typeof MFA_CREDENTIAL_TYPES)[number]; + +export type MfaCredentialStatus = 'active' | 'pending'; + +export type PublicKeyCredentialCreationOptionsJSON = Infer< + typeof PublicKeyCredentialCreationOptionsJSONStruct +>; + +export type PublicKeyCredentialRequestOptionsJSON = Infer< + typeof PublicKeyCredentialRequestOptionsJSONStruct +>; + +export type RegistrationResponseJSON = Infer< + typeof RegistrationResponseJSONStruct +>; + +export type AuthenticationResponseJSON = Infer< + typeof AuthenticationResponseJSONStruct +>; + +export type MfaEnrollResponse = Infer; + +export type MfaEnrollCompleteResponse = Infer< + typeof MfaEnrollCompleteResponseStruct +>; + +export type MfaVerifyResponse = Infer; + +export type MfaVerifyCompleteResponse = Infer< + typeof AuthenticationResponseStruct +>; + +export type MfaCredentialsResponse = Infer; + +export type MfaCredential = Infer; + +export type MfaPasskeyDetail = Infer; + +export type MfaEmailDetail = Infer; + +export type MfaEnrollRequest = { + // eslint-disable-next-line @typescript-eslint/naming-convention + credential_type: MfaCredentialType; + identifier?: string; +}; + +export type MfaEnrollCompleteRequest = { + // 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?: string; +}; + +export type MfaVerifyRequest = { + // eslint-disable-next-line @typescript-eslint/naming-convention + credential_type: MfaCredentialType; +}; + +export type MfaVerifyCompleteRequest = { + // 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 + passkey_assertion?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + otp_code?: string; +}; + +export type EnrolledCredential = + | { + type: 'passkey'; + status: MfaCredentialStatus; + enrolledAt?: number; + displayName?: string; + } + | { + type: 'email_otp'; + status: MfaCredentialStatus; + enrolledAt?: number; + email: string; + verified: boolean; + }; + +export type TokenReason = { + operation: string; + description?: string; +}; + +export type EnrollmentChallenge = + | { + type: 'passkey'; + flowId: string; + expiresAt: number; + publicKey: PublicKeyCredentialCreationOptionsJSON; + } + | { + type: 'email_otp'; + flowId: string; + expiresAt: number; + emailSent: true; + }; + +export type EnrollmentProof = + | { + type: 'passkey'; + attestation: RegistrationResponseJSON; + } + | { + type: 'email_otp'; + code: string; + }; + +export type StepUpChallenge = + | { + type: 'passkey'; + flowId: string; + expiresAt: number; + publicKey: PublicKeyCredentialRequestOptionsJSON; + } + | { + type: 'email_otp'; + flowId: string; + expiresAt: number; + deliverySent: true; + }; + +export type StepUpProof = + | { + type: 'passkey'; + assertion: AuthenticationResponseJSON; + } + | { + type: 'email_otp'; + code: string; + }; + +export type ElevatedProfileToken = { + accessToken: string; + expiresIn: number; + obtainedAt: number; + claims: { + sub: string; + aal: 2; + amr: MfaCredentialType[]; + exp: number; + }; +}; + +export type BeginEnrollmentRequest = { + type: MfaCredentialType; + email?: string; + reason: TokenReason; +}; + +export type CompleteEnrollmentRequest = { + type: MfaCredentialType; + flowId: string; + proof: EnrollmentProof; +}; + +export type BeginStepUpRequest = { + credentialType: MfaCredentialType; + reason: TokenReason; +}; + +export type CompleteStepUpRequest = { + credentialType: MfaCredentialType; + flowId: string; + proof: StepUpProof; +}; + +export type GetElevatedTokenRequest = { + maxSessionAgeMs?: number; +}; + +export type MfaErrorCode = + | 'max_passkeys_reached' + | 'max_identifiers_reached' + | 'email_already_enrolled' + | 'credential_already_enrolled' + | 'credential_not_enrolled' + | 'mfa_identity_missing' + | 'otp_resend_cooldown' + | 'kratos_unavailable' + | 'flow_expired' + | 'invalid_flow' + | 'invalid_attestation' + | 'invalid_code' + | 'invalid_assertion' + | 'too_many_attempts' + | 'elevated_token_invalid' + | 'authentication_required' + | 'server_error' + | 'invalid_response' + | 'invalid_request'; diff --git a/packages/profile-sync-controller/src/sdk/constants.ts b/packages/profile-sync-controller/src/sdk/constants.ts index 0a956713bba..ef2c13eb24a 100644 --- a/packages/profile-sync-controller/src/sdk/constants.ts +++ b/packages/profile-sync-controller/src/sdk/constants.ts @@ -1,5 +1,8 @@ export const HTTP_STATUS_CODES = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, CONFLICT: 409, UNPROCESSABLE_ENTITY: 422, TOO_MANY_REQUESTS: 429, + BAD_GATEWAY: 502, }; diff --git a/packages/profile-sync-controller/src/sdk/errors.test.ts b/packages/profile-sync-controller/src/sdk/errors.test.ts new file mode 100644 index 00000000000..09920418fee --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/errors.test.ts @@ -0,0 +1,99 @@ +import { JsonRpcError, serializeError } from '@metamask/rpc-errors'; + +import { + CredentialAlreadyEnrolledError, + CredentialNotEnrolledError, + ElevatedTokenInvalidError, + MaxIdentifiersReachedError, + MaxPasskeysReachedError, + MfaError, + MfaFlowExpiredError, + MfaUnavailableError, + MfaVerificationFailedError, + OtpResendCooldownError, + TooManyAttemptsError, + getMfaErrorCode, + getMfaRetryAfterMs, + isMfaError, +} from './errors.js'; + +describe('MFA errors', () => { + it('exposes a stable enumerable code', () => { + const error = new CredentialAlreadyEnrolledError( + 'credential_already_enrolled', + 'Credential already exists', + ); + + expect(error).toBeInstanceOf(MfaError); + expect(error.message).toBe( + 'MFA[credential_already_enrolled]: Credential already exists', + ); + expect(Object.keys(error)).toContain('mfaCode'); + expect(getMfaErrorCode(error)).toBe('credential_already_enrolled'); + expect(isMfaError(error)).toBe(true); + expect(isMfaError(error, 'credential_already_enrolled')).toBe(true); + expect(isMfaError(error, 'invalid_code')).toBe(false); + }); + + it('reads codes and retry delays after JSON-RPC serialization', () => { + const original = new OtpResendCooldownError( + 'Wait before requesting another code', + 30_000, + ); + const serialized = serializeError(original); + const rebuilt = new JsonRpcError( + serialized.code, + serialized.message, + serialized.data, + ); + + expect(getMfaErrorCode(rebuilt)).toBe('otp_resend_cooldown'); + expect(getMfaRetryAfterMs(rebuilt)).toBe(30_000); + }); + + it('falls back to the stable message prefix', () => { + expect( + getMfaErrorCode(new Error('MFA[flow_cancelled]: User cancelled')), + ).toBe('flow_cancelled'); + }); + + it('provides specific errors for every actionable condition', () => { + const errors = [ + new CredentialNotEnrolledError('Not enrolled'), + new MfaFlowExpiredError('flow_expired', 'Flow expired', 400), + new MfaVerificationFailedError('invalid_code', 'Invalid code', 400), + new TooManyAttemptsError('Too many attempts', 400), + new MaxPasskeysReachedError('Maximum passkeys reached'), + new MaxIdentifiersReachedError('Maximum identifiers reached'), + new MfaUnavailableError('Identity provider unavailable'), + new ElevatedTokenInvalidError('Expected AAL2 claims'), + ]; + + expect(errors.map((error) => error.name)).toStrictEqual([ + 'CredentialNotEnrolledError', + 'MfaFlowExpiredError', + 'MfaVerificationFailedError', + 'TooManyAttemptsError', + 'MaxPasskeysReachedError', + 'MaxIdentifiersReachedError', + 'MfaUnavailableError', + 'ElevatedTokenInvalidError', + ]); + }); + + it('reads direct retry delays and a serialized cause message', () => { + const direct = new OtpResendCooldownError('Cooldown', 1_000); + expect(getMfaRetryAfterMs(direct)).toBe(1_000); + expect( + getMfaErrorCode({ + data: { cause: { message: 'MFA[invalid_code]: Incorrect code' } }, + }), + ).toBe('invalid_code'); + }); + + it('returns undefined for unrelated values', () => { + expect(getMfaErrorCode(new Error('unrelated'))).toBeUndefined(); + expect(getMfaRetryAfterMs(null)).toBeUndefined(); + expect(isMfaError('invalid_code')).toBe(false); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index f453e763671..ed68f577f11 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -1,5 +1,196 @@ +import type { MfaErrorCode } from './authentication-jwt-bearer/mfa/types.js'; import { HTTP_STATUS_CODES } from './constants.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. + * + * The machine-readable code is an enumerable own property so it survives + * JSON-RPC error serialization. + */ +export class MfaError extends Error { + readonly mfaCode: ExtensibleMfaErrorCode; + + readonly status?: number; + + readonly retryAfterMs?: number; + + constructor( + mfaCode: ExtensibleMfaErrorCode, + message: string, + options?: { status?: number; retryAfterMs?: number }, + ) { + super(`MFA[${mfaCode}]: ${message}`); + this.name = 'MfaError'; + this.mfaCode = mfaCode; + this.status = options?.status; + this.retryAfterMs = options?.retryAfterMs; + } +} + +export class CredentialAlreadyEnrolledError extends MfaError { + constructor( + code: 'credential_already_enrolled' | 'email_already_enrolled', + message: string, + status = HTTP_STATUS_CODES.CONFLICT, + ) { + super(code, message, { status }); + this.name = 'CredentialAlreadyEnrolledError'; + } +} + +export class CredentialNotEnrolledError extends MfaError { + constructor(message: string, status = HTTP_STATUS_CODES.CONFLICT) { + super('credential_not_enrolled', message, { status }); + this.name = 'CredentialNotEnrolledError'; + } +} + +export class MfaFlowExpiredError extends MfaError { + constructor( + code: 'flow_expired' | 'invalid_flow' | 'mfa_identity_missing', + message: string, + status?: number, + ) { + super(code, message, { status }); + this.name = 'MfaFlowExpiredError'; + } +} + +export class MfaVerificationFailedError extends MfaError { + constructor( + code: 'invalid_code' | 'invalid_attestation' | 'invalid_assertion', + message: string, + status?: number, + ) { + super(code, message, { status }); + this.name = 'MfaVerificationFailedError'; + } +} + +export class TooManyAttemptsError extends MfaError { + constructor(message: string, status?: number) { + super('too_many_attempts', message, { status }); + this.name = 'TooManyAttemptsError'; + } +} + +export class MaxPasskeysReachedError extends MfaError { + constructor( + message: string, + status = HTTP_STATUS_CODES.UNPROCESSABLE_ENTITY, + ) { + super('max_passkeys_reached', message, { status }); + this.name = 'MaxPasskeysReachedError'; + } +} + +export class MaxIdentifiersReachedError extends MfaError { + constructor(message: string, status = HTTP_STATUS_CODES.CONFLICT) { + super('max_identifiers_reached', message, { status }); + this.name = 'MaxIdentifiersReachedError'; + } +} + +export class OtpResendCooldownError extends MfaError { + constructor( + message: string, + retryAfterMs?: number, + status = HTTP_STATUS_CODES.TOO_MANY_REQUESTS, + ) { + super('otp_resend_cooldown', message, { status, retryAfterMs }); + this.name = 'OtpResendCooldownError'; + } +} + +export class MfaUnavailableError extends MfaError { + constructor(message: string, status = HTTP_STATUS_CODES.BAD_GATEWAY) { + super('kratos_unavailable', message, { status }); + this.name = 'MfaUnavailableError'; + } +} + +export class ElevatedTokenInvalidError extends MfaError { + constructor(message: string) { + super('elevated_token_invalid', message); + this.name = 'ElevatedTokenInvalidError'; + } +} + +/** + * Gets an MFA code from direct, serialized, or message-only errors. + * + * @param error - Value to inspect. + * @returns The stable code, if present. + */ +export function getMfaErrorCode( + error: unknown, +): ExtensibleMfaErrorCode | undefined { + const direct = asRecord(error); + if (typeof direct?.mfaCode === 'string') { + return direct.mfaCode; + } + + const data = asRecord(direct?.data); + const cause = asRecord(data?.cause); + if (typeof cause?.mfaCode === 'string') { + return cause.mfaCode; + } + + const messages = [direct?.message, cause?.message]; + for (const message of messages) { + if (typeof message === 'string') { + const match = /^MFA\[([^\]]+)\]:/u.exec(message); + if (match?.[1]) { + return match[1]; + } + } + } + return undefined; +} + +/** + * Checks whether a value carries an MFA error code. + * + * @param error - Value to inspect. + * @param codes - Optional codes to match. + * @returns Whether the value is an MFA error with a requested code. + */ +export function isMfaError( + error: unknown, + ...codes: ExtensibleMfaErrorCode[] +): boolean { + const code = getMfaErrorCode(error); + return code !== undefined && (codes.length === 0 || codes.includes(code)); +} + +/** + * Gets a retry delay from direct or serialized MFA errors. + * + * @param error - Value to inspect. + * @returns Retry delay in milliseconds, if present. + */ +export function getMfaRetryAfterMs(error: unknown): number | undefined { + const direct = asRecord(error); + if (typeof direct?.retryAfterMs === 'number') { + return direct.retryAfterMs; + } + const data = asRecord(direct?.data); + const cause = asRecord(data?.cause); + return typeof cause?.retryAfterMs === 'number' + ? cause.retryAfterMs + : undefined; +} + export class NonceRetrievalError extends Error { constructor(message: string) { super(message); diff --git a/packages/profile-sync-controller/src/sdk/utils/jwt.test.ts b/packages/profile-sync-controller/src/sdk/utils/jwt.test.ts new file mode 100644 index 00000000000..8950e6277a5 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/jwt.test.ts @@ -0,0 +1,33 @@ +import { decodeJwtPayload } from './jwt.js'; + +function toBase64Url(value: unknown): string { + return btoa(JSON.stringify(value)) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +describe('decodeJwtPayload()', () => { + it('decodes a base64url payload', () => { + const payload = { + sub: 'profile-id', + aal: 2, + amr: 'passkey', + exp: 2_000_000_000, + }; + const token = `${toBase64Url({ alg: 'none' })}.${toBase64Url( + payload, + )}.signature`; + + expect(decodeJwtPayload(token)).toStrictEqual(payload); + }); + + it.each(['', 'one.part', 'one..three', 'one.!!!.three'])( + 'rejects malformed token %s', + (token) => { + expect(() => decodeJwtPayload(token)).toThrow( + /Invalid JWT|Unexpected|JSON|invalid characters/u, + ); + }, + ); +}); diff --git a/packages/profile-sync-controller/src/sdk/utils/jwt.ts b/packages/profile-sync-controller/src/sdk/utils/jwt.ts new file mode 100644 index 00000000000..6488cdecd35 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/utils/jwt.ts @@ -0,0 +1,21 @@ +/** + * Decodes a JWT payload without verifying its signature. + * + * Use this only for inspecting claims on a token received from a trusted + * exchange. Signature verification remains the authorization server's + * responsibility. + * + * @param token - JWT to decode. + * @returns The parsed payload. + * @throws If the token or payload is malformed. + */ +export function decodeJwtPayload(token: string): unknown { + const parts = token.split('.'); + if (parts.length !== 3 || !parts[1]) { + throw new Error('Invalid JWT'); + } + + const base64 = parts[1].replace(/-/gu, '+').replace(/_/gu, '/'); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); + return JSON.parse(atob(padded)); +} diff --git a/packages/profile-sync-controller/src/sdk/utils/validate-login-response.ts b/packages/profile-sync-controller/src/sdk/utils/validate-login-response.ts index 0346a9e7908..be1441545c6 100644 --- a/packages/profile-sync-controller/src/sdk/utils/validate-login-response.ts +++ b/packages/profile-sync-controller/src/sdk/utils/validate-login-response.ts @@ -1,4 +1,5 @@ import type { LoginResponse } from '../authentication.js'; +import { decodeJwtPayload } from './jwt.js'; /** * Validates that the input is a well-formed, non-expired LoginResponse. @@ -37,13 +38,12 @@ export function validateLoginResponse(input: unknown): input is LoginResponse { */ function isJwtExpired(token: string): boolean { try { - const parts = token.split('.'); - if (parts.length !== 3) { - return true; - } - const base64 = parts[1].replace(/-/gu, '+').replace(/_/gu, '/'); - const { exp } = JSON.parse(atob(base64)); - return !Number.isInteger(exp) || exp * 1000 <= Date.now(); + const { exp } = decodeJwtPayload(token) as { exp?: unknown }; + return ( + typeof exp !== 'number' || + !Number.isInteger(exp) || + exp * 1000 <= Date.now() + ); } catch { return true; } diff --git a/yarn.lock b/yarn.lock index 0f3e9cb12e2..f9719467379 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8653,7 +8653,9 @@ __metadata: "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/providers": "npm:^22.1.0" + "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/seedless-onboarding-controller": "npm:^11.0.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^12.0.0" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.2" From 2a8899481b2a37bf016217319f82605d80089fb1 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 16 Sep 2026 21:35:29 +0200 Subject: [PATCH 2/4] fix(profile-sync): tighten MFA schemas and error taxonomy - Read aal/amr from top-level or Hydra ext claims - Derive credential type from proof; add reason to completion requests - Split mfa_identity_missing from flow-expired errors; add rate_limited - Accept unknown credential statuses; drop unused description/BAD_REQUEST - MfaUnavailableError no longer invents a 502 for transport failures Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 2 +- .../mfa/schemas.test.ts | 65 +++++++- .../authentication-jwt-bearer/mfa/schemas.ts | 142 ++++++++---------- .../authentication-jwt-bearer/mfa/types.ts | 18 ++- .../src/sdk/constants.ts | 1 - .../src/sdk/errors.test.ts | 23 +++ .../profile-sync-controller/src/sdk/errors.ts | 41 ++++- 7 files changed, 192 insertions(+), 100 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 38ca8983dc7..2bc02110e1f 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 -- Add validated MFA domain types, structured errors, and JWT claim decoding utilities ([#10264](https://github.com/MetaMask/core/pull/10264)) +- 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)) ## [32.1.1] diff --git a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts index 757920b7e14..defe1be969e 100644 --- a/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts +++ b/packages/profile-sync-controller/src/sdk/authentication-jwt-bearer/mfa/schemas.test.ts @@ -162,17 +162,40 @@ describe('MFA schemas', () => { ).toThrow(/MFA\[invalid_request\].*\[reason.operation\]/u); }); - it('rejects unknown request fields and mismatched proofs', () => { + it('rejects an email on passkey enrollment', () => { expect(() => assertValidMfaRequest( { type: 'passkey', + email: 'user@example.com', + reason: { operation: 'settings.addPasskey' }, + }, + BeginEnrollmentRequestStruct, + ), + ).toThrow(/email is not accepted/u); + }); + + it('rejects unknown request fields and malformed proofs', () => { + expect(() => + assertValidMfaRequest( + { flowId: 'flow-id', - proof: { type: 'email_otp', code: '123456' }, + proof: { type: 'email_otp', code: '12345' }, + reason: { operation: 'settings.addEmail' }, }, CompleteEnrollmentRequestStruct, ), - ).toThrow(/type must match/u); + ).toThrow(/MFA\[invalid_request\].*\[proof\]/u); + + expect(() => + assertValidMfaRequest( + { + flowId: 'flow-id', + proof: { type: 'passkey', attestation: registration }, + }, + CompleteEnrollmentRequestStruct, + ), + ).toThrow(/\[reason\]/u); expect(() => assertValidMfaRequest( @@ -186,6 +209,17 @@ describe('MFA schemas', () => { ).toThrow(/extra/u); }); + it('accepts credential rows with unknown statuses', () => { + expect(() => + assertValidMfaResponse( + { + credentials: [{ credential_type: 'passkey', status: 'revoked' }], + }, + MfaCredentialsResponseStruct, + ), + ).not.toThrow(); + }); + it('normalizes elevated-token authentication methods', () => { expect( parseElevatedTokenClaims({ @@ -211,14 +245,29 @@ describe('MFA schemas', () => { ).toStrictEqual(['email_otp']); }); - it('rejects tokens that are not AAL2', () => { - expect(() => + it('reads step-up claims nested under the Hydra ext claim', () => { + expect( parseElevatedTokenClaims({ sub: 'profile-id', - aal: 1, exp: 2_000_000_000, - amr: 'passkey', + ext: { aal: 2, amr: ['passkey'] }, }), - ).toThrow(/MFA\[elevated_token_invalid\]/u); + ).toStrictEqual({ + sub: 'profile-id', + aal: 2, + exp: 2_000_000_000, + amr: ['passkey'], + }); + }); + + it.each([ + ['AAL1', { sub: 'profile-id', aal: 1, exp: 2_000_000_000, amr: 'passkey' }], + ['AAL1 under ext', { sub: 'profile-id', exp: 1, ext: { aal: 1 } }], + ['missing claims', { sub: 'profile-id', exp: 2_000_000_000 }], + ['non-object payload', 'not-a-payload'], + ])('rejects tokens without AAL2 claims (%s)', (_name, payload) => { + expect(() => parseElevatedTokenClaims(payload)).toThrow( + /MFA\[elevated_token_invalid\]/u, + ); }); }); 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 b11052df53c..3010bfa26bd 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 @@ -111,38 +111,14 @@ export const AuthenticationResponseJSONStruct = type({ clientExtensionResults: optional(ExtensionsStruct), }); -const ProfileStruct = type({ - profile_id: string(), - - identifier_id: string(), - - identifier_type: string(), - - metametrics_id: optional(string()), -}); - -const ProfileAliasStruct = type({ - alias_profile_id: string(), - - canonical_profile_id: string(), - - identifier_ids: optional( - array( - type({ - id: string(), - type: string(), - }), - ), - ), -}); - -export const AuthenticationResponseStruct = type({ +/** + * 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(), expires_in: integer(), - profile: ProfileStruct, - - profile_aliases: optional(array(ProfileAliasStruct)), }); export const MfaEnrollResponseStruct = type({ @@ -177,11 +153,11 @@ export const MfaEmailDetailStruct = type({ }); export const MfaCredentialStruct = type({ - // Intentionally accepts strings so future server credential types can be - // ignored without making the complete response invalid. + // Intentionally accepts strings so future server credential types and + // statuses can be ignored without making the complete response invalid. credential_type: string(), - status: enums(['active', 'pending']), + status: string(), enrolled_at: optional(string()), passkey: optional(MfaPasskeyDetailStruct), @@ -199,7 +175,6 @@ export const MfaErrorResponseStruct = type({ export const TokenReasonStruct = object({ operation: pattern(string(), /^[A-Za-z0-9_.:-]{1,64}$/u), - description: optional(string()), }); export const BeginEnrollmentRequestStruct = refine( @@ -209,60 +184,57 @@ export const BeginEnrollmentRequestStruct = refine( reason: TokenReasonStruct, }), 'BeginEnrollmentRequest', - (value) => - value.type !== 'email_otp' || value.email !== undefined - ? true - : 'email is required for email_otp enrollment', + (value) => { + if (value.type === 'email_otp' && value.email === undefined) { + return 'email is required for email_otp enrollment'; + } + if (value.type === 'passkey' && value.email !== undefined) { + return 'email is not accepted for passkey enrollment'; + } + return true; + }, ); -const PasskeyEnrollmentProofStruct = object({ - type: literal('passkey'), - attestation: RegistrationResponseJSONStruct, -}); - -const EmailOtpProofStruct = object({ - type: literal('email_otp'), - code: pattern(string(), /^\d{6}$/u), -}); +const EmailOtpCodeStruct = pattern(string(), /^\d{6}$/u); -export const CompleteEnrollmentRequestStruct = refine( +const EnrollmentProofStruct = union([ object({ - type: MfaCredentialTypeStruct, - flowId: string(), - proof: union([PasskeyEnrollmentProofStruct, EmailOtpProofStruct]), + type: literal('passkey'), + attestation: RegistrationResponseJSONStruct, }), - 'CompleteEnrollmentRequest', - (value) => - value.type === value.proof.type ? true : 'type must match the proof type', -); + object({ + type: literal('email_otp'), + code: EmailOtpCodeStruct, + }), +]); -export const BeginStepUpRequestStruct = object({ - credentialType: MfaCredentialTypeStruct, +export const CompleteEnrollmentRequestStruct = object({ + flowId: string(), + proof: EnrollmentProofStruct, reason: TokenReasonStruct, }); -const PasskeyStepUpProofStruct = object({ - type: literal('passkey'), - assertion: AuthenticationResponseJSONStruct, -}); - -const EmailOtpStepUpProofStruct = object({ - type: literal('email_otp'), - code: pattern(string(), /^\d{6}$/u), +export const BeginStepUpRequestStruct = object({ + type: MfaCredentialTypeStruct, + reason: TokenReasonStruct, }); -export const CompleteStepUpRequestStruct = refine( +const StepUpProofStruct = union([ object({ - credentialType: MfaCredentialTypeStruct, - flowId: string(), - proof: union([PasskeyStepUpProofStruct, EmailOtpStepUpProofStruct]), + type: literal('passkey'), + assertion: AuthenticationResponseJSONStruct, }), - 'CompleteStepUpRequest', - (value) => - value.credentialType === value.proof.type - ? true - : 'credentialType must match the proof type', -); + object({ + type: literal('email_otp'), + code: EmailOtpCodeStruct, + }), +]); + +export const CompleteStepUpRequestStruct = object({ + flowId: string(), + proof: StepUpProofStruct, + reason: TokenReasonStruct, +}); export const GetElevatedTokenRequestStruct = object({ maxSessionAgeMs: optional(min(integer(), 0)), @@ -326,19 +298,37 @@ 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. * - * @param value - Decoded JWT payload. + * Hydra places hook-supplied claims under `ext` unless they are promoted to + * the top level, so `aal` and `amr` are read from either location. + * + * @param payload - Decoded JWT payload. * @returns Validated claims with `amr` represented as an array. * @throws ElevatedTokenInvalidError if claims are missing or invalid. */ -export function parseElevatedTokenClaims(value: unknown): { +export function parseElevatedTokenClaims(payload: unknown): { sub: string; aal: 2; exp: number; amr: ('passkey' | 'email_otp')[]; } { + const record = asRecord(payload); + const ext = asRecord(record?.ext); + const value: unknown = { + sub: record?.sub, + exp: record?.exp, + aal: record?.aal ?? ext?.aal, + amr: record?.amr ?? ext?.amr, + }; + try { assert(value, ElevatedTokenClaimsStruct); } catch (error) { 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 20c321ec7c4..1d6d622b08a 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 @@ -2,13 +2,13 @@ import type { Infer } from '@metamask/superstruct'; import type { AuthenticationResponseJSONStruct, - AuthenticationResponseStruct, MfaCredentialStruct, MfaCredentialsResponseStruct, MfaEmailDetailStruct, MfaEnrollCompleteResponseStruct, MfaEnrollResponseStruct, MfaPasskeyDetailStruct, + MfaVerifyCompleteResponseStruct, MfaVerifyResponseStruct, PublicKeyCredentialCreationOptionsJSONStruct, PublicKeyCredentialRequestOptionsJSONStruct, @@ -47,7 +47,7 @@ export type MfaEnrollCompleteResponse = Infer< export type MfaVerifyResponse = Infer; export type MfaVerifyCompleteResponse = Infer< - typeof AuthenticationResponseStruct + typeof MfaVerifyCompleteResponseStruct >; export type MfaCredentialsResponse = Infer; @@ -106,9 +106,12 @@ export type EnrolledCredential = verified: boolean; }; +/** + * Caller-supplied context attached to MFA trace spans. `operation` names the + * client flow that needs the credential (for example `money.signTransaction`). + */ export type TokenReason = { operation: string; - description?: string; }; export type EnrollmentChallenge = @@ -122,7 +125,6 @@ export type EnrollmentChallenge = type: 'email_otp'; flowId: string; expiresAt: number; - emailSent: true; }; export type EnrollmentProof = @@ -146,7 +148,6 @@ export type StepUpChallenge = type: 'email_otp'; flowId: string; expiresAt: number; - deliverySent: true; }; export type StepUpProof = @@ -178,20 +179,20 @@ export type BeginEnrollmentRequest = { }; export type CompleteEnrollmentRequest = { - type: MfaCredentialType; flowId: string; proof: EnrollmentProof; + reason: TokenReason; }; export type BeginStepUpRequest = { - credentialType: MfaCredentialType; + type: MfaCredentialType; reason: TokenReason; }; export type CompleteStepUpRequest = { - credentialType: MfaCredentialType; flowId: string; proof: StepUpProof; + reason: TokenReason; }; export type GetElevatedTokenRequest = { @@ -206,6 +207,7 @@ export type MfaErrorCode = | 'credential_not_enrolled' | 'mfa_identity_missing' | 'otp_resend_cooldown' + | 'rate_limited' | 'kratos_unavailable' | 'flow_expired' | 'invalid_flow' diff --git a/packages/profile-sync-controller/src/sdk/constants.ts b/packages/profile-sync-controller/src/sdk/constants.ts index ef2c13eb24a..22215e0d01e 100644 --- a/packages/profile-sync-controller/src/sdk/constants.ts +++ b/packages/profile-sync-controller/src/sdk/constants.ts @@ -1,5 +1,4 @@ export const HTTP_STATUS_CODES = { - BAD_REQUEST: 400, UNAUTHORIZED: 401, CONFLICT: 409, UNPROCESSABLE_ENTITY: 422, diff --git a/packages/profile-sync-controller/src/sdk/errors.test.ts b/packages/profile-sync-controller/src/sdk/errors.test.ts index 09920418fee..84295e201cc 100644 --- a/packages/profile-sync-controller/src/sdk/errors.test.ts +++ b/packages/profile-sync-controller/src/sdk/errors.test.ts @@ -8,6 +8,8 @@ import { MaxPasskeysReachedError, MfaError, MfaFlowExpiredError, + MfaIdentityMissingError, + MfaRateLimitedError, MfaUnavailableError, MfaVerificationFailedError, OtpResendCooldownError, @@ -61,10 +63,12 @@ describe('MFA errors', () => { const errors = [ new CredentialNotEnrolledError('Not enrolled'), new MfaFlowExpiredError('flow_expired', 'Flow expired', 400), + new MfaIdentityMissingError('No identity'), new MfaVerificationFailedError('invalid_code', 'Invalid code', 400), new TooManyAttemptsError('Too many attempts', 400), new MaxPasskeysReachedError('Maximum passkeys reached'), new MaxIdentifiersReachedError('Maximum identifiers reached'), + new MfaRateLimitedError('Slow down', 5_000), new MfaUnavailableError('Identity provider unavailable'), new ElevatedTokenInvalidError('Expected AAL2 claims'), ]; @@ -72,13 +76,32 @@ describe('MFA errors', () => { expect(errors.map((error) => error.name)).toStrictEqual([ 'CredentialNotEnrolledError', 'MfaFlowExpiredError', + 'MfaIdentityMissingError', 'MfaVerificationFailedError', 'TooManyAttemptsError', 'MaxPasskeysReachedError', 'MaxIdentifiersReachedError', + 'MfaRateLimitedError', 'MfaUnavailableError', 'ElevatedTokenInvalidError', ]); + expect(errors.map((error) => error.mfaCode)).toStrictEqual([ + 'credential_not_enrolled', + 'flow_expired', + 'mfa_identity_missing', + 'invalid_code', + 'too_many_attempts', + 'max_passkeys_reached', + 'max_identifiers_reached', + 'rate_limited', + 'kratos_unavailable', + 'elevated_token_invalid', + ]); + }); + + it('does not invent an HTTP status for transport failures', () => { + expect(new MfaUnavailableError('fetch failed').status).toBeUndefined(); + expect(new MfaUnavailableError('Bad gateway', 502).status).toBe(502); }); it('reads direct retry delays and a serialized cause message', () => { diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index ed68f577f11..52633f3d532 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -55,17 +55,27 @@ export class CredentialNotEnrolledError extends MfaError { } } +/** + * The begin/complete flow is stale or unknown; the client must restart it. + */ export class MfaFlowExpiredError extends MfaError { - constructor( - code: 'flow_expired' | 'invalid_flow' | 'mfa_identity_missing', - message: string, - status?: number, - ) { + constructor(code: 'flow_expired' | 'invalid_flow', message: string, status?: number) { super(code, message, { status }); this.name = 'MfaFlowExpiredError'; } } +/** + * The profile has no identity-provider record yet. Not a stale flow: the + * user must enroll a first credential before verifying. + */ +export class MfaIdentityMissingError extends MfaError { + constructor(message: string, status = HTTP_STATUS_CODES.CONFLICT) { + super('mfa_identity_missing', message, { status }); + this.name = 'MfaIdentityMissingError'; + } +} + export class MfaVerificationFailedError extends MfaError { constructor( code: 'invalid_code' | 'invalid_attestation' | 'invalid_assertion', @@ -112,8 +122,27 @@ export class OtpResendCooldownError extends MfaError { } } +/** + * A 429 without an MFA-specific code: generic throttling, not an OTP resend + * cooldown. + */ +export class MfaRateLimitedError extends MfaError { + constructor( + message: string, + retryAfterMs?: number, + status = HTTP_STATUS_CODES.TOO_MANY_REQUESTS, + ) { + super('rate_limited', message, { status, retryAfterMs }); + this.name = 'MfaRateLimitedError'; + } +} + +/** + * The identity provider or the network is unreachable. Safe to retry. + * `status` is only set when the service answered. + */ export class MfaUnavailableError extends MfaError { - constructor(message: string, status = HTTP_STATUS_CODES.BAD_GATEWAY) { + constructor(message: string, status?: number) { super('kratos_unavailable', message, { status }); this.name = 'MfaUnavailableError'; } From 30d307f1c8579229e33aa0af30f08f74f86ca46e Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 16 Sep 2026 22:03:07 +0200 Subject: [PATCH 3/4] fix: lint --- packages/profile-sync-controller/src/sdk/errors.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/profile-sync-controller/src/sdk/errors.ts b/packages/profile-sync-controller/src/sdk/errors.ts index 52633f3d532..e240bddc0b1 100644 --- a/packages/profile-sync-controller/src/sdk/errors.ts +++ b/packages/profile-sync-controller/src/sdk/errors.ts @@ -59,7 +59,11 @@ export class CredentialNotEnrolledError extends MfaError { * The begin/complete flow is stale or unknown; the client must restart it. */ export class MfaFlowExpiredError extends MfaError { - constructor(code: 'flow_expired' | 'invalid_flow', message: string, status?: number) { + constructor( + code: 'flow_expired' | 'invalid_flow', + message: string, + status?: number, + ) { super(code, message, { status }); this.name = 'MfaFlowExpiredError'; } From d6d147b1f486243ee91fe979fc340c8d0b0b172d Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Thu, 17 Sep 2026 08:47:56 +0200 Subject: [PATCH 4/4] fix: error name --- packages/profile-sync-controller/src/sdk/errors.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/profile-sync-controller/src/sdk/errors.test.ts b/packages/profile-sync-controller/src/sdk/errors.test.ts index 84295e201cc..b4a7b237d4b 100644 --- a/packages/profile-sync-controller/src/sdk/errors.test.ts +++ b/packages/profile-sync-controller/src/sdk/errors.test.ts @@ -54,9 +54,9 @@ describe('MFA errors', () => { }); it('falls back to the stable message prefix', () => { - expect( - getMfaErrorCode(new Error('MFA[flow_cancelled]: User cancelled')), - ).toBe('flow_cancelled'); + expect(getMfaErrorCode(new Error('MFA[flow_expired]: Expired flow'))).toBe( + 'flow_expired', + ); }); it('provides specific errors for every actionable condition', () => {