Skip to content

Commit 8fa28a0

Browse files
committed
feat(passkeys): Support passkeys verification method for sessionTokens
Because: * Passkey authentication needs a verified session token with verificationMethod = 5 and AAL2. Adding passkey also required fixing AAL enforcement, which previously blocked password sign-in for passkey-holding accounts (passkeys are optional; only TOTP mandates 2FA). This commit: * Registers passkey as verification method across auth-server, fxa-shared, and account repository * Maps passkey -> webauth AMR in authMethods * Adds accountRequiresAal2() and replaces the old AAL enforcement pattern in the session auth strategies * Adds createPasskeySessionToken utility to PasskeyHandler (needed for FXA-13095) * Adds PASSKEY/WEBAUTHN enum values to fxa-settings and fxa-content-server * Extends unit tests across affected files Closes #FXA-13097
1 parent 21706ba commit 8fa28a0

18 files changed

Lines changed: 490 additions & 129 deletions

File tree

libs/shared/account/account/src/lib/account.repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export enum VerificationMethods {
7878
totp2fa = 2,
7979
recoveryCode = 3,
8080
sms2fa = 4,
81+
passkey = 5,
8182
}
8283

8384
/**

packages/fxa-auth-server/lib/authMethods.js

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@
66

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

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

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

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

73102
/**
74-
* Given a set of AMR value strings, return the maximum authenticator assurance
75-
* level that can be achieved using them. We aim to follow the definition
76-
* of levels 1, 2, and 3 from NIST SP 800-63B based on different categories
77-
* of authenticator (e.g. "something you know" vs "something you have"),
78-
* although we don't yet support any methods that would qualify the user
79-
* for level 3.
103+
* Given a set of AMR value strings, return the AAL implied by the
104+
* distinct authenticator types present (NIST SP 800-63B levels 1–2;
105+
* level 3 is not supported). Two distinct types (e.g. 'know' + 'have')
106+
* yields AAL2; one type yields AAL1.
107+
*
108+
* This function has two call sites with different inputs and different
109+
* semantics:
110+
*
111+
* - SessionToken.authenticatorAssuranceLevel passes the session's own AMR
112+
* set, producing the AAL of the current session. This value flows into
113+
* the fxa-aal JWT claim and is checked against RP acr_values requests.
114+
*
115+
* - The profile:amr response path passes the output of
116+
* availableAuthenticationMethods, which reflects mandatory second factors
117+
* only and intentionally excludes passkeys. An account with passkeys
118+
* registered but no TOTP will receive AAL1 here even though AAL2 is
119+
* achievable — see FXA-13432.
80120
*/
81121
maximumAssuranceLevel(amrValues) {
82122
const types = new Set();
83123
amrValues.forEach((amr) => {
84-
types.add(AMR_TO_TYPE[amr]);
124+
const type = AMR_TO_TYPE[amr];
125+
if (type) types.add(type);
85126
});
86127
return types.size;
87128
},
129+
130+
/**
131+
* Returns true if the account requires AAL2 on ALL sign-in paths.
132+
*
133+
* Only TOTP makes 2FA mandatory — if enabled, every session must reach AAL2.
134+
* Passkeys are optional: registering one does not force AAL2 on password
135+
* sign-ins.
136+
*/
137+
async accountRequiresAAL2(db, account) {
138+
let res;
139+
try {
140+
res = await db.totpToken(account.uid);
141+
} catch (err) {
142+
if (err.errno !== error.ERRNO.TOTP_TOKEN_NOT_FOUND) {
143+
throw err;
144+
}
145+
}
146+
return !!(res && res.verified && res.enabled);
147+
},
88148
};

packages/fxa-auth-server/lib/authMethods.spec.ts

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,78 +2,64 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import sinon from 'sinon';
65
import { AppError as error } from '@fxa/accounts/errors';
76
import * as authMethods from './authMethods';
87

98
const MOCK_ACCOUNT = {
109
uid: 'abcdef123456',
1110
};
1211

13-
function mockDB() {
14-
return {
15-
totpToken: sinon.stub(),
16-
// Add other DB methods as needed
17-
};
18-
}
19-
2012
describe('availableAuthenticationMethods', () => {
21-
let mockDbInstance: ReturnType<typeof mockDB>;
13+
let db: { totpToken: jest.Mock };
2214

2315
beforeEach(() => {
24-
mockDbInstance = mockDB();
16+
db = { totpToken: jest.fn() };
2517
});
2618

2719
it('returns [`pwd`,`email`] for non-TOTP-enabled accounts', async () => {
28-
mockDbInstance.totpToken = sinon.stub().rejects(error.totpTokenNotFound());
20+
db.totpToken.mockRejectedValue(error.totpTokenNotFound());
2921
const amr = await authMethods.availableAuthenticationMethods(
30-
mockDbInstance as any,
22+
db as any,
3123
MOCK_ACCOUNT as any
3224
);
33-
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
25+
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
3426
expect(Array.from(amr).sort()).toEqual(['email', 'pwd']);
3527
});
3628

3729
it('returns [`pwd`,`email`,`otp`] for TOTP-enabled accounts', async () => {
38-
mockDbInstance.totpToken = sinon.stub().resolves({
30+
db.totpToken.mockResolvedValue({
3931
verified: true,
4032
enabled: true,
4133
sharedSecret: 'secret!',
4234
});
4335
const amr = await authMethods.availableAuthenticationMethods(
44-
mockDbInstance as any,
36+
db as any,
4537
MOCK_ACCOUNT as any
4638
);
47-
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
39+
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
4840
expect(Array.from(amr).sort()).toEqual(['email', 'otp', 'pwd']);
4941
});
5042

5143
it('returns [`pwd`,`email`] when TOTP token is not yet enabled', async () => {
52-
mockDbInstance.totpToken = sinon.stub().resolves({
44+
db.totpToken.mockResolvedValue({
5345
verified: true,
5446
enabled: false,
5547
sharedSecret: 'secret!',
5648
});
5749
const amr = await authMethods.availableAuthenticationMethods(
58-
mockDbInstance as any,
50+
db as any,
5951
MOCK_ACCOUNT as any
6052
);
61-
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
53+
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
6254
expect(Array.from(amr).sort()).toEqual(['email', 'pwd']);
6355
});
6456

6557
it('rethrows unexpected DB errors', async () => {
66-
mockDbInstance.totpToken = sinon.stub().rejects(error.serviceUnavailable());
67-
try {
68-
await authMethods.availableAuthenticationMethods(
69-
mockDbInstance as any,
70-
MOCK_ACCOUNT as any
71-
);
72-
throw new Error('error should have been re-thrown');
73-
} catch (err: any) {
74-
expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true);
75-
expect(err.errno).toBe(error.ERRNO.SERVER_BUSY);
76-
}
58+
db.totpToken.mockRejectedValue(error.serviceUnavailable());
59+
await expect(
60+
authMethods.availableAuthenticationMethods(db as any, MOCK_ACCOUNT as any)
61+
).rejects.toMatchObject({ errno: error.ERRNO.SERVER_BUSY });
62+
expect(db.totpToken).toHaveBeenCalledWith(MOCK_ACCOUNT.uid);
7763
});
7864
});
7965

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

87+
it('maps `passkey` to `webauthn`', () => {
88+
expect(authMethods.verificationMethodToAMR('passkey')).toBe('webauthn');
89+
});
90+
10191
it('throws when given an unknown verification method', () => {
10292
expect(() => {
10393
authMethods.verificationMethodToAMR('email-gotcha' as any);
@@ -130,4 +120,63 @@ describe('maximumAssuranceLevel', () => {
130120
it('returns 2 when both `pwd` and `otp` methods are used', () => {
131121
expect(authMethods.maximumAssuranceLevel(['pwd', 'otp'])).toBe(2);
132122
});
123+
124+
it('returns 2 when both `pwd` and `webauthn` methods are used (passkey session)', () => {
125+
expect(authMethods.maximumAssuranceLevel(['pwd', 'webauthn'])).toBe(2);
126+
});
127+
});
128+
129+
describe('accountRequiresAAL2', () => {
130+
let db: { totpToken: jest.Mock };
131+
132+
beforeEach(() => {
133+
db = { totpToken: jest.fn() };
134+
});
135+
136+
it('returns false when account has no TOTP token', async () => {
137+
db.totpToken.mockRejectedValue(error.totpTokenNotFound());
138+
const result = await authMethods.accountRequiresAAL2(
139+
db as any,
140+
MOCK_ACCOUNT as any
141+
);
142+
expect(result).toBe(false);
143+
});
144+
145+
// The current TOTP setup flow writes to the DB only at setup-complete via
146+
// replaceTotpToken, always with both flags true — partial states cannot be
147+
// produced by any current code path. These tests are defensive guards against
148+
// legacy data or future regressions.
149+
it('returns false when TOTP token exists but is not verified', async () => {
150+
db.totpToken.mockResolvedValue({ verified: false, enabled: true });
151+
const result = await authMethods.accountRequiresAAL2(
152+
db as any,
153+
MOCK_ACCOUNT as any
154+
);
155+
expect(result).toBe(false);
156+
});
157+
158+
it('returns false when TOTP token exists but is not enabled', async () => {
159+
db.totpToken.mockResolvedValue({ verified: true, enabled: false });
160+
const result = await authMethods.accountRequiresAAL2(
161+
db as any,
162+
MOCK_ACCOUNT as any
163+
);
164+
expect(result).toBe(false);
165+
});
166+
167+
it('returns true when TOTP token is both verified and enabled', async () => {
168+
db.totpToken.mockResolvedValue({ verified: true, enabled: true });
169+
const result = await authMethods.accountRequiresAAL2(
170+
db as any,
171+
MOCK_ACCOUNT as any
172+
);
173+
expect(result).toBe(true);
174+
});
175+
176+
it('rethrows unexpected DB errors', async () => {
177+
db.totpToken.mockRejectedValue(error.serviceUnavailable());
178+
await expect(
179+
authMethods.accountRequiresAAL2(db as any, MOCK_ACCOUNT as any)
180+
).rejects.toMatchObject({ errno: error.ERRNO.SERVER_BUSY });
181+
});
133182
});

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,6 +1813,12 @@ export class AccountHandler {
18131813
res.locale = account.locale;
18141814
}
18151815
if (scope.contains('profile:amr')) {
1816+
// authenticatorAssuranceLevel here is account-level: it tells the RP
1817+
// what AAL this account *requires* (based on mandatory second factors
1818+
// like TOTP), so the RP can decide whether to prompt for step-up.
1819+
// It is NOT the AAL of the current session. Passkeys are excluded from
1820+
// this computation — a passkey-only account reports AAL1 even though
1821+
// AAL2 is achievable. See FXA-13432.
18161822
const amrValues = await authMethods.availableAuthenticationMethods(
18171823
this.db,
18181824
account

0 commit comments

Comments
 (0)