Skip to content

Commit 566b091

Browse files
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
1 parent 82aa181 commit 566b091

12 files changed

Lines changed: 294 additions & 20 deletions

File tree

libs/accounts/passkey/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ export * from './lib/passkey.manager';
2525
export * from './lib/passkey.repository';
2626
export * from './lib/passkey.errors';
2727
export * from './lib/passkey.config';
28+
export * from './lib/passkey.provider';
2829
export * from './lib/webauthn-adapter';

libs/accounts/passkey/src/lib/passkey.config.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import {
6+
ArrayMinSize,
67
IsArray,
7-
IsBoolean,
88
IsIn,
9+
IsNotEmpty,
910
IsNumber,
1011
IsOptional,
1112
IsString,
13+
Matches,
1214
} from 'class-validator';
1315
import type {
1416
AuthenticatorAttachment,
@@ -23,32 +25,27 @@ import type {
2325
* and passed to PasskeyService constructor.
2426
*/
2527
export class PasskeyConfig {
26-
/**
27-
* Feature flag to enable/disable passkey functionality.
28-
*/
29-
@IsBoolean()
30-
public enabled?: boolean;
31-
3228
/**
3329
* WebAuthn Relying Party ID (must match the domain).
3430
* @example 'accounts.firefox.com'
3531
*/
3632
@IsString()
33+
@IsNotEmpty()
3734
public rpId!: string;
3835

39-
/**
40-
* WebAuthn Relying Party display name.
41-
* @example 'Mozilla Accounts'
42-
*/
43-
@IsString()
44-
public rpName!: string;
45-
4636
/**
4737
* Allowed origins for WebAuthn credential creation and authentication.
4838
* Must include protocol and domain.
4939
* @example ['https://accounts.firefox.com', 'https://accounts.stage.mozaws.net']
5040
*/
5141
@IsArray()
42+
@ArrayMinSize(1)
43+
@IsString({ each: true })
44+
@Matches(/^https?:\/\/[^/]+$/, {
45+
each: true,
46+
message:
47+
'Each allowedOrigins entry must be a full origin (e.g. "https://accounts.firefox.com")',
48+
})
5249
public allowedOrigins!: Array<string>;
5350

5451
/**
@@ -71,7 +68,6 @@ export class PasskeyConfig {
7168
* - 'discouraged': User verification should not occur
7269
* @example 'required'
7370
*/
74-
@IsOptional()
7571
@IsIn(['required', 'preferred', 'discouraged'])
7672
public userVerification?: UserVerificationRequirement;
7773

@@ -85,7 +81,6 @@ export class PasskeyConfig {
8581
* - 'discouraged': Non-discoverable credential preferred
8682
* @example 'required'
8783
*/
88-
@IsOptional()
8984
@IsIn(['required', 'preferred', 'discouraged'])
9085
public residentKey?: ResidentKeyRequirement;
9186

@@ -97,5 +92,5 @@ export class PasskeyConfig {
9792
*/
9893
@IsOptional()
9994
@IsIn(['platform', 'cross-platform'])
100-
public authenticatorAttachment?: AuthenticatorAttachment;
95+
public authenticatorAttachment?: AuthenticatorAttachment | undefined;
10196
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
6+
7+
import { Test, TestingModule } from '@nestjs/testing';
8+
import { ConfigService } from '@nestjs/config';
9+
import { LOGGER_PROVIDER } from '@fxa/shared/log';
10+
import { PasskeyConfig } from './passkey.config';
11+
import { PasskeyConfigProvider, RawPasskeyConfig } from './passkey.provider';
12+
13+
const VALID_RAW_CONFIG: RawPasskeyConfig = {
14+
enabled: true,
15+
rpId: 'accounts.firefox.com',
16+
allowedOrigins: ['https://accounts.firefox.com'],
17+
challengeTimeout: 60000,
18+
maxPasskeysPerUser: 10,
19+
userVerification: 'required',
20+
residentKey: 'required',
21+
authenticatorAttachment: '',
22+
};
23+
24+
function buildModule(rawPasskeys: unknown) {
25+
const mockConfigService = {
26+
get: jest.fn().mockReturnValue(rawPasskeys),
27+
};
28+
const mockLogger = {
29+
error: jest.fn(),
30+
warn: jest.fn(),
31+
log: jest.fn(),
32+
};
33+
34+
return Test.createTestingModule({
35+
providers: [
36+
PasskeyConfigProvider,
37+
{ provide: ConfigService, useValue: mockConfigService },
38+
{ provide: LOGGER_PROVIDER, useValue: mockLogger },
39+
],
40+
})
41+
.compile()
42+
.then((module: TestingModule) => ({
43+
config: module.get(PasskeyConfig),
44+
logger: mockLogger,
45+
}));
46+
}
47+
48+
describe('PasskeyConfigProvider', () => {
49+
describe('when passkeys.enabled is false', () => {
50+
it('returns null without validation', async () => {
51+
const { config } = await buildModule({ enabled: false });
52+
expect(config).toBeNull();
53+
});
54+
});
55+
56+
describe('when config is valid', () => {
57+
it('returns a PasskeyConfig instance', async () => {
58+
const { config } = await buildModule(VALID_RAW_CONFIG);
59+
expect(config).toBeInstanceOf(PasskeyConfig);
60+
});
61+
62+
it('copies all fields correctly', async () => {
63+
const { config } = await buildModule(VALID_RAW_CONFIG);
64+
expect(config!.rpId).toBe('accounts.firefox.com');
65+
expect(config!.allowedOrigins).toEqual(['https://accounts.firefox.com']);
66+
expect(config!.challengeTimeout).toBe(60000);
67+
expect(config!.maxPasskeysPerUser).toBe(10);
68+
expect(config!.userVerification).toBe('required');
69+
expect(config!.residentKey).toBe('required');
70+
});
71+
72+
it('maps authenticatorAttachment null to undefined', async () => {
73+
const { config } = await buildModule(VALID_RAW_CONFIG);
74+
expect(config!.authenticatorAttachment).toBeUndefined();
75+
});
76+
77+
it('does not log an error', async () => {
78+
const { logger } = await buildModule(VALID_RAW_CONFIG);
79+
expect(logger.error).not.toHaveBeenCalled();
80+
});
81+
});
82+
83+
describe('when config is invalid', () => {
84+
it('returns null', async () => {
85+
const { config } = await buildModule({
86+
...VALID_RAW_CONFIG,
87+
rpId: '',
88+
allowedOrigins: ['not-a-valid-origin'],
89+
});
90+
expect(config).toBeNull();
91+
});
92+
93+
it('logs an error with the validation message', async () => {
94+
const { logger } = await buildModule({
95+
...VALID_RAW_CONFIG,
96+
allowedOrigins: ['not-a-valid-origin'],
97+
});
98+
expect(logger.error).toHaveBeenCalledWith(
99+
'passkey.config.invalid',
100+
expect.objectContaining({
101+
message: expect.stringContaining(
102+
'Passkeys disabled due to malformed config'
103+
),
104+
})
105+
);
106+
});
107+
108+
it('rejects allowedOrigins with trailing path', async () => {
109+
const { config, logger } = await buildModule({
110+
...VALID_RAW_CONFIG,
111+
allowedOrigins: ['https://accounts.firefox.com/path'],
112+
});
113+
expect(config).toBeNull();
114+
expect(logger.error).toHaveBeenCalled();
115+
});
116+
117+
it('rejects empty allowedOrigins array', async () => {
118+
const { config, logger } = await buildModule({
119+
...VALID_RAW_CONFIG,
120+
allowedOrigins: [],
121+
});
122+
expect(config).toBeNull();
123+
expect(logger.error).toHaveBeenCalled();
124+
});
125+
});
126+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { LoggerService } from '@nestjs/common';
6+
import { ConfigService } from '@nestjs/config';
7+
import { LOGGER_PROVIDER } from '@fxa/shared/log';
8+
import { PasskeyConfig } from './passkey.config';
9+
import { validateSync } from 'class-validator';
10+
import type {
11+
AuthenticatorAttachment,
12+
ResidentKeyRequirement,
13+
UserVerificationRequirement,
14+
} from '@simplewebauthn/server';
15+
16+
export type RawPasskeyConfig = {
17+
enabled: boolean;
18+
rpId: string;
19+
allowedOrigins: string[];
20+
challengeTimeout: number;
21+
maxPasskeysPerUser: number;
22+
userVerification: UserVerificationRequirement;
23+
residentKey: ResidentKeyRequirement;
24+
authenticatorAttachment: AuthenticatorAttachment | '';
25+
};
26+
27+
export function buildPasskeyConfig(
28+
raw: RawPasskeyConfig,
29+
log: LoggerService
30+
): PasskeyConfig | null {
31+
if (!raw.enabled) {
32+
return null;
33+
}
34+
35+
const mapped = {
36+
...raw,
37+
authenticatorAttachment: raw.authenticatorAttachment || undefined,
38+
};
39+
40+
const passkeyConfig = Object.assign(new PasskeyConfig(), mapped);
41+
const errors = validateSync(passkeyConfig, {
42+
skipMissingProperties: false,
43+
});
44+
if (errors.length > 0) {
45+
const message = errors.map((e) => e.toString()).join('\n');
46+
log.error('passkey.config.invalid', {
47+
message: `Passkeys disabled due to malformed config:\n${message}`,
48+
});
49+
return null;
50+
}
51+
return passkeyConfig;
52+
}
53+
54+
export const PasskeyConfigProvider = {
55+
provide: PasskeyConfig,
56+
useFactory: (config: ConfigService, log: LoggerService) => {
57+
const rawConfig = config.get('passkeys');
58+
if (!rawConfig) {
59+
log.error('passkey.config.missing', {
60+
message: 'Passkeys disabled due to missing config',
61+
});
62+
return null;
63+
}
64+
return buildPasskeyConfig(rawConfig as RawPasskeyConfig, log);
65+
},
66+
inject: [ConfigService, LOGGER_PROVIDER],
67+
};

libs/accounts/passkey/src/lib/passkey.service.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import { Test, TestingModule } from '@nestjs/testing';
66
import { LOGGER_PROVIDER } from '@fxa/shared/log';
77
import { StatsDService } from '@fxa/shared/metrics/statsd';
8+
import { PasskeyConfig } from './passkey.config';
89
import { PasskeyService } from './passkey.service';
910
import { PasskeyManager } from './passkey.manager';
1011

1112
describe('PasskeyService', () => {
1213
let service: PasskeyService;
1314
let manager: PasskeyManager;
15+
let config: PasskeyConfig;
1416

1517
const mockManager = {
1618
// Mock methods will be added as manager grows
@@ -27,18 +29,29 @@ describe('PasskeyService', () => {
2729
warn: jest.fn(),
2830
};
2931

32+
const mockConfig = Object.assign(new PasskeyConfig(), {
33+
rpId: 'accounts.firefox.com',
34+
allowedOrigins: ['https://accounts.firefox.com'],
35+
challengeTimeout: 60000,
36+
maxPasskeysPerUser: 10,
37+
userVerification: 'required',
38+
residentKey: 'required',
39+
});
40+
3041
beforeEach(async () => {
3142
const module: TestingModule = await Test.createTestingModule({
3243
providers: [
3344
PasskeyService,
3445
{ provide: PasskeyManager, useValue: mockManager },
46+
{ provide: PasskeyConfig, useValue: mockConfig },
3547
{ provide: StatsDService, useValue: mockMetrics },
3648
{ provide: LOGGER_PROVIDER, useValue: mockLogger },
3749
],
3850
}).compile();
3951

4052
service = module.get(PasskeyService);
4153
manager = module.get(PasskeyManager);
54+
config = module.get(PasskeyConfig);
4255
});
4356

4457
afterEach(() => {
@@ -53,4 +66,9 @@ describe('PasskeyService', () => {
5366
expect(manager).toBeDefined();
5467
expect(manager).toBe(mockManager);
5568
});
69+
70+
it('should inject PasskeyConfig', () => {
71+
expect(config).toBeDefined();
72+
expect(config).toBe(mockConfig);
73+
});
5674
});

libs/accounts/passkey/src/lib/passkey.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { Inject, Injectable, LoggerService } from '@nestjs/common';
66
import { LOGGER_PROVIDER } from '@fxa/shared/log';
77
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
8+
import { PasskeyConfig } from './passkey.config';
89
import { PasskeyManager } from './passkey.manager';
910

1011
/**
@@ -40,6 +41,7 @@ import { PasskeyManager } from './passkey.manager';
4041
export class PasskeyService {
4142
constructor(
4243
private readonly passkeyManager: PasskeyManager,
44+
private readonly config: PasskeyConfig,
4345
@Inject(StatsDService) private readonly metrics: StatsD,
4446
@Inject(LOGGER_PROVIDER) private readonly log?: LoggerService
4547
) {}

libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ const libMocks = jest.requireMock('@simplewebauthn/server') as {
4040
function mockConfig(overrides: Partial<PasskeyConfig> = {}): PasskeyConfig {
4141
return Object.assign(new PasskeyConfig(), {
4242
rpId: 'accounts.firefox.com',
43-
rpName: 'Mozilla Accounts',
4443
allowedOrigins: ['https://accounts.firefox.com'],
4544
userVerification: 'required',
4645
residentKey: 'preferred',
@@ -127,7 +126,7 @@ describe('generateRegistrationOptions', () => {
127126

128127
expect(libMocks.generateRegistrationOptions).toHaveBeenCalledWith(
129128
expect.objectContaining({
130-
rpName: 'Mozilla Accounts',
129+
rpName: 'accounts.firefox.com',
131130
rpID: 'accounts.firefox.com',
132131
userName: '[email protected]',
133132
userID: uid,

libs/accounts/passkey/src/lib/webauthn-adapter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export async function generateRegistrationOptions(
3939
input: RegistrationOptionsInput
4040
): Promise<PublicKeyCredentialCreationOptionsJSON> {
4141
return libGenerateRegistrationOptions({
42-
rpName: config.rpName,
42+
// rpName is deprecated field kept for backward compatibility;
43+
// spec recommends using rpId as a safe default.
44+
rpName: config.rpId,
4345
rpID: config.rpId,
4446
userName: input.email,
4547
userID: input.uid,

packages/fxa-auth-server/bin/key_server.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,19 @@ async function run(config) {
299299
);
300300
Container.set(RecoveryPhoneService, recoveryPhoneService);
301301

302+
// TODO: uncomment when we are ready to enable passkey APIs.
303+
// const passkeyConfig = buildPasskeyConfig(config.passkeys, log);
304+
// if (passkeyConfig) {
305+
// const passkeyManager = new PasskeyManager(accountDatabase);
306+
// const passkeyService = new PasskeyService(
307+
// passkeyManager,
308+
// passkeyConfig,
309+
// statsd,
310+
// log
311+
// );
312+
// Container.set(PasskeyService, passkeyService);
313+
// }
314+
302315
const profile = new ProfileClient(log, statsd, {
303316
...config.profileServer,
304317
serviceName: 'subhub',

0 commit comments

Comments
 (0)