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..b0b1bf01de3 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, @@ -34,6 +36,7 @@ export class PasskeyConfig { * @example 'accounts.firefox.com' */ @IsString() + @IsNotEmpty() public rpId!: string; /** @@ -41,6 +44,7 @@ export class PasskeyConfig { * @example 'Mozilla Accounts' */ @IsString() + @IsNotEmpty() public rpName!: string; /** @@ -49,6 +53,13 @@ export class PasskeyConfig { * @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 +82,6 @@ export class PasskeyConfig { * - 'discouraged': User verification should not occur * @example 'required' */ - @IsOptional() @IsIn(['required', 'preferred', 'discouraged']) public userVerification?: UserVerificationRequirement; @@ -85,7 +95,6 @@ export class PasskeyConfig { * - 'discouraged': Non-discoverable credential preferred * @example 'required' */ - @IsOptional() @IsIn(['required', 'preferred', 'discouraged']) public residentKey?: ResidentKeyRequirement; @@ -95,7 +104,6 @@ export class PasskeyConfig { * - 'cross-platform': Roaming authenticators (USB security keys) * - undefined: No preference (allow any) */ - @IsOptional() - @IsIn(['platform', 'cross-platform']) - public authenticatorAttachment?: AuthenticatorAttachment; + @IsIn(['platform', 'cross-platform', undefined]) + 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..4e1b6dfa93c --- /dev/null +++ b/libs/accounts/passkey/src/lib/passkey.provider.spec.ts @@ -0,0 +1,131 @@ +/* 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 { 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', + rpName: '', + allowedOrigins: ['https://accounts.firefox.com'], + challengeTimeout: 60000, + maxPasskeysPerUser: 10, + userVerification: 'required', + residentKey: 'required', + authenticatorAttachment: null, +}; + +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 { enabled: false } without validation', async () => { + const { config } = await buildModule({ enabled: false }); + expect(config.enabled).toBe(false); + }); + }); + + 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.enabled).toBe(true); + 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 rpName null to rpId', async () => { + const { config } = await buildModule(VALID_RAW_CONFIG); + expect(config.rpName).toBe('accounts.firefox.com'); + }); + + 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 { enabled: false }', async () => { + const { config } = await buildModule({ + ...VALID_RAW_CONFIG, + rpId: '', // fails @IsString() non-empty check + allowedOrigins: ['not-a-valid-origin'], + }); + expect(config.enabled).toBe(false); + }); + + 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.enabled).toBe(false); + expect(logger.error).toHaveBeenCalled(); + }); + + it('rejects empty allowedOrigins array', async () => { + const { config, logger } = await buildModule({ + ...VALID_RAW_CONFIG, + allowedOrigins: [], + }); + expect(config.enabled).toBe(false); + 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..168c84d5183 --- /dev/null +++ b/libs/accounts/passkey/src/lib/passkey.provider.ts @@ -0,0 +1,72 @@ +/* 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; + rpName: string; + allowedOrigins: string[]; + challengeTimeout: number; + maxPasskeysPerUser: number; + userVerification: UserVerificationRequirement; + residentKey: ResidentKeyRequirement; + authenticatorAttachment: AuthenticatorAttachment | null; +}; + +export function buildPasskeyConfig( + raw: RawPasskeyConfig, + log: Pick +): PasskeyConfig { + if (raw.enabled === false) { + return { enabled: false } as PasskeyConfig; + } + + const mapped = { + ...raw, + rpName: raw.rpName || raw.rpId, + authenticatorAttachment: + raw.authenticatorAttachment === null + ? undefined + : raw.authenticatorAttachment, + }; + + 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 { enabled: false } as PasskeyConfig; + } + 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 { enabled: false } as PasskeyConfig; + } + 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..6d97e4bdaaa 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,23 @@ describe('PasskeyService', () => { warn: jest.fn(), }; + const mockConfig = Object.assign(new PasskeyConfig(), { + enabled: true, + rpId: 'accounts.firefox.com', + rpName: '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 +53,7 @@ describe('PasskeyService', () => { service = module.get(PasskeyService); manager = module.get(PasskeyManager); + config = module.get(PasskeyConfig); }); afterEach(() => { @@ -53,4 +68,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/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 34868f190d2..4be2e808bcd 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -52,6 +52,11 @@ const { TwilioFactory, } = require('@fxa/accounts/recovery-phone'); const { parseConfigRules, RateLimit } = require('@fxa/accounts/rate-limit'); +const { + PasskeyService, + PasskeyManager, + buildPasskeyConfig, +} = require('@fxa/accounts/passkey'); const { RelyingPartyConfigurationManager, LegalTermsConfigurationManager, @@ -299,6 +304,16 @@ async function run(config) { ); Container.set(RecoveryPhoneService, recoveryPhoneService); + const passkeyConfig = buildPasskeyConfig(config.passkeys, log); + 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..5ba2d90db9e 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -2531,6 +2531,54 @@ 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, + }, + rpName: { + default: '', + doc: 'WebAuthn Relying Party Name. rpName is a deprecated field kept for backward compatibility, usually ignored by clients. Leave this field null to use the same value as rpId, which is recommended by the spec as a safe default.', + env: 'PASSKEYS__RP_NAME', + 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: null, + doc: 'Optional authenticator attachment preference. One of "platform" (device-bound) or "cross-platform" (roaming key)', + env: 'PASSKEYS__AUTHENTICATOR_ATTACHMENT', + format: ['platform', 'cross-platform', null], + }, }, twilio: { credentialMode: {