Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ([#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`

### Changed

- Bump `immer` from `^9.0.6` to `^9.0.21` ([#10331](https://github.com/MetaMask/core/pull/10331))
Expand Down
6 changes: 5 additions & 1 deletion packages/profile-sync-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnrollmentChallenge> {
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 } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MfaRateLimitedError,
MfaUnavailableError,
MfaVerificationFailedError,
StepUpRequiredError,
TooManyAttemptsError,
} from '../../errors.js';
import {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
MfaUnavailableError,
MfaVerificationFailedError,
OtpResendCooldownError,
StepUpRequiredError,
TooManyAttemptsError,
} from '../../errors.js';
import { asRecord } from '../../utils/as-record.js';
Expand Down Expand Up @@ -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.
Comment thread
gantunesr marked this conversation as resolved.
*
* @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<MfaErrorBody | undefined> {
let body: unknown;
try {
body = await response.json();
Expand Down Expand Up @@ -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);
Expand All @@ -263,7 +291,7 @@ async function throwMfaError(
case 'otp_resend_cooldown':
throw new OtpResendCooldownError(
message,
parseRetryAfter(response),
resolveRetryAfterMs(body, response),
status,
);
case 'kratos_unavailable':
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnrollmentChallenge> {
this.#assertSRP(this.#type, this.#sdk);
return await this.#sdk.beginMfaEnrollment(type, options);
Expand Down
1 change: 1 addition & 0 deletions packages/profile-sync-controller/src/sdk/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const HTTP_STATUS_CODES = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
Expand Down
24 changes: 23 additions & 1 deletion packages/profile-sync-controller/src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand All @@ -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 });
Expand Down
Loading