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
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export enum VerificationMethods {
totp2fa = 2,
recoveryCode = 3,
sms2fa = 4,
passkey = 5,
}

/**
Expand Down
78 changes: 69 additions & 9 deletions packages/fxa-auth-server/lib/authMethods.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@

const { AppError: error } = require('@fxa/accounts/errors');

// This module serves two distinct purposes that should not be conflated:
//
// 1) RP reporting — availableAuthenticationMethods + maximumAssuranceLevel
// compute the amr/acr values returned to relying parties via the
// profile:amr scope. These reflect which mandatory second factors an
// account has configured, so RPs can decide whether to prompt for
// step-up authentication.
//
// 2) Session enforcement — accountRequiresAAL2 is used by the
// session auth strategies (verified-session-token, mfa) to decide
// whether to reject a session with insufficient AAL.
//
// The two paths intentionally use different functions. The semantics of
// RP-facing AMR/AAL are not well-defined and warrant a rethink — FXA-13432.
//
// Maps our variety of verification methods down to a few short standard
// "authentication method reference" strings that we're happy to expose to
// reliers. We try to use the values defined in RFC8176 where possible:
Expand All @@ -20,6 +35,7 @@ const METHOD_TO_AMR = {
'totp-2fa': 'otp',
'recovery-code': 'otp',
'sms-2fa': 'otp',
passkey: 'webauthn',
};

// Maps AMR values to the type of authenticator they represent, e.g.
Expand All @@ -29,12 +45,25 @@ const AMR_TO_TYPE = {
pwd: 'know',
email: 'know',
otp: 'have',
// WebAuthn with user verification is intrinsically multi-factor ('know'/'are'
// + 'have'), so a passkey session should yield AAL2 on its own. Mapping only
// to 'have' here means the AAL2 result for passkey sessions currently depends
// on the 'pwd' entry always being present in the session AMR set — see the
// comment in session_token.js authenticationMethods. Fixing this requires
// maximumAssuranceLevel to support multi-type AMR entries — FXA-13432.
webauthn: 'have',
};

module.exports = {
/**
* Returns the set of authentication methods available
* for the given account, as amr value strings.
* Returns the AMR values used to compute the authenticatorAssuranceLevel
* returned to relying parties via the profile:amr scope. In practice this
* reflects which *mandatory* second factors the account has enabled, not
* every method the account could theoretically use.
*
* Passkeys are intentionally excluded: they are optional and do not raise
* the required AAL for other sign-in paths. The semantics here are murky
* and need a proper rethink — see FXA-13432.
*/
async availableAuthenticationMethods(db, account) {
const amrValues = new Set();
Expand Down Expand Up @@ -71,18 +100,49 @@ module.exports = {
},

/**
* Given a set of AMR value strings, return the maximum authenticator assurance
* level that can be achieved using them. We aim to follow the definition
* of levels 1, 2, and 3 from NIST SP 800-63B based on different categories
* of authenticator (e.g. "something you know" vs "something you have"),
* although we don't yet support any methods that would qualify the user
* for level 3.
* Given a set of AMR value strings, return the AAL implied by the
* distinct authenticator types present (NIST SP 800-63B levels 1–2;
* level 3 is not supported). Two distinct types (e.g. 'know' + 'have')
* yields AAL2; one type yields AAL1.
*
* This function has two call sites with different inputs and different
* semantics:
*
* - SessionToken.authenticatorAssuranceLevel passes the session's own AMR
* set, producing the AAL of the current session. This value flows into
* the fxa-aal JWT claim and is checked against RP acr_values requests.
*
* - The profile:amr response path passes the output of
* availableAuthenticationMethods, which reflects mandatory second factors
* only and intentionally excludes passkeys. An account with passkeys
* registered but no TOTP will receive AAL1 here even though AAL2 is
* achievable — see FXA-13432.
*/
maximumAssuranceLevel(amrValues) {
const types = new Set();
amrValues.forEach((amr) => {
types.add(AMR_TO_TYPE[amr]);
const type = AMR_TO_TYPE[amr];
if (type) types.add(type);
});
return types.size;
},

/**
* Returns true if the account requires AAL2 on ALL sign-in paths.
*
* Only TOTP makes 2FA mandatory — if enabled, every session must reach AAL2.
* Passkeys are optional: registering one does not force AAL2 on password
* sign-ins.
*/
async accountRequiresAAL2(db, account) {
let res;
try {
res = await db.totpToken(account.uid);
} catch (err) {
if (err.errno !== error.ERRNO.TOTP_TOKEN_NOT_FOUND) {
throw err;
}
}
return !!(res && res.verified && res.enabled);
},
};
109 changes: 79 additions & 30 deletions packages/fxa-auth-server/lib/authMethods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,78 +2,64 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import sinon from 'sinon';
import { AppError as error } from '@fxa/accounts/errors';
import * as authMethods from './authMethods';

const MOCK_ACCOUNT = {
uid: 'abcdef123456',
};

function mockDB() {
return {
totpToken: sinon.stub(),
// Add other DB methods as needed
};
}

describe('availableAuthenticationMethods', () => {
let mockDbInstance: ReturnType<typeof mockDB>;
let db: { totpToken: jest.Mock };

beforeEach(() => {
mockDbInstance = mockDB();
db = { totpToken: jest.fn() };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for cleaning this up!

});

it('returns [`pwd`,`email`] for non-TOTP-enabled accounts', async () => {
mockDbInstance.totpToken = sinon.stub().rejects(error.totpTokenNotFound());
db.totpToken.mockRejectedValue(error.totpTokenNotFound());
const amr = await authMethods.availableAuthenticationMethods(
mockDbInstance as any,
db as any,
MOCK_ACCOUNT as any
);
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
expect(Array.from(amr).sort()).toEqual(['email', 'pwd']);
});

it('returns [`pwd`,`email`,`otp`] for TOTP-enabled accounts', async () => {
mockDbInstance.totpToken = sinon.stub().resolves({
db.totpToken.mockResolvedValue({
verified: true,
enabled: true,
sharedSecret: 'secret!',
});
const amr = await authMethods.availableAuthenticationMethods(
mockDbInstance as any,
db as any,
MOCK_ACCOUNT as any
);
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
expect(Array.from(amr).sort()).toEqual(['email', 'otp', 'pwd']);
});

it('returns [`pwd`,`email`] when TOTP token is not yet enabled', async () => {
mockDbInstance.totpToken = sinon.stub().resolves({
db.totpToken.mockResolvedValue({
verified: true,
enabled: false,
sharedSecret: 'secret!',
});
const amr = await authMethods.availableAuthenticationMethods(
mockDbInstance as any,
db as any,
MOCK_ACCOUNT as any
);
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
expect(Array.from(amr).sort()).toEqual(['email', 'pwd']);
});

it('rethrows unexpected DB errors', async () => {
mockDbInstance.totpToken = sinon.stub().rejects(error.serviceUnavailable());
try {
await authMethods.availableAuthenticationMethods(
mockDbInstance as any,
MOCK_ACCOUNT as any
);
throw new Error('error should have been re-thrown');
} catch (err: any) {
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
expect(err.errno).toBe(error.ERRNO.SERVER_BUSY);
}
db.totpToken.mockRejectedValue(error.serviceUnavailable());
await expect(
authMethods.availableAuthenticationMethods(db as any, MOCK_ACCOUNT as any)
).rejects.toMatchObject({ errno: error.ERRNO.SERVER_BUSY });
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
});
});

Expand All @@ -98,6 +84,10 @@ describe('verificationMethodToAMR', () => {
expect(authMethods.verificationMethodToAMR('recovery-code')).toBe('otp');
});

it('maps `passkey` to `webauthn`', () => {
expect(authMethods.verificationMethodToAMR('passkey')).toBe('webauthn');
});

it('throws when given an unknown verification method', () => {
expect(() => {
authMethods.verificationMethodToAMR('email-gotcha' as any);
Expand Down Expand Up @@ -130,4 +120,63 @@ describe('maximumAssuranceLevel', () => {
it('returns 2 when both `pwd` and `otp` methods are used', () => {
expect(authMethods.maximumAssuranceLevel(['pwd', 'otp'])).toBe(2);
});

it('returns 2 when both `pwd` and `webauthn` methods are used (passkey session)', () => {
expect(authMethods.maximumAssuranceLevel(['pwd', 'webauthn'])).toBe(2);
});
});

describe('accountRequiresAAL2', () => {
let db: { totpToken: jest.Mock };

beforeEach(() => {
db = { totpToken: jest.fn() };
});

it('returns false when account has no TOTP token', async () => {
db.totpToken.mockRejectedValue(error.totpTokenNotFound());
const result = await authMethods.accountRequiresAAL2(
db as any,
MOCK_ACCOUNT as any
);
expect(result).toBe(false);
});

// The current TOTP setup flow writes to the DB only at setup-complete via
// replaceTotpToken, always with both flags true — partial states cannot be
// produced by any current code path. These tests are defensive guards against
// legacy data or future regressions.
it('returns false when TOTP token exists but is not verified', async () => {
db.totpToken.mockResolvedValue({ verified: false, enabled: true });
const result = await authMethods.accountRequiresAAL2(
db as any,
MOCK_ACCOUNT as any
);
expect(result).toBe(false);
});

it('returns false when TOTP token exists but is not enabled', async () => {
db.totpToken.mockResolvedValue({ verified: true, enabled: false });
const result = await authMethods.accountRequiresAAL2(
db as any,
MOCK_ACCOUNT as any
);
expect(result).toBe(false);
});

it('returns true when TOTP token is both verified and enabled', async () => {
db.totpToken.mockResolvedValue({ verified: true, enabled: true });
const result = await authMethods.accountRequiresAAL2(
db as any,
MOCK_ACCOUNT as any
);
expect(result).toBe(true);
});

it('rethrows unexpected DB errors', async () => {
db.totpToken.mockRejectedValue(error.serviceUnavailable());
await expect(
authMethods.accountRequiresAAL2(db as any, MOCK_ACCOUNT as any)
).rejects.toMatchObject({ errno: error.ERRNO.SERVER_BUSY });
});
});
6 changes: 6 additions & 0 deletions packages/fxa-auth-server/lib/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1813,6 +1813,12 @@ export class AccountHandler {
res.locale = account.locale;
}
if (scope.contains('profile:amr')) {
// authenticatorAssuranceLevel here is account-level: it tells the RP
// what AAL this account *requires* (based on mandatory second factors
// like TOTP), so the RP can decide whether to prompt for step-up.
// It is NOT the AAL of the current session. Passkeys are excluded from
// this computation — a passkey-only account reports AAL1 even though
// AAL2 is achievable. See FXA-13432.
Copy link
Copy Markdown
Contributor

@dschom dschom Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my own personal clarification... A passkey session is a AAL2 session correct? I'm just getting thrown off by the 'is acheivable' comment...

I guess I also don't understand why a 'passkey only' account is excluded from this calculation. Is it that we are concerned the RP will assume TOTP is required? I'd assume a passkey only account would be require AAL2 just like an account with totp. Maybe this is incorrect though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passkey session is AAL2, but the account does not necessarily require AAL2 for all sign-ins unless 2FA/TOTP is also enabled.

const amrValues = await authMethods.availableAuthenticationMethods(
this.db,
account
Expand Down
Loading
Loading