diff --git a/packages/fxa-auth-server/config/dev.json b/packages/fxa-auth-server/config/dev.json index 3babd26695c..889f75dd885 100644 --- a/packages/fxa-auth-server/config/dev.json +++ b/packages/fxa-auth-server/config/dev.json @@ -467,6 +467,8 @@ }, "passkeys": { "enabled": true, + "registrationEnabled": true, + "authenticationEnabled": true, "rpId": "localhost", "allowedOrigins": ["http://localhost:3030"] }, diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 971df21ce43..9d035654f22 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -2527,10 +2527,22 @@ const convictConf = convict({ passkeys: { enabled: { default: false, - doc: 'Enable passkeys authentication feature', + doc: 'Master switch for passkeys. Must be true for registrationEnabled or authenticationEnabled to take effect.', env: 'PASSKEYS__ENABLED', format: Boolean, }, + registrationEnabled: { + default: false, + doc: 'Enable passkey registration and management (add/view/delete/rename). Requires passkeys.enabled.', + env: 'PASSKEYS__REGISTRATION_ENABLED', + format: Boolean, + }, + authenticationEnabled: { + default: false, + doc: 'Enable passkey authentication (sign in with passkey). Requires passkeys.enabled.', + env: 'PASSKEYS__AUTHENTICATION_ENABLED', + format: Boolean, + }, rpId: { default: '', doc: 'WebAuthn Relying Party ID. Must match the domain of the deployment (e.g. "accounts.firefox.com"). Required when passkeys are enabled.', diff --git a/packages/fxa-auth-server/lib/passkey-utils.spec.ts b/packages/fxa-auth-server/lib/passkey-utils.spec.ts index 754c3faa0be..d285af14834 100644 --- a/packages/fxa-auth-server/lib/passkey-utils.spec.ts +++ b/packages/fxa-auth-server/lib/passkey-utils.spec.ts @@ -2,36 +2,85 @@ * 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 { isPasskeyFeatureEnabled } from './passkey-utils'; -import { AppError } from '@fxa/accounts/errors'; +import { + isPasskeyAuthenticationEnabled, + isPasskeyFeatureEnabled, + isPasskeyRegistrationEnabled, +} from './passkey-utils'; describe('passkey-utils', () => { describe('isPasskeyFeatureEnabled', () => { it('should return true when passkeys are enabled', () => { const config = { passkeys: { enabled: true } }; - const result = isPasskeyFeatureEnabled(config); - expect(result).toBe(true); + expect(isPasskeyFeatureEnabled(config)).toBe(true); }); it('should throw featureNotEnabled error when passkeys are disabled', () => { const config = { passkeys: { enabled: false } }; - try { - isPasskeyFeatureEnabled(config); - throw new Error('should have thrown an error'); - } catch (error: any) { - expect(error.errno).toBe(AppError.featureNotEnabled().errno); - expect(error.message).toBe('Feature not enabled'); - } + expect(() => isPasskeyFeatureEnabled(config)).toThrow( + 'Feature not enabled' + ); }); it('should throw featureNotEnabled error when config.passkeys.enabled is undefined', () => { const config = { passkeys: {} }; - try { - isPasskeyFeatureEnabled(config); - throw new Error('should have thrown an error'); - } catch (error: any) { - expect(error.errno).toBe(AppError.featureNotEnabled().errno); - } + expect(() => isPasskeyFeatureEnabled(config)).toThrow( + 'Feature not enabled' + ); + }); + }); + + describe('isPasskeyRegistrationEnabled', () => { + it('should return true when master and registration flags are both enabled', () => { + const config = { + passkeys: { enabled: true, registrationEnabled: true }, + }; + expect(isPasskeyRegistrationEnabled(config)).toBe(true); + }); + + it('should throw when master is enabled but registrationEnabled is false', () => { + const config = { + passkeys: { enabled: true, registrationEnabled: false }, + }; + expect(() => isPasskeyRegistrationEnabled(config)).toThrow( + 'Feature not enabled' + ); + }); + + it('should throw when master is disabled even if registrationEnabled is true', () => { + const config = { + passkeys: { enabled: false, registrationEnabled: true }, + }; + expect(() => isPasskeyRegistrationEnabled(config)).toThrow( + 'Feature not enabled' + ); + }); + }); + + describe('isPasskeyAuthenticationEnabled', () => { + it('should return true when master and authentication flags are both enabled', () => { + const config = { + passkeys: { enabled: true, authenticationEnabled: true }, + }; + expect(isPasskeyAuthenticationEnabled(config)).toBe(true); + }); + + it('should throw when master is enabled but authenticationEnabled is false', () => { + const config = { + passkeys: { enabled: true, authenticationEnabled: false }, + }; + expect(() => isPasskeyAuthenticationEnabled(config)).toThrow( + 'Feature not enabled' + ); + }); + + it('should throw when master is disabled even if authenticationEnabled is true', () => { + const config = { + passkeys: { enabled: false, authenticationEnabled: true }, + }; + expect(() => isPasskeyAuthenticationEnabled(config)).toThrow( + 'Feature not enabled' + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/passkey-utils.ts b/packages/fxa-auth-server/lib/passkey-utils.ts index 0231f14a8ef..f4ed7808acb 100644 --- a/packages/fxa-auth-server/lib/passkey-utils.ts +++ b/packages/fxa-auth-server/lib/passkey-utils.ts @@ -17,3 +17,29 @@ export function isPasskeyFeatureEnabled(config: ConfigType): boolean { } return true; } + +/** + * Checks if passkey registration (adding new passkeys) is enabled. + * Requires both the master `passkeys.enabled` flag and `passkeys.registrationEnabled`. + * Management routes (list/delete/rename) use isPasskeyFeatureEnabled instead. + * @throws AppError.featureNotEnabled if either flag is disabled + */ +export function isPasskeyRegistrationEnabled(config: ConfigType): boolean { + if (!config.passkeys.enabled || !config.passkeys.registrationEnabled) { + throw AppError.featureNotEnabled(); + } + return true; +} + +/** + * Checks if passkey authentication (sign in with passkey) is enabled. + * Requires both the master `passkeys.enabled` flag and `passkeys.authenticationEnabled`. + * @throws AppError.featureNotEnabled if either flag is disabled + * TODO FXA-13069: wire into passkey authentication routes once they are added to passkeys.ts + */ +export function isPasskeyAuthenticationEnabled(config: ConfigType): boolean { + if (!config.passkeys.enabled || !config.passkeys.authenticationEnabled) { + throw AppError.featureNotEnabled(); + } + return true; +} diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index db7f5a89112..860019ccf1d 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -6,7 +6,7 @@ import { Container } from 'typedi'; import { PasskeyService } from '@fxa/accounts/passkey'; import { AppError } from '@fxa/accounts/errors'; import { recordSecurityEvent } from './utils/security-event'; -import { isPasskeyFeatureEnabled } from '../passkey-utils'; +import { isPasskeyRegistrationEnabled } from '../passkey-utils'; import { passkeyRoutes } from './passkeys'; jest.mock('./utils/security-event', () => ({ @@ -33,6 +33,7 @@ describe('passkeys routes', () => { const config = { passkeys: { enabled: true, + registrationEnabled: true, }, }; @@ -110,16 +111,17 @@ describe('passkeys routes', () => { afterEach(() => { config.passkeys.enabled = true; - jest.clearAllMocks(); + config.passkeys.registrationEnabled = true; Container.reset(); }); - describe('isPasskeyFeatureEnabled', () => { - it('throws featureNotEnabled when passkeys.enabled is false', () => { + describe('isPasskeyRegistrationEnabled', () => { + it('throws featureNotEnabled when registrationEnabled is false', () => { expect(() => - isPasskeyFeatureEnabled({ + isPasskeyRegistrationEnabled({ passkeys: { - enabled: false, + enabled: true, + registrationEnabled: false, }, }) ).toThrow('Feature not enabled'); diff --git a/packages/fxa-auth-server/lib/routes/passkeys.ts b/packages/fxa-auth-server/lib/routes/passkeys.ts index a12754814ff..b3656b51208 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.ts @@ -8,7 +8,10 @@ import { PasskeyService } from '@fxa/accounts/passkey'; import { AuthRequest } from '../types'; import { recordSecurityEvent } from './utils/security-event'; import { ConfigType } from '../../config'; -import { isPasskeyFeatureEnabled } from '../passkey-utils'; +import { + isPasskeyFeatureEnabled, + isPasskeyRegistrationEnabled, +} from '../passkey-utils'; import { GleanMetricsType } from '../metrics/glean'; import PASSKEYS_API_DOCS from '../../docs/swagger/passkeys-api'; import { RegistrationResponseJSON } from '@simplewebauthn/server'; @@ -320,7 +323,12 @@ export const passkeyRoutes = ( glean: GleanMetricsType, log: any ) => { - const featureEnabledCheck = () => isPasskeyFeatureEnabled(config); + // Passkey route flag hierarchy: + // passkeys.enabled (master switch) — gates management routes (list/delete/rename) + // + registrationEnabled — gates registration routes + // + authenticationEnabled — gates auth routes (TODO FXA-13095) + const passkeysEnabledCheck = () => isPasskeyFeatureEnabled(config); + const registrationEnabledCheck = () => isPasskeyRegistrationEnabled(config); const service = Container.get(PasskeyService); if (!service) { @@ -336,7 +344,7 @@ export const passkeyRoutes = ( path: '/passkey/registration/start', options: { ...PASSKEYS_API_DOCS.PASSKEY_REGISTRATION_START_POST, - pre: [{ method: featureEnabledCheck }], + pre: [{ method: registrationEnabledCheck }], auth: { strategy: 'mfa', scope: ['mfa:passkey'], @@ -457,7 +465,7 @@ export const passkeyRoutes = ( path: '/passkey/registration/finish', options: { ...PASSKEYS_API_DOCS.PASSKEY_REGISTRATION_FINISH_POST, - pre: [{ method: featureEnabledCheck }], + pre: [{ method: registrationEnabledCheck }], auth: { strategy: 'mfa', scope: ['mfa:passkey'], @@ -493,7 +501,7 @@ export const passkeyRoutes = ( path: '/passkeys', options: { ...PASSKEYS_API_DOCS.PASSKEYS_GET, - pre: [{ method: featureEnabledCheck }], + pre: [{ method: passkeysEnabledCheck }], auth: { strategy: 'verifiedSessionToken', payload: false, @@ -524,7 +532,7 @@ export const passkeyRoutes = ( path: '/passkey/{credentialId}', options: { ...PASSKEYS_API_DOCS.PASSKEY_CREDENTIAL_DELETE, - pre: [{ method: featureEnabledCheck }], + pre: [{ method: passkeysEnabledCheck }], auth: { strategy: 'mfa', scope: ['mfa:passkey'], @@ -549,7 +557,7 @@ export const passkeyRoutes = ( path: '/passkey/{credentialId}', options: { ...PASSKEYS_API_DOCS.PASSKEY_CREDENTIAL_PATCH, - pre: [{ method: featureEnabledCheck }], + pre: [{ method: passkeysEnabledCheck }], auth: { strategy: 'mfa', scope: ['mfa:passkey'], diff --git a/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts b/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts index c7df641c2e7..9b7a69b9d1e 100644 --- a/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts @@ -60,6 +60,8 @@ beforeAll(async () => { }, passkeys: { enabled: true, + registrationEnabled: true, + authenticationEnabled: true, }, }, }); diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index a94ec210083..c1dc841a23d 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -122,6 +122,12 @@ const settingsConfig = { 'featureFlags.paymentsNextSubscriptionManagement' ), passkeysEnabled: config.get('featureFlags.passkeysEnabled'), + passkeyRegistrationEnabled: config.get( + 'featureFlags.passkeyRegistrationEnabled' + ), + passkeyAuthenticationEnabled: config.get( + 'featureFlags.passkeyAuthenticationEnabled' + ), passwordlessEnabled: config.get('featureFlags.passwordlessEnabled'), }, darkMode: { diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 571a81bc530..399b24091ca 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -249,10 +249,22 @@ const conf = (module.exports = convict({ }, passkeysEnabled: { default: false, - doc: 'Enables passkeys authentication', + doc: 'Master switch for passkeys UI. Must be true for registration or authentication UI to activate.', format: Boolean, env: 'FEATURE_FLAGS_PASSKEYS_ENABLED', }, + passkeyRegistrationEnabled: { + default: false, + doc: 'Enables passkey registration and management UI', + format: Boolean, + env: 'FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED', + }, + passkeyAuthenticationEnabled: { + default: false, + doc: 'Enables passkey sign-in UI', + format: Boolean, + env: 'FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED', + }, passwordlessEnabled: { default: false, doc: 'Enables auto-redirect to passwordless OTP signup for new accounts on allowed RPs', diff --git a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js index 91da1bc9c69..661bbbdf8a4 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js @@ -58,6 +58,12 @@ function getIndexRouteDefinition(config) { const FEATURE_FLAGS_PASSKEYS_ENABLED = config.get( 'featureFlags.passkeysEnabled' ); + const FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED = config.get( + 'featureFlags.passkeyRegistrationEnabled' + ); + const FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED = config.get( + 'featureFlags.passkeyAuthenticationEnabled' + ); const DARK_MODE_ENABLED = config.get('darkMode.enabled'); const GLEAN_ENABLED = config.get('glean.enabled'); const GLEAN_APPLICATION_ID = config.get('glean.applicationId'); @@ -126,6 +132,9 @@ function getIndexRouteDefinition(config) { FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN, showLocaleToggle: FEATURE_FLAGS_SHOW_LOCALE_TOGGLE, passkeysEnabled: FEATURE_FLAGS_PASSKEYS_ENABLED, + passkeyRegistrationEnabled: FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED, + passkeyAuthenticationEnabled: + FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED, }, darkMode: { enabled: DARK_MODE_ENABLED, diff --git a/packages/fxa-settings/src/lib/config.test.ts b/packages/fxa-settings/src/lib/config.test.ts index 1e399d0cd3e..3bcf11b8884 100644 --- a/packages/fxa-settings/src/lib/config.test.ts +++ b/packages/fxa-settings/src/lib/config.test.ts @@ -198,7 +198,64 @@ describe('featureFlags', () => { expect(config.featureFlags?.passkeysEnabled).toBe(false); }); - it('handles undefined passkeysEnabled flag', () => { + it('can parse passkeyRegistrationEnabled feature flag', () => { + const data = { + featureFlags: { + passkeyRegistrationEnabled: true, + }, + }; + + readConfigMeta(() => { + return { + getAttribute() { + return encodeURIComponent(JSON.stringify(data)); + }, + }; + }); + + expect(config.featureFlags).toBeDefined(); + expect(config.featureFlags?.passkeyRegistrationEnabled).toBe(true); + }); + + it('handles passkeyRegistrationEnabled as false', () => { + const data = { + featureFlags: { + passkeyRegistrationEnabled: false, + }, + }; + + readConfigMeta(() => { + return { + getAttribute() { + return encodeURIComponent(JSON.stringify(data)); + }, + }; + }); + + expect(config.featureFlags).toBeDefined(); + expect(config.featureFlags?.passkeyRegistrationEnabled).toBe(false); + }); + + it('can parse passkeyAuthenticationEnabled feature flag', () => { + const data = { + featureFlags: { + passkeyAuthenticationEnabled: true, + }, + }; + + readConfigMeta(() => { + return { + getAttribute() { + return encodeURIComponent(JSON.stringify(data)); + }, + }; + }); + + expect(config.featureFlags).toBeDefined(); + expect(config.featureFlags?.passkeyAuthenticationEnabled).toBe(true); + }); + + it('handles undefined passkey flags', () => { const data = { featureFlags: { keyStretchV2: true, @@ -215,5 +272,7 @@ describe('featureFlags', () => { expect(config.featureFlags).toBeDefined(); expect(config.featureFlags?.passkeysEnabled).toBeUndefined(); + expect(config.featureFlags?.passkeyRegistrationEnabled).toBeUndefined(); + expect(config.featureFlags?.passkeyAuthenticationEnabled).toBeUndefined(); }); }); diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index 65fd085a5fa..9d8b13041ce 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -102,6 +102,8 @@ export interface Config { showLocaleToggle?: boolean; paymentsNextSubscriptionManagement?: boolean; passkeysEnabled?: boolean; + passkeyRegistrationEnabled?: boolean; + passkeyAuthenticationEnabled?: boolean; passwordlessEnabled?: boolean; }; darkMode?: {