Skip to content
Open
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
1 change: 1 addition & 0 deletions libs/accounts/passkey/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
20 changes: 14 additions & 6 deletions libs/accounts/passkey/src/lib/passkey.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,13 +36,15 @@ export class PasskeyConfig {
* @example 'accounts.firefox.com'
*/
@IsString()
@IsNotEmpty()
public rpId!: string;

/**
* WebAuthn Relying Party display name.
* @example 'Mozilla Accounts'
*/
@IsString()
@IsNotEmpty()
public rpName!: string;

/**
Expand All @@ -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<string>;

/**
Expand All @@ -71,7 +82,6 @@ export class PasskeyConfig {
* - 'discouraged': User verification should not occur
* @example 'required'
*/
@IsOptional()
@IsIn(['required', 'preferred', 'discouraged'])
public userVerification?: UserVerificationRequirement;

Expand All @@ -85,7 +95,6 @@ export class PasskeyConfig {
* - 'discouraged': Non-discoverable credential preferred
* @example 'required'
*/
@IsOptional()
@IsIn(['required', 'preferred', 'discouraged'])
public residentKey?: ResidentKeyRequirement;

Expand All @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the | undefined here for convict compatibility?

Sometimes side stepping the optional vs null vs undefined issue and just using an empty string or explicit value ends up being simpler. eg @IsIn(['platform', 'cross-platform', '']) or @IsIn(['platform', 'cross-platform', 'none']).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthenticatorAttachment | undefined is the type that simplewebauthn/server (the webauthn lib we depend on) takes.

}
131 changes: 131 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
72 changes: 72 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.provider.ts
Original file line number Diff line number Diff line change
@@ -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<LoggerService, 'error'>
): 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],
};
20 changes: 20 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,18 +29,31 @@ 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 },
],
}).compile();

service = module.get(PasskeyService);
manager = module.get(PasskeyManager);
config = module.get(PasskeyConfig);
});

afterEach(() => {
Expand All @@ -53,4 +68,9 @@ describe('PasskeyService', () => {
expect(manager).toBeDefined();
expect(manager).toBe(mockManager);
});

it('should inject PasskeyConfig', () => {
expect(config).toBeDefined();
expect(config).toBe(mockConfig);
});
});
2 changes: 2 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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
) {}
Expand Down
15 changes: 15 additions & 0 deletions packages/fxa-auth-server/bin/key_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -299,6 +304,16 @@ async function run(config) {
);
Container.set(RecoveryPhoneService, recoveryPhoneService);

const passkeyConfig = buildPasskeyConfig(config.passkeys, log);
Copy link
Contributor Author

@MagentaManifold MagentaManifold Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added these just to demonstrate how it will be used in auth-server. Remove before merging

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',
Expand Down
5 changes: 5 additions & 0 deletions packages/fxa-auth-server/config/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -464,5 +464,10 @@
"subscriptionAccountReminders": {
"firstInterval": "5s",
"secondInterval": "10s"
},
"passkeys": {
"enabled": true,
"rpId": "localhost",
"allowedOrigins": ["http://localhost:3030"]
}
}
Loading