From 566b091357849fd50529bd9a43516cdf5167d000 Mon Sep 17 00:00:00 2001 From: MagentaManifold <17zhaomingyuan@gmail.com> Date: Mon, 9 Mar 2026 15:05:33 -0400 Subject: [PATCH] feat(passkeys): add auth server passkey configs Because: * we need to load passkey configs to auth server This commit: * defines convict passkey configs Closes FXA-13057 --- libs/accounts/passkey/src/index.ts | 1 + .../passkey/src/lib/passkey.config.ts | 29 ++-- .../passkey/src/lib/passkey.provider.spec.ts | 126 ++++++++++++++++++ .../passkey/src/lib/passkey.provider.ts | 67 ++++++++++ .../passkey/src/lib/passkey.service.spec.ts | 18 +++ .../passkey/src/lib/passkey.service.ts | 2 + .../passkey/src/lib/webauthn-adapter.spec.ts | 3 +- .../passkey/src/lib/webauthn-adapter.ts | 4 +- packages/fxa-auth-server/bin/key_server.js | 13 ++ packages/fxa-auth-server/config/dev.json | 5 + packages/fxa-auth-server/config/index.ts | 42 ++++++ packages/fxa-auth-server/lib/passkey-utils.ts | 4 + 12 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 libs/accounts/passkey/src/lib/passkey.provider.spec.ts create mode 100644 libs/accounts/passkey/src/lib/passkey.provider.ts diff --git a/libs/accounts/passkey/src/index.ts b/libs/accounts/passkey/src/index.ts index 2e601b32c35..85a55e3a553 100644 --- a/libs/accounts/passkey/src/index.ts +++ b/libs/accounts/passkey/src/index.ts @@ -25,4 +25,5 @@ export * from './lib/passkey.manager'; export * from './lib/passkey.repository'; export * from './lib/passkey.errors'; export * from './lib/passkey.config'; +export * from './lib/passkey.provider'; export * from './lib/webauthn-adapter'; diff --git a/libs/accounts/passkey/src/lib/passkey.config.ts b/libs/accounts/passkey/src/lib/passkey.config.ts index 5e14502038c..8fe6e42f794 100644 --- a/libs/accounts/passkey/src/lib/passkey.config.ts +++ b/libs/accounts/passkey/src/lib/passkey.config.ts @@ -3,12 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { + ArrayMinSize, IsArray, - IsBoolean, IsIn, + IsNotEmpty, IsNumber, IsOptional, IsString, + Matches, } from 'class-validator'; import type { AuthenticatorAttachment, @@ -23,32 +25,27 @@ import type { * and passed to PasskeyService constructor. */ export class PasskeyConfig { - /** - * Feature flag to enable/disable passkey functionality. - */ - @IsBoolean() - public enabled?: boolean; - /** * WebAuthn Relying Party ID (must match the domain). * @example 'accounts.firefox.com' */ @IsString() + @IsNotEmpty() public rpId!: string; - /** - * WebAuthn Relying Party display name. - * @example 'Mozilla Accounts' - */ - @IsString() - public rpName!: string; - /** * Allowed origins for WebAuthn credential creation and authentication. * Must include protocol and domain. * @example ['https://accounts.firefox.com', 'https://accounts.stage.mozaws.net'] */ @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + @Matches(/^https?:\/\/[^/]+$/, { + each: true, + message: + 'Each allowedOrigins entry must be a full origin (e.g. "https://accounts.firefox.com")', + }) public allowedOrigins!: Array; /** @@ -71,7 +68,6 @@ export class PasskeyConfig { * - 'discouraged': User verification should not occur * @example 'required' */ - @IsOptional() @IsIn(['required', 'preferred', 'discouraged']) public userVerification?: UserVerificationRequirement; @@ -85,7 +81,6 @@ export class PasskeyConfig { * - 'discouraged': Non-discoverable credential preferred * @example 'required' */ - @IsOptional() @IsIn(['required', 'preferred', 'discouraged']) public residentKey?: ResidentKeyRequirement; @@ -97,5 +92,5 @@ export class PasskeyConfig { */ @IsOptional() @IsIn(['platform', 'cross-platform']) - public authenticatorAttachment?: AuthenticatorAttachment; + public authenticatorAttachment?: AuthenticatorAttachment | undefined; } diff --git a/libs/accounts/passkey/src/lib/passkey.provider.spec.ts b/libs/accounts/passkey/src/lib/passkey.provider.spec.ts new file mode 100644 index 00000000000..6c2e0896255 --- /dev/null +++ b/libs/accounts/passkey/src/lib/passkey.provider.spec.ts @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { LOGGER_PROVIDER } from '@fxa/shared/log'; +import { PasskeyConfig } from './passkey.config'; +import { PasskeyConfigProvider, RawPasskeyConfig } from './passkey.provider'; + +const VALID_RAW_CONFIG: RawPasskeyConfig = { + enabled: true, + rpId: 'accounts.firefox.com', + allowedOrigins: ['https://accounts.firefox.com'], + challengeTimeout: 60000, + maxPasskeysPerUser: 10, + userVerification: 'required', + residentKey: 'required', + authenticatorAttachment: '', +}; + +function buildModule(rawPasskeys: unknown) { + const mockConfigService = { + get: jest.fn().mockReturnValue(rawPasskeys), + }; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), + }; + + return Test.createTestingModule({ + providers: [ + PasskeyConfigProvider, + { provide: ConfigService, useValue: mockConfigService }, + { provide: LOGGER_PROVIDER, useValue: mockLogger }, + ], + }) + .compile() + .then((module: TestingModule) => ({ + config: module.get(PasskeyConfig), + logger: mockLogger, + })); +} + +describe('PasskeyConfigProvider', () => { + describe('when passkeys.enabled is false', () => { + it('returns null without validation', async () => { + const { config } = await buildModule({ enabled: false }); + expect(config).toBeNull(); + }); + }); + + describe('when config is valid', () => { + it('returns a PasskeyConfig instance', async () => { + const { config } = await buildModule(VALID_RAW_CONFIG); + expect(config).toBeInstanceOf(PasskeyConfig); + }); + + it('copies all fields correctly', async () => { + const { config } = await buildModule(VALID_RAW_CONFIG); + expect(config!.rpId).toBe('accounts.firefox.com'); + expect(config!.allowedOrigins).toEqual(['https://accounts.firefox.com']); + expect(config!.challengeTimeout).toBe(60000); + expect(config!.maxPasskeysPerUser).toBe(10); + expect(config!.userVerification).toBe('required'); + expect(config!.residentKey).toBe('required'); + }); + + it('maps authenticatorAttachment null to undefined', async () => { + const { config } = await buildModule(VALID_RAW_CONFIG); + expect(config!.authenticatorAttachment).toBeUndefined(); + }); + + it('does not log an error', async () => { + const { logger } = await buildModule(VALID_RAW_CONFIG); + expect(logger.error).not.toHaveBeenCalled(); + }); + }); + + describe('when config is invalid', () => { + it('returns null', async () => { + const { config } = await buildModule({ + ...VALID_RAW_CONFIG, + rpId: '', + allowedOrigins: ['not-a-valid-origin'], + }); + expect(config).toBeNull(); + }); + + it('logs an error with the validation message', async () => { + const { logger } = await buildModule({ + ...VALID_RAW_CONFIG, + allowedOrigins: ['not-a-valid-origin'], + }); + expect(logger.error).toHaveBeenCalledWith( + 'passkey.config.invalid', + expect.objectContaining({ + message: expect.stringContaining( + 'Passkeys disabled due to malformed config' + ), + }) + ); + }); + + it('rejects allowedOrigins with trailing path', async () => { + const { config, logger } = await buildModule({ + ...VALID_RAW_CONFIG, + allowedOrigins: ['https://accounts.firefox.com/path'], + }); + expect(config).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('rejects empty allowedOrigins array', async () => { + const { config, logger } = await buildModule({ + ...VALID_RAW_CONFIG, + allowedOrigins: [], + }); + expect(config).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/accounts/passkey/src/lib/passkey.provider.ts b/libs/accounts/passkey/src/lib/passkey.provider.ts new file mode 100644 index 00000000000..7eb9d14185d --- /dev/null +++ b/libs/accounts/passkey/src/lib/passkey.provider.ts @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { LoggerService } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { LOGGER_PROVIDER } from '@fxa/shared/log'; +import { PasskeyConfig } from './passkey.config'; +import { validateSync } from 'class-validator'; +import type { + AuthenticatorAttachment, + ResidentKeyRequirement, + UserVerificationRequirement, +} from '@simplewebauthn/server'; + +export type RawPasskeyConfig = { + enabled: boolean; + rpId: string; + allowedOrigins: string[]; + challengeTimeout: number; + maxPasskeysPerUser: number; + userVerification: UserVerificationRequirement; + residentKey: ResidentKeyRequirement; + authenticatorAttachment: AuthenticatorAttachment | ''; +}; + +export function buildPasskeyConfig( + raw: RawPasskeyConfig, + log: LoggerService +): PasskeyConfig | null { + if (!raw.enabled) { + return null; + } + + const mapped = { + ...raw, + authenticatorAttachment: raw.authenticatorAttachment || undefined, + }; + + const passkeyConfig = Object.assign(new PasskeyConfig(), mapped); + const errors = validateSync(passkeyConfig, { + skipMissingProperties: false, + }); + if (errors.length > 0) { + const message = errors.map((e) => e.toString()).join('\n'); + log.error('passkey.config.invalid', { + message: `Passkeys disabled due to malformed config:\n${message}`, + }); + return null; + } + return passkeyConfig; +} + +export const PasskeyConfigProvider = { + provide: PasskeyConfig, + useFactory: (config: ConfigService, log: LoggerService) => { + const rawConfig = config.get('passkeys'); + if (!rawConfig) { + log.error('passkey.config.missing', { + message: 'Passkeys disabled due to missing config', + }); + return null; + } + return buildPasskeyConfig(rawConfig as RawPasskeyConfig, log); + }, + inject: [ConfigService, LOGGER_PROVIDER], +}; diff --git a/libs/accounts/passkey/src/lib/passkey.service.spec.ts b/libs/accounts/passkey/src/lib/passkey.service.spec.ts index d621caf5581..d2170cfa55e 100644 --- a/libs/accounts/passkey/src/lib/passkey.service.spec.ts +++ b/libs/accounts/passkey/src/lib/passkey.service.spec.ts @@ -5,12 +5,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { StatsDService } from '@fxa/shared/metrics/statsd'; +import { PasskeyConfig } from './passkey.config'; import { PasskeyService } from './passkey.service'; import { PasskeyManager } from './passkey.manager'; describe('PasskeyService', () => { let service: PasskeyService; let manager: PasskeyManager; + let config: PasskeyConfig; const mockManager = { // Mock methods will be added as manager grows @@ -27,11 +29,21 @@ describe('PasskeyService', () => { warn: jest.fn(), }; + const mockConfig = Object.assign(new PasskeyConfig(), { + rpId: 'accounts.firefox.com', + allowedOrigins: ['https://accounts.firefox.com'], + challengeTimeout: 60000, + maxPasskeysPerUser: 10, + userVerification: 'required', + residentKey: 'required', + }); + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PasskeyService, { provide: PasskeyManager, useValue: mockManager }, + { provide: PasskeyConfig, useValue: mockConfig }, { provide: StatsDService, useValue: mockMetrics }, { provide: LOGGER_PROVIDER, useValue: mockLogger }, ], @@ -39,6 +51,7 @@ describe('PasskeyService', () => { service = module.get(PasskeyService); manager = module.get(PasskeyManager); + config = module.get(PasskeyConfig); }); afterEach(() => { @@ -53,4 +66,9 @@ describe('PasskeyService', () => { expect(manager).toBeDefined(); expect(manager).toBe(mockManager); }); + + it('should inject PasskeyConfig', () => { + expect(config).toBeDefined(); + expect(config).toBe(mockConfig); + }); }); diff --git a/libs/accounts/passkey/src/lib/passkey.service.ts b/libs/accounts/passkey/src/lib/passkey.service.ts index 8dba738bfb8..f7324c36701 100644 --- a/libs/accounts/passkey/src/lib/passkey.service.ts +++ b/libs/accounts/passkey/src/lib/passkey.service.ts @@ -5,6 +5,7 @@ import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd'; +import { PasskeyConfig } from './passkey.config'; import { PasskeyManager } from './passkey.manager'; /** @@ -40,6 +41,7 @@ import { PasskeyManager } from './passkey.manager'; export class PasskeyService { constructor( private readonly passkeyManager: PasskeyManager, + private readonly config: PasskeyConfig, @Inject(StatsDService) private readonly metrics: StatsD, @Inject(LOGGER_PROVIDER) private readonly log?: LoggerService ) {} diff --git a/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts b/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts index 1e3bff3c712..93927209086 100644 --- a/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts +++ b/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts @@ -40,7 +40,6 @@ const libMocks = jest.requireMock('@simplewebauthn/server') as { function mockConfig(overrides: Partial = {}): PasskeyConfig { return Object.assign(new PasskeyConfig(), { rpId: 'accounts.firefox.com', - rpName: 'Mozilla Accounts', allowedOrigins: ['https://accounts.firefox.com'], userVerification: 'required', residentKey: 'preferred', @@ -127,7 +126,7 @@ describe('generateRegistrationOptions', () => { expect(libMocks.generateRegistrationOptions).toHaveBeenCalledWith( expect.objectContaining({ - rpName: 'Mozilla Accounts', + rpName: 'accounts.firefox.com', rpID: 'accounts.firefox.com', userName: 'alice@example.com', userID: uid, diff --git a/libs/accounts/passkey/src/lib/webauthn-adapter.ts b/libs/accounts/passkey/src/lib/webauthn-adapter.ts index 8cd504c74dc..ab60d0e4ee7 100644 --- a/libs/accounts/passkey/src/lib/webauthn-adapter.ts +++ b/libs/accounts/passkey/src/lib/webauthn-adapter.ts @@ -39,7 +39,9 @@ export async function generateRegistrationOptions( input: RegistrationOptionsInput ): Promise { return libGenerateRegistrationOptions({ - rpName: config.rpName, + // rpName is deprecated field kept for backward compatibility; + // spec recommends using rpId as a safe default. + rpName: config.rpId, rpID: config.rpId, userName: input.email, userID: input.uid, diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 34868f190d2..32debeb6a0d 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -299,6 +299,19 @@ async function run(config) { ); Container.set(RecoveryPhoneService, recoveryPhoneService); + // TODO: uncomment when we are ready to enable passkey APIs. + // const passkeyConfig = buildPasskeyConfig(config.passkeys, log); + // if (passkeyConfig) { + // const passkeyManager = new PasskeyManager(accountDatabase); + // const passkeyService = new PasskeyService( + // passkeyManager, + // passkeyConfig, + // statsd, + // log + // ); + // Container.set(PasskeyService, passkeyService); + // } + const profile = new ProfileClient(log, statsd, { ...config.profileServer, serviceName: 'subhub', diff --git a/packages/fxa-auth-server/config/dev.json b/packages/fxa-auth-server/config/dev.json index f175e4bfe7e..3858bd20450 100644 --- a/packages/fxa-auth-server/config/dev.json +++ b/packages/fxa-auth-server/config/dev.json @@ -464,5 +464,10 @@ "subscriptionAccountReminders": { "firstInterval": "5s", "secondInterval": "10s" + }, + "passkeys": { + "enabled": 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 3e254792b71..a7e6458c9c5 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -2531,6 +2531,48 @@ const convictConf = convict({ env: 'PASSKEYS__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.', + env: 'PASSKEYS__RP_ID', + format: String, + }, + allowedOrigins: { + default: [], + doc: 'List of allowed origins for WebAuthn registration and authentication ceremonies. Must be full origins (scheme + host + optional port), e.g. ["https://accounts.firefox.com"]. Must have at least one entry when passkeys are enabled.', + env: 'PASSKEYS__ALLOWED_ORIGINS', + format: Array, + }, + challengeTimeout: { + default: 300000, + doc: 'Time in milliseconds before a WebAuthn challenge expires. Defaults to 300000 ms (5 minutes).', + env: 'PASSKEYS__CHALLENGE_TIMEOUT', + format: Number, + }, + maxPasskeysPerUser: { + default: 10, + doc: 'Maximum number of passkeys a single user account may register.', + env: 'PASSKEYS__MAX_PASSKEYS_PER_USER', + format: Number, + }, + userVerification: { + default: 'required', + doc: 'WebAuthn user-verification requirement for ceremonies. One of "required", "preferred", or "discouraged". May be relaxed to "preferred" depending on UX requirements.', + env: 'PASSKEYS__USER_VERIFICATION', + format: ['required', 'preferred', 'discouraged'], + }, + residentKey: { + default: 'required', + doc: 'WebAuthn resident-key (discoverable credential) requirement. One of "required", "preferred", or "discouraged". Discoverable credential flow won\'t work if not set to "required".', + env: 'PASSKEYS__RESIDENT_KEY', + format: ['required', 'preferred', 'discouraged'], + }, + authenticatorAttachment: { + default: '', + doc: 'Optional authenticator attachment preference. One of "platform" (device-bound) or "cross-platform" (roaming key)', + env: 'PASSKEYS__AUTHENTICATOR_ATTACHMENT', + format: ['platform', 'cross-platform', ''], + }, }, twilio: { credentialMode: { diff --git a/packages/fxa-auth-server/lib/passkey-utils.ts b/packages/fxa-auth-server/lib/passkey-utils.ts index c3bd21cb26f..4b08e664861 100644 --- a/packages/fxa-auth-server/lib/passkey-utils.ts +++ b/packages/fxa-auth-server/lib/passkey-utils.ts @@ -6,6 +6,10 @@ import { ConfigType } from '../config'; import { AppError } from '@fxa/accounts/errors'; /** + * FIXME: This function needs to be reworked to check for the existence of + * PasskeyService instead, since it is possible for the passkey feature to be + * disabled due to invalid configuration, even if the "enabled" flag is set to + * true. (See FXA-13069) * Checks if the passkey feature is enabled in the configuration * @param config - The application configuration object * @returns true if the passkey feature is enabled