Skip to content

Commit f76f974

Browse files
committed
feat(passkeys): Split the feature flag for passkeys
Because: * Allow testing registration separately from authentication This commit: * Adds registrationEnabled and authenticationEnabled flags and uses the existing flag as a master switch Closes #FXA-13398
1 parent 769e5a8 commit f76f974

12 files changed

Lines changed: 222 additions & 33 deletions

File tree

packages/fxa-auth-server/config/dev.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,8 @@
467467
},
468468
"passkeys": {
469469
"enabled": true,
470+
"registrationEnabled": true,
471+
"authenticationEnabled": true,
470472
"rpId": "localhost",
471473
"allowedOrigins": ["http://localhost:3030"]
472474
},

packages/fxa-auth-server/config/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2527,10 +2527,22 @@ const convictConf = convict({
25272527
passkeys: {
25282528
enabled: {
25292529
default: false,
2530-
doc: 'Enable passkeys authentication feature',
2530+
doc: 'Master switch for passkeys. Must be true for registrationEnabled or authenticationEnabled to take effect.',
25312531
env: 'PASSKEYS__ENABLED',
25322532
format: Boolean,
25332533
},
2534+
registrationEnabled: {
2535+
default: false,
2536+
doc: 'Enable passkey registration and management (add/view/delete/rename). Requires passkeys.enabled.',
2537+
env: 'PASSKEYS__REGISTRATION_ENABLED',
2538+
format: Boolean,
2539+
},
2540+
authenticationEnabled: {
2541+
default: false,
2542+
doc: 'Enable passkey authentication (sign in with passkey). Requires passkeys.enabled.',
2543+
env: 'PASSKEYS__AUTHENTICATION_ENABLED',
2544+
format: Boolean,
2545+
},
25342546
rpId: {
25352547
default: '',
25362548
doc: 'WebAuthn Relying Party ID. Must match the domain of the deployment (e.g. "accounts.firefox.com"). Required when passkeys are enabled.',

packages/fxa-auth-server/lib/passkey-utils.spec.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,85 @@
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 { isPasskeyFeatureEnabled } from './passkey-utils';
6-
import { AppError } from '@fxa/accounts/errors';
5+
import {
6+
isPasskeyAuthenticationEnabled,
7+
isPasskeyFeatureEnabled,
8+
isPasskeyRegistrationEnabled,
9+
} from './passkey-utils';
710

811
describe('passkey-utils', () => {
912
describe('isPasskeyFeatureEnabled', () => {
1013
it('should return true when passkeys are enabled', () => {
1114
const config = { passkeys: { enabled: true } };
12-
const result = isPasskeyFeatureEnabled(config);
13-
expect(result).toBe(true);
15+
expect(isPasskeyFeatureEnabled(config)).toBe(true);
1416
});
1517

1618
it('should throw featureNotEnabled error when passkeys are disabled', () => {
1719
const config = { passkeys: { enabled: false } };
18-
try {
19-
isPasskeyFeatureEnabled(config);
20-
throw new Error('should have thrown an error');
21-
} catch (error: any) {
22-
expect(error.errno).toBe(AppError.featureNotEnabled().errno);
23-
expect(error.message).toBe('Feature not enabled');
24-
}
20+
expect(() => isPasskeyFeatureEnabled(config)).toThrow(
21+
'Feature not enabled'
22+
);
2523
});
2624

2725
it('should throw featureNotEnabled error when config.passkeys.enabled is undefined', () => {
2826
const config = { passkeys: {} };
29-
try {
30-
isPasskeyFeatureEnabled(config);
31-
throw new Error('should have thrown an error');
32-
} catch (error: any) {
33-
expect(error.errno).toBe(AppError.featureNotEnabled().errno);
34-
}
27+
expect(() => isPasskeyFeatureEnabled(config)).toThrow(
28+
'Feature not enabled'
29+
);
30+
});
31+
});
32+
33+
describe('isPasskeyRegistrationEnabled', () => {
34+
it('should return true when master and registration flags are both enabled', () => {
35+
const config = {
36+
passkeys: { enabled: true, registrationEnabled: true },
37+
};
38+
expect(isPasskeyRegistrationEnabled(config)).toBe(true);
39+
});
40+
41+
it('should throw when master is enabled but registrationEnabled is false', () => {
42+
const config = {
43+
passkeys: { enabled: true, registrationEnabled: false },
44+
};
45+
expect(() => isPasskeyRegistrationEnabled(config)).toThrow(
46+
'Feature not enabled'
47+
);
48+
});
49+
50+
it('should throw when master is disabled even if registrationEnabled is true', () => {
51+
const config = {
52+
passkeys: { enabled: false, registrationEnabled: true },
53+
};
54+
expect(() => isPasskeyRegistrationEnabled(config)).toThrow(
55+
'Feature not enabled'
56+
);
57+
});
58+
});
59+
60+
describe('isPasskeyAuthenticationEnabled', () => {
61+
it('should return true when master and authentication flags are both enabled', () => {
62+
const config = {
63+
passkeys: { enabled: true, authenticationEnabled: true },
64+
};
65+
expect(isPasskeyAuthenticationEnabled(config)).toBe(true);
66+
});
67+
68+
it('should throw when master is enabled but authenticationEnabled is false', () => {
69+
const config = {
70+
passkeys: { enabled: true, authenticationEnabled: false },
71+
};
72+
expect(() => isPasskeyAuthenticationEnabled(config)).toThrow(
73+
'Feature not enabled'
74+
);
75+
});
76+
77+
it('should throw when master is disabled even if authenticationEnabled is true', () => {
78+
const config = {
79+
passkeys: { enabled: false, authenticationEnabled: true },
80+
};
81+
expect(() => isPasskeyAuthenticationEnabled(config)).toThrow(
82+
'Feature not enabled'
83+
);
3584
});
3685
});
3786
});

packages/fxa-auth-server/lib/passkey-utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,29 @@ export function isPasskeyFeatureEnabled(config: ConfigType): boolean {
1717
}
1818
return true;
1919
}
20+
21+
/**
22+
* Checks if passkey registration (adding new passkeys) is enabled.
23+
* Requires both the master `passkeys.enabled` flag and `passkeys.registrationEnabled`.
24+
* Management routes (list/delete/rename) use isPasskeyFeatureEnabled instead.
25+
* @throws AppError.featureNotEnabled if either flag is disabled
26+
*/
27+
export function isPasskeyRegistrationEnabled(config: ConfigType): boolean {
28+
if (!config.passkeys.enabled || !config.passkeys.registrationEnabled) {
29+
throw AppError.featureNotEnabled();
30+
}
31+
return true;
32+
}
33+
34+
/**
35+
* Checks if passkey authentication (sign in with passkey) is enabled.
36+
* Requires both the master `passkeys.enabled` flag and `passkeys.authenticationEnabled`.
37+
* @throws AppError.featureNotEnabled if either flag is disabled
38+
* TODO FXA-13069: wire into passkey authentication routes once they are added to passkeys.ts
39+
*/
40+
export function isPasskeyAuthenticationEnabled(config: ConfigType): boolean {
41+
if (!config.passkeys.enabled || !config.passkeys.authenticationEnabled) {
42+
throw AppError.featureNotEnabled();
43+
}
44+
return true;
45+
}

packages/fxa-auth-server/lib/routes/passkeys.spec.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Container } from 'typedi';
66
import { PasskeyService } from '@fxa/accounts/passkey';
77
import { AppError } from '@fxa/accounts/errors';
88
import { recordSecurityEvent } from './utils/security-event';
9-
import { isPasskeyFeatureEnabled } from '../passkey-utils';
9+
import { isPasskeyRegistrationEnabled } from '../passkey-utils';
1010
import { passkeyRoutes } from './passkeys';
1111

1212
jest.mock('./utils/security-event', () => ({
@@ -33,6 +33,7 @@ describe('passkeys routes', () => {
3333
const config = {
3434
passkeys: {
3535
enabled: true,
36+
registrationEnabled: true,
3637
},
3738
};
3839

@@ -110,16 +111,17 @@ describe('passkeys routes', () => {
110111

111112
afterEach(() => {
112113
config.passkeys.enabled = true;
113-
jest.clearAllMocks();
114+
config.passkeys.registrationEnabled = true;
114115
Container.reset();
115116
});
116117

117-
describe('isPasskeyFeatureEnabled', () => {
118-
it('throws featureNotEnabled when passkeys.enabled is false', () => {
118+
describe('isPasskeyRegistrationEnabled', () => {
119+
it('throws featureNotEnabled when registrationEnabled is false', () => {
119120
expect(() =>
120-
isPasskeyFeatureEnabled({
121+
isPasskeyRegistrationEnabled({
121122
passkeys: {
122-
enabled: false,
123+
enabled: true,
124+
registrationEnabled: false,
123125
},
124126
})
125127
).toThrow('Feature not enabled');

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import { PasskeyService } from '@fxa/accounts/passkey';
88
import { AuthRequest } from '../types';
99
import { recordSecurityEvent } from './utils/security-event';
1010
import { ConfigType } from '../../config';
11-
import { isPasskeyFeatureEnabled } from '../passkey-utils';
11+
import {
12+
isPasskeyFeatureEnabled,
13+
isPasskeyRegistrationEnabled,
14+
} from '../passkey-utils';
1215
import { GleanMetricsType } from '../metrics/glean';
1316
import PASSKEYS_API_DOCS from '../../docs/swagger/passkeys-api';
1417
import { RegistrationResponseJSON } from '@simplewebauthn/server';
@@ -320,7 +323,12 @@ export const passkeyRoutes = (
320323
glean: GleanMetricsType,
321324
log: any
322325
) => {
323-
const featureEnabledCheck = () => isPasskeyFeatureEnabled(config);
326+
// Passkey route flag hierarchy:
327+
// passkeys.enabled (master switch) — gates management routes (list/delete/rename)
328+
// + registrationEnabled — gates registration routes
329+
// + authenticationEnabled — gates auth routes (TODO FXA-13095)
330+
const passkeysEnabledCheck = () => isPasskeyFeatureEnabled(config);
331+
const registrationEnabledCheck = () => isPasskeyRegistrationEnabled(config);
324332

325333
const service = Container.get(PasskeyService);
326334
if (!service) {
@@ -336,7 +344,7 @@ export const passkeyRoutes = (
336344
path: '/passkey/registration/start',
337345
options: {
338346
...PASSKEYS_API_DOCS.PASSKEY_REGISTRATION_START_POST,
339-
pre: [{ method: featureEnabledCheck }],
347+
pre: [{ method: registrationEnabledCheck }],
340348
auth: {
341349
strategy: 'mfa',
342350
scope: ['mfa:passkey'],
@@ -457,7 +465,7 @@ export const passkeyRoutes = (
457465
path: '/passkey/registration/finish',
458466
options: {
459467
...PASSKEYS_API_DOCS.PASSKEY_REGISTRATION_FINISH_POST,
460-
pre: [{ method: featureEnabledCheck }],
468+
pre: [{ method: registrationEnabledCheck }],
461469
auth: {
462470
strategy: 'mfa',
463471
scope: ['mfa:passkey'],
@@ -493,7 +501,7 @@ export const passkeyRoutes = (
493501
path: '/passkeys',
494502
options: {
495503
...PASSKEYS_API_DOCS.PASSKEYS_GET,
496-
pre: [{ method: featureEnabledCheck }],
504+
pre: [{ method: passkeysEnabledCheck }],
497505
auth: {
498506
strategy: 'verifiedSessionToken',
499507
payload: false,
@@ -524,7 +532,7 @@ export const passkeyRoutes = (
524532
path: '/passkey/{credentialId}',
525533
options: {
526534
...PASSKEYS_API_DOCS.PASSKEY_CREDENTIAL_DELETE,
527-
pre: [{ method: featureEnabledCheck }],
535+
pre: [{ method: passkeysEnabledCheck }],
528536
auth: {
529537
strategy: 'mfa',
530538
scope: ['mfa:passkey'],
@@ -549,7 +557,7 @@ export const passkeyRoutes = (
549557
path: '/passkey/{credentialId}',
550558
options: {
551559
...PASSKEYS_API_DOCS.PASSKEY_CREDENTIAL_PATCH,
552-
pre: [{ method: featureEnabledCheck }],
560+
pre: [{ method: passkeysEnabledCheck }],
553561
auth: {
554562
strategy: 'mfa',
555563
scope: ['mfa:passkey'],

packages/fxa-auth-server/test/remote/passkeys.in.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ beforeAll(async () => {
6060
},
6161
passkeys: {
6262
enabled: true,
63+
registrationEnabled: true,
64+
authenticationEnabled: true,
6365
},
6466
},
6567
});

packages/fxa-content-server/server/lib/beta-settings.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ const settingsConfig = {
122122
'featureFlags.paymentsNextSubscriptionManagement'
123123
),
124124
passkeysEnabled: config.get('featureFlags.passkeysEnabled'),
125+
passkeyRegistrationEnabled: config.get(
126+
'featureFlags.passkeyRegistrationEnabled'
127+
),
128+
passkeyAuthenticationEnabled: config.get(
129+
'featureFlags.passkeyAuthenticationEnabled'
130+
),
125131
passwordlessEnabled: config.get('featureFlags.passwordlessEnabled'),
126132
},
127133
darkMode: {

packages/fxa-content-server/server/lib/configuration.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,22 @@ const conf = (module.exports = convict({
249249
},
250250
passkeysEnabled: {
251251
default: false,
252-
doc: 'Enables passkeys authentication',
252+
doc: 'Master switch for passkeys UI. Must be true for registration or authentication UI to activate.',
253253
format: Boolean,
254254
env: 'FEATURE_FLAGS_PASSKEYS_ENABLED',
255255
},
256+
passkeyRegistrationEnabled: {
257+
default: false,
258+
doc: 'Enables passkey registration and management UI',
259+
format: Boolean,
260+
env: 'FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED',
261+
},
262+
passkeyAuthenticationEnabled: {
263+
default: false,
264+
doc: 'Enables passkey sign-in UI',
265+
format: Boolean,
266+
env: 'FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED',
267+
},
256268
passwordlessEnabled: {
257269
default: false,
258270
doc: 'Enables auto-redirect to passwordless OTP signup for new accounts on allowed RPs',

packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ function getIndexRouteDefinition(config) {
5858
const FEATURE_FLAGS_PASSKEYS_ENABLED = config.get(
5959
'featureFlags.passkeysEnabled'
6060
);
61+
const FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED = config.get(
62+
'featureFlags.passkeyRegistrationEnabled'
63+
);
64+
const FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED = config.get(
65+
'featureFlags.passkeyAuthenticationEnabled'
66+
);
6167
const DARK_MODE_ENABLED = config.get('darkMode.enabled');
6268
const GLEAN_ENABLED = config.get('glean.enabled');
6369
const GLEAN_APPLICATION_ID = config.get('glean.applicationId');
@@ -126,6 +132,9 @@ function getIndexRouteDefinition(config) {
126132
FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN,
127133
showLocaleToggle: FEATURE_FLAGS_SHOW_LOCALE_TOGGLE,
128134
passkeysEnabled: FEATURE_FLAGS_PASSKEYS_ENABLED,
135+
passkeyRegistrationEnabled: FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED,
136+
passkeyAuthenticationEnabled:
137+
FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED,
129138
},
130139
darkMode: {
131140
enabled: DARK_MODE_ENABLED,

0 commit comments

Comments
 (0)