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
2 changes: 2 additions & 0 deletions packages/fxa-auth-server/config/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@
},
"passkeys": {
"enabled": true,
"registrationEnabled": true,
"authenticationEnabled": true,
"rpId": "localhost",
"allowedOrigins": ["http://localhost:3030"]
},
Expand Down
14 changes: 13 additions & 1 deletion packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
83 changes: 66 additions & 17 deletions packages/fxa-auth-server/lib/passkey-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
vpomerleau marked this conversation as resolved.
'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'
);
});
});
});
26 changes: 26 additions & 0 deletions packages/fxa-auth-server/lib/passkey-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 8 additions & 6 deletions packages/fxa-auth-server/lib/routes/passkeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -33,6 +33,7 @@ describe('passkeys routes', () => {
const config = {
passkeys: {
enabled: true,
registrationEnabled: true,
},
};

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

afterEach(() => {
config.passkeys.enabled = true;
jest.clearAllMocks();
Comment thread
vpomerleau marked this conversation as resolved.
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');
Expand Down
22 changes: 15 additions & 7 deletions packages/fxa-auth-server/lib/routes/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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'],
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand All @@ -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'],
Expand Down
2 changes: 2 additions & 0 deletions packages/fxa-auth-server/test/remote/passkeys.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ beforeAll(async () => {
},
passkeys: {
enabled: true,
registrationEnabled: true,
authenticationEnabled: true,
},
},
});
Expand Down
6 changes: 6 additions & 0 deletions packages/fxa-content-server/server/lib/beta-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
14 changes: 13 additions & 1 deletion packages/fxa-content-server/server/lib/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
vpomerleau marked this conversation as resolved.
},
passwordlessEnabled: {
default: false,
doc: 'Enables auto-redirect to passwordless OTP signup for new accounts on allowed RPs',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading