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
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';
29 changes: 12 additions & 17 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 @@ -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<string>;

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

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

Expand All @@ -97,5 +92,5 @@ export class PasskeyConfig {
*/
@IsOptional()
@IsIn(['platform', 'cross-platform'])
public authenticatorAttachment?: AuthenticatorAttachment;
public authenticatorAttachment?: AuthenticatorAttachment | undefined;
Copy link
Copy Markdown
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
Copy Markdown
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.

}
126 changes: 126 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,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();
});
});
});
67 changes: 67 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.provider.ts
Original file line number Diff line number Diff line change
@@ -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],
};
18 changes: 18 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,29 @@ 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 },
],
}).compile();

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

afterEach(() => {
Expand All @@ -53,4 +66,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
3 changes: 1 addition & 2 deletions libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ const libMocks = jest.requireMock('@simplewebauthn/server') as {
function mockConfig(overrides: Partial<PasskeyConfig> = {}): PasskeyConfig {
return Object.assign(new PasskeyConfig(), {
rpId: 'accounts.firefox.com',
rpName: 'Mozilla Accounts',
allowedOrigins: ['https://accounts.firefox.com'],
userVerification: 'required',
residentKey: 'preferred',
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion libs/accounts/passkey/src/lib/webauthn-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export async function generateRegistrationOptions(
input: RegistrationOptionsInput
): Promise<PublicKeyCredentialCreationOptionsJSON> {
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,
Expand Down
13 changes: 13 additions & 0 deletions packages/fxa-auth-server/bin/key_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading