Skip to content

Commit d5e312b

Browse files
authored
Merge pull request #20230 from mozilla/worktree-FXA-13069
feat(auth): Add passkey registration REST endpoints (FXA-13069)
2 parents 32c3d87 + 74248b6 commit d5e312b

20 files changed

+1132
-96
lines changed

libs/accounts/passkey/src/lib/passkey.challenge.manager.in.spec.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@ async function clearChallengeKeys() {
5050
beforeAll(async () => {
5151
redis = new Redis({ host: 'localhost' });
5252

53-
config = new PasskeyConfig();
54-
config.rpId = 'localhost';
55-
config.allowedOrigins = ['http://localhost'];
56-
config.challengeTimeout = 1000 * 60 * 5; // 5 minutes
53+
config = new PasskeyConfig({
54+
enabled: true,
55+
rpId: 'localhost',
56+
allowedOrigins: ['http://localhost'],
57+
maxPasskeysPerUser: 10,
58+
challengeTimeout: 1000 * 60 * 5,
59+
});
5760

5861
const moduleRef = await buildTestModule(redis, config, mockLogger);
5962

@@ -136,10 +139,13 @@ describe('PasskeyChallengeManager (integration)', () => {
136139

137140
describe('TTL expiry', () => {
138141
it('challenge is gone from Redis after the TTL elapses', async () => {
139-
const shortConfig = new PasskeyConfig();
140-
shortConfig.rpId = 'localhost';
141-
shortConfig.allowedOrigins = ['http://localhost'];
142-
shortConfig.challengeTimeout = 1000;
142+
const shortConfig = new PasskeyConfig({
143+
enabled: true,
144+
rpId: 'localhost',
145+
allowedOrigins: ['http://localhost'],
146+
maxPasskeysPerUser: 10,
147+
challengeTimeout: 1000,
148+
});
143149

144150
const moduleRef = await buildTestModule(redis, shortConfig, mockLogger);
145151

libs/accounts/passkey/src/lib/passkey.challenge.manager.spec.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ const MOCK_CHALLENGE = Buffer.alloc(32, 0xab).toString('base64url');
2828
const CHALLENGE_TIMEOUT_MS = 1000 * 60 * 5;
2929

3030
function makeConfig(overrides: Partial<PasskeyConfig> = {}): PasskeyConfig {
31-
const config = new PasskeyConfig();
32-
config.rpId = 'accounts.firefox.com';
33-
config.allowedOrigins = ['https://accounts.firefox.com'];
34-
config.challengeTimeout = CHALLENGE_TIMEOUT_MS;
35-
Object.assign(config, overrides);
31+
const config = new PasskeyConfig({
32+
allowedOrigins: ['https://accounts.firefox.com'],
33+
challengeTimeout: CHALLENGE_TIMEOUT_MS,
34+
enabled: true,
35+
maxPasskeysPerUser: 6,
36+
residentKey: 'required',
37+
rpId: 'accounts.firefox.com',
38+
...overrides,
39+
});
40+
3641
return config;
3742
}
3843

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import {
66
ArrayMinSize,
77
IsArray,
8+
IsBoolean,
89
IsIn,
910
IsNotEmpty,
1011
IsNumber,
@@ -31,6 +32,9 @@ export const MAX_PASSKEY_NAME_LENGTH = 255;
3132
* and passed to PasskeyService constructor.
3233
*/
3334
export class PasskeyConfig {
35+
@IsBoolean()
36+
public enabled!: boolean;
37+
3438
/**
3539
* WebAuthn Relying Party ID (must match the domain).
3640
* @example 'accounts.firefox.com'
@@ -99,4 +103,21 @@ export class PasskeyConfig {
99103
@IsOptional()
100104
@IsIn(['platform', 'cross-platform'])
101105
public authenticatorAttachment?: AuthenticatorAttachment | undefined;
106+
107+
/**
108+
* Creates a new PasskeyConfig instance by copying all fields from the
109+
* provided options object.
110+
*
111+
* @param opts - Source object whose properties are copied into this instance.
112+
*/
113+
constructor(opts: PasskeyConfig) {
114+
this.allowedOrigins = opts.allowedOrigins;
115+
this.authenticatorAttachment = opts.authenticatorAttachment;
116+
this.challengeTimeout = opts.challengeTimeout;
117+
this.enabled = opts.enabled;
118+
this.maxPasskeysPerUser = opts.maxPasskeysPerUser;
119+
this.residentKey = opts.residentKey;
120+
this.rpId = opts.rpId;
121+
this.userVerification = opts.userVerification;
122+
}
102123
}

libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ describe('PasskeyManager (Integration)', () => {
2424
let accountManager: AccountManager;
2525

2626
// Use a small limit to make passkeyLimitReached error tests practical
27-
const testConfig: PasskeyConfig = Object.assign(new PasskeyConfig(), {
27+
const testConfig: PasskeyConfig = new PasskeyConfig({
28+
enabled: true,
2829
rpId: 'accounts.example.com',
29-
rpName: 'Example',
3030
allowedOrigins: ['https://accounts.example.com'],
3131
maxPasskeysPerUser: 2,
32+
challengeTimeout: 30_000,
3233
});
3334

3435
const mockMetrics = {

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ jest.mock('./passkey.repository', () => ({
3232

3333
const mockDb = {} as unknown as AccountDatabase;
3434
const MOCK_MAX_PASSKEYS_PER_USER = 3;
35+
const CHALLENGE_TIMEOUT_MS = 1000 * 60 * 5;
3536

36-
const mockConfig: PasskeyConfig = Object.assign(new PasskeyConfig(), {
37-
rpId: 'accounts.example.com',
38-
rpName: 'Example',
37+
const mockConfig = new PasskeyConfig({
3938
allowedOrigins: ['https://accounts.example.com'],
39+
enabled: true,
40+
rpId: 'accounts.example.com',
41+
challengeTimeout: CHALLENGE_TIMEOUT_MS,
4042
maxPasskeysPerUser: MOCK_MAX_PASSKEYS_PER_USER,
4143
});
4244

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

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ const VALID_RAW_CONFIG: RawPasskeyConfig = {
2020
enabled: true,
2121
rpId: 'accounts.firefox.com',
2222
allowedOrigins: ['https://accounts.firefox.com'],
23-
challengeTimeout: 60000,
2423
maxPasskeysPerUser: 10,
24+
challengeTimeout: 30_000,
2525
userVerification: 'required',
2626
residentKey: 'required',
2727
authenticatorAttachment: '',
@@ -90,13 +90,6 @@ describe('PasskeyChallengeRedisProvider', () => {
9090
});
9191

9292
describe('PasskeyConfigProvider', () => {
93-
describe('when passkeys.enabled is false', () => {
94-
it('returns null without validation', async () => {
95-
const { config } = await buildModule({ enabled: false });
96-
expect(config).toBeNull();
97-
});
98-
});
99-
10093
describe('when config is valid', () => {
10194
it('returns a PasskeyConfig instance', async () => {
10295
const { config } = await buildModule(VALID_RAW_CONFIG);
@@ -107,7 +100,7 @@ describe('PasskeyConfigProvider', () => {
107100
const { config } = await buildModule(VALID_RAW_CONFIG);
108101
expect(config!.rpId).toBe('accounts.firefox.com');
109102
expect(config!.allowedOrigins).toEqual(['https://accounts.firefox.com']);
110-
expect(config!.challengeTimeout).toBe(60000);
103+
expect(config!.challengeTimeout).toBe(30_000);
111104
expect(config!.maxPasskeysPerUser).toBe(10);
112105
expect(config!.userVerification).toBe('required');
113106
expect(config!.residentKey).toBe('required');
@@ -125,46 +118,46 @@ describe('PasskeyConfigProvider', () => {
125118
});
126119

127120
describe('when config is invalid', () => {
128-
it('returns null', async () => {
129-
const { config } = await buildModule({
130-
...VALID_RAW_CONFIG,
131-
rpId: '',
132-
allowedOrigins: ['not-a-valid-origin'],
133-
});
134-
expect(config).toBeNull();
121+
it('throws', async () => {
122+
await expect(() =>
123+
buildModule({
124+
...VALID_RAW_CONFIG,
125+
rpId: '',
126+
})
127+
).rejects.toThrow('property rpId has failed the following constraints');
135128
});
136129

137-
it('logs an error with the validation message', async () => {
138-
const { logger } = await buildModule({
139-
...VALID_RAW_CONFIG,
140-
allowedOrigins: ['not-a-valid-origin'],
141-
});
142-
expect(logger.error).toHaveBeenCalledWith(
143-
'passkey.config.invalid',
144-
expect.objectContaining({
145-
message: expect.stringContaining(
146-
'Passkeys disabled due to malformed config'
147-
),
130+
it('rejects allowedOrigins with trailing path', async () => {
131+
await expect(() =>
132+
buildModule({
133+
...VALID_RAW_CONFIG,
134+
allowedOrigins: 'not-a-valid-origin',
148135
})
136+
).rejects.toThrow(
137+
'property allowedOrigins has failed the following constraints'
149138
);
150139
});
151140

152141
it('rejects allowedOrigins with trailing path', async () => {
153-
const { config, logger } = await buildModule({
154-
...VALID_RAW_CONFIG,
155-
allowedOrigins: ['https://accounts.firefox.com/path'],
156-
});
157-
expect(config).toBeNull();
158-
expect(logger.error).toHaveBeenCalled();
142+
await expect(() =>
143+
buildModule({
144+
...VALID_RAW_CONFIG,
145+
allowedOrigins: ['https://accounts.firefox.com/path'],
146+
})
147+
).rejects.toThrow(
148+
'property allowedOrigins has failed the following constraints'
149+
);
159150
});
160151

161152
it('rejects empty allowedOrigins array', async () => {
162-
const { config, logger } = await buildModule({
163-
...VALID_RAW_CONFIG,
164-
allowedOrigins: [],
165-
});
166-
expect(config).toBeNull();
167-
expect(logger.error).toHaveBeenCalled();
153+
await expect(() =>
154+
buildModule({
155+
...VALID_RAW_CONFIG,
156+
allowedOrigins: [],
157+
})
158+
).rejects.toThrow(
159+
'property allowedOrigins has failed the following constraints'
160+
);
168161
});
169162
});
170163
});

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

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import type {
1414
UserVerificationRequirement,
1515
} from '@simplewebauthn/server';
1616

17+
/**
18+
* Raw passkey configuration.
19+
*
20+
* Mirrors the passkeys section of the for general configurations.
21+
* An empty string for `authenticatorAttachment` is normalized to `undefined`
22+
* by {@link buildPasskeyConfig} because Convict cannot represent `undefined`
23+
* in JSON config files.
24+
*/
1725
export type RawPasskeyConfig = {
1826
enabled: boolean;
1927
rpId: string;
@@ -22,38 +30,46 @@ export type RawPasskeyConfig = {
2230
maxPasskeysPerUser: number;
2331
userVerification: UserVerificationRequirement;
2432
residentKey: ResidentKeyRequirement;
33+
/** Empty string is treated as "no preference" and normalized to `undefined`. */
2534
authenticatorAttachment: AuthenticatorAttachment | '';
2635
};
2736

28-
export function buildPasskeyConfig(
29-
raw: RawPasskeyConfig,
30-
log: LoggerService
31-
): PasskeyConfig | null {
32-
if (!raw.enabled) {
33-
return null;
34-
}
35-
36-
const mapped = {
37+
/**
38+
* Builds and validates a {@link PasskeyConfig} from raw Convict values.
39+
*
40+
* Normalizes `authenticatorAttachment`: an empty string (the Convict
41+
* no-preference sentinel) is converted to `undefined` so that the
42+
* WebAuthn library omits the field from registration options entirely.
43+
*
44+
* @param raw - Raw config values read from Convict.
45+
* @returns A fully validated {@link PasskeyConfig} instance.
46+
* @throws {Error} If any field fails class-validator constraints, with a
47+
* human-readable list of all validation errors.
48+
*/
49+
export function buildPasskeyConfig(raw: RawPasskeyConfig): PasskeyConfig {
50+
const passkeyConfig = new PasskeyConfig({
3751
...raw,
3852
authenticatorAttachment: raw.authenticatorAttachment || undefined,
39-
};
53+
});
4054

41-
const passkeyConfig = Object.assign(new PasskeyConfig(), mapped);
4255
const errors = validateSync(passkeyConfig, {
4356
skipMissingProperties: false,
4457
});
4558
if (errors.length > 0) {
4659
const message = errors.map((e) => e.toString()).join('\n');
47-
log.error('passkey.config.invalid', {
48-
message: `Passkeys disabled due to malformed config:\n${message}`,
49-
});
50-
return null;
60+
throw new Error(`Malformed passkey config:\n${message}`);
5161
}
5262
return passkeyConfig;
5363
}
5464

65+
/** NestJS injection token for the passkey-specific Redis client. */
5566
export const PASSKEY_CHALLENGE_REDIS = 'PasskeyChallengeRedis';
5667

68+
/**
69+
* NestJS provider that creates a dedicated Redis client for passkey challenge
70+
* storage. The client is configured by merging the base `redis` config with
71+
* the `redis.passkey` override, allowing a separate DB index or host.
72+
*/
5773
export const PasskeyChallengeRedisProvider = {
5874
provide: PASSKEY_CHALLENGE_REDIS,
5975
useFactory: (config: ConfigService) => {
@@ -67,6 +83,14 @@ export const PasskeyChallengeRedisProvider = {
6783
inject: [ConfigService],
6884
};
6985

86+
/**
87+
* NestJS provider that reads, validates, and exposes the {@link PasskeyConfig}
88+
* for injection throughout the passkey module.
89+
*
90+
* Returns `null` (and logs an error) when the `passkeys` config key is absent,
91+
* allowing dependent services to treat passkeys as disabled rather than
92+
* throwing at startup.
93+
*/
7094
export const PasskeyConfigProvider = {
7195
provide: PasskeyConfig,
7296
useFactory: (config: ConfigService, log: LoggerService) => {
@@ -77,7 +101,7 @@ export const PasskeyConfigProvider = {
77101
});
78102
return null;
79103
}
80-
return buildPasskeyConfig(rawConfig as RawPasskeyConfig, log);
104+
return buildPasskeyConfig(rawConfig as RawPasskeyConfig);
81105
},
82106
inject: [ConfigService, LOGGER_PROVIDER],
83107
};

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,12 @@ describe('PasskeyService', () => {
101101
warn: jest.fn(),
102102
};
103103

104-
const mockConfig = Object.assign(new PasskeyConfig(), {
104+
const mockConfig = new PasskeyConfig({
105+
enabled: true,
105106
rpId: 'accounts.firefox.com',
106107
allowedOrigins: ['https://accounts.firefox.com'],
107-
challengeTimeout: 60000,
108108
maxPasskeysPerUser: 10,
109+
challengeTimeout: 30_000,
109110
userVerification: 'required',
110111
residentKey: 'required',
111112
});

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ const DISPLAY_SAFE_UNICODE_WITH_NON_BMP =
7575
*/
7676
@Injectable()
7777
export class PasskeyService {
78+
/**
79+
* Indicates service is enabled.
80+
*/
81+
get enabled(): boolean {
82+
return this.config.enabled;
83+
}
84+
85+
/**
86+
* Creates new PasskeyService
87+
* @param passkeyManager current passkey manager
88+
* @param challengeManager current pass key challenge manager
89+
* @param config config for passkeys
90+
* @param metrics metrics for emitting raw metrics
91+
* @param log logger for logging errors / warnings
92+
*/
7893
constructor(
7994
private readonly passkeyManager: PasskeyManager,
8095
private readonly challengeManager: PasskeyChallengeManager,
@@ -122,6 +137,11 @@ export class PasskeyService {
122137
* @param uid - User ID (16-byte Buffer)
123138
* @param response - Raw registration response from the browser
124139
* @param challenge - Challenge that was issued for this registration
140+
* @returns The newly created passkey record as stored in the database.
141+
* @throws {AppError} passkeyChallengeNotFound if the challenge has expired,
142+
* was already consumed, or never existed.
143+
* @throws {AppError} passkeyRegistrationFailed if attestation verification
144+
* fails or the verified flag is false.
125145
*/
126146
async createPasskeyFromRegistrationResponse(
127147
uid: Buffer,

0 commit comments

Comments
 (0)