Skip to content

Commit ac54c39

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 f4c4f28 commit ac54c39

File tree

9 files changed

+308
-6
lines changed

9 files changed

+308
-6
lines changed

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: 14 additions & 6 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,
78
IsBoolean,
89
IsIn,
10+
IsNotEmpty,
911
IsNumber,
10-
IsOptional,
1112
IsString,
13+
Matches,
1214
} from 'class-validator';
1315
import type {
1416
AuthenticatorAttachment,
@@ -34,13 +36,15 @@ export class PasskeyConfig {
3436
* @example 'accounts.firefox.com'
3537
*/
3638
@IsString()
39+
@IsNotEmpty()
3740
public rpId!: string;
3841

3942
/**
4043
* WebAuthn Relying Party display name.
4144
* @example 'Mozilla Accounts'
4245
*/
4346
@IsString()
47+
@IsNotEmpty()
4448
public rpName!: string;
4549

4650
/**
@@ -49,6 +53,13 @@ export class PasskeyConfig {
4953
* @example ['https://accounts.firefox.com', 'https://accounts.stage.mozaws.net']
5054
*/
5155
@IsArray()
56+
@ArrayMinSize(1)
57+
@IsString({ each: true })
58+
@Matches(/^https?:\/\/[^/]+$/, {
59+
each: true,
60+
message:
61+
'Each allowedOrigins entry must be a full origin (e.g. "https://accounts.firefox.com")',
62+
})
5263
public allowedOrigins!: Array<string>;
5364

5465
/**
@@ -71,7 +82,6 @@ export class PasskeyConfig {
7182
* - 'discouraged': User verification should not occur
7283
* @example 'required'
7384
*/
74-
@IsOptional()
7585
@IsIn(['required', 'preferred', 'discouraged'])
7686
public userVerification?: UserVerificationRequirement;
7787

@@ -85,7 +95,6 @@ export class PasskeyConfig {
8595
* - 'discouraged': Non-discoverable credential preferred
8696
* @example 'required'
8797
*/
88-
@IsOptional()
8998
@IsIn(['required', 'preferred', 'discouraged'])
9099
public residentKey?: ResidentKeyRequirement;
91100

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

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

Lines changed: 20 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,31 @@ describe('PasskeyService', () => {
2729
warn: jest.fn(),
2830
};
2931

32+
const mockConfig = Object.assign(new PasskeyConfig(), {
33+
enabled: true,
34+
rpId: 'accounts.firefox.com',
35+
rpName: 'accounts.firefox.com',
36+
allowedOrigins: ['https://accounts.firefox.com'],
37+
challengeTimeout: 60000,
38+
maxPasskeysPerUser: 10,
39+
userVerification: 'required',
40+
residentKey: 'required',
41+
});
42+
3043
beforeEach(async () => {
3144
const module: TestingModule = await Test.createTestingModule({
3245
providers: [
3346
PasskeyService,
3447
{ provide: PasskeyManager, useValue: mockManager },
48+
{ provide: PasskeyConfig, useValue: mockConfig },
3549
{ provide: StatsDService, useValue: mockMetrics },
3650
{ provide: LOGGER_PROVIDER, useValue: mockLogger },
3751
],
3852
}).compile();
3953

4054
service = module.get(PasskeyService);
4155
manager = module.get(PasskeyManager);
56+
config = module.get(PasskeyConfig);
4257
});
4358

4459
afterEach(() => {
@@ -53,4 +68,9 @@ describe('PasskeyService', () => {
5368
expect(manager).toBeDefined();
5469
expect(manager).toBe(mockManager);
5570
});
71+
72+
it('should inject PasskeyConfig', () => {
73+
expect(config).toBeDefined();
74+
expect(config).toBe(mockConfig);
75+
});
5676
});

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
) {}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ const {
5252
TwilioFactory,
5353
} = require('@fxa/accounts/recovery-phone');
5454
const { parseConfigRules, RateLimit } = require('@fxa/accounts/rate-limit');
55+
const {
56+
PasskeyService,
57+
PasskeyManager,
58+
buildPasskeyConfig,
59+
} = require('@fxa/accounts/passkey');
5560
const {
5661
RelyingPartyConfigurationManager,
5762
LegalTermsConfigurationManager,
@@ -299,6 +304,16 @@ async function run(config) {
299304
);
300305
Container.set(RecoveryPhoneService, recoveryPhoneService);
301306

307+
const passkeyConfig = buildPasskeyConfig(config.passkeys, log);
308+
const passkeyManager = new PasskeyManager(accountDatabase);
309+
const passkeyService = new PasskeyService(
310+
passkeyManager,
311+
passkeyConfig,
312+
statsd,
313+
log
314+
);
315+
Container.set(PasskeyService, passkeyService);
316+
302317
const profile = new ProfileClient(log, statsd, {
303318
...config.profileServer,
304319
serviceName: 'subhub',

packages/fxa-auth-server/config/dev.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,5 +464,10 @@
464464
"subscriptionAccountReminders": {
465465
"firstInterval": "5s",
466466
"secondInterval": "10s"
467+
},
468+
"passkeys": {
469+
"enabled": true,
470+
"rpId": "localhost",
471+
"allowedOrigins": ["http://localhost:3030"]
467472
}
468473
}

0 commit comments

Comments
 (0)