Skip to content

Commit 7401be6

Browse files
committed
feat(passkey): Add passkey challenge manager#20151
Because: - We want a central point for managing passkey challenge creation and validation This Commit: - Adds a new passkey.challenge.manager backed by Redis - Adds unit and integration tests for the new manager Closes: FXA-13059
1 parent 2fac797 commit 7401be6

9 files changed

Lines changed: 792 additions & 2 deletions

libs/accounts/errors/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export const ERRNO = {
134134
PASSKEY_LIMIT_REACHED: 226,
135135
PASSKEY_AUTHENTICATION_FAILED: 227,
136136
PASSKEY_REGISTRATION_FAILED: 228,
137+
PASSKEY_CHALLENGE_NOT_FOUND: 229,
137138
// Jump to 238 to leave room for future passkey errors
138139
PASSKEY_CHALLENGE_EXPIRED: 238,
139140
INTERNAL_VALIDATION_ERROR: 998,

libs/accounts/passkey/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ export * from './lib/passkey.repository';
2626
export * from './lib/passkey.errors';
2727
export * from './lib/passkey.config';
2828
export * from './lib/passkey.provider';
29+
export * from './lib/passkey.challenge.manager';
2930
export * from './lib/webauthn-adapter';
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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 } from '@nestjs/testing';
6+
import Redis from 'ioredis';
7+
import { LOGGER_PROVIDER } from '@fxa/shared/log';
8+
import { StatsDService } from '@fxa/shared/metrics/statsd';
9+
10+
import { PasskeyChallengeManager } from './passkey.challenge.manager';
11+
import { PasskeyConfig } from './passkey.config';
12+
import { PASSKEY_CHALLENGE_REDIS } from './passkey.provider';
13+
14+
const mockLogger = {
15+
log: jest.fn(),
16+
error: jest.fn(),
17+
warn: jest.fn(),
18+
debug: jest.fn(),
19+
};
20+
21+
const mockStatsd = { increment: jest.fn() };
22+
23+
let redis: Redis.Redis;
24+
let manager: PasskeyChallengeManager;
25+
let config: PasskeyConfig;
26+
27+
const buildTestModule = async (
28+
redis: Redis.Redis,
29+
config: PasskeyConfig,
30+
logger: typeof mockLogger
31+
) => {
32+
return await Test.createTestingModule({
33+
providers: [
34+
PasskeyChallengeManager,
35+
{ provide: PASSKEY_CHALLENGE_REDIS, useValue: redis },
36+
{ provide: PasskeyConfig, useValue: config },
37+
{ provide: LOGGER_PROVIDER, useValue: logger },
38+
{ provide: StatsDService, useValue: mockStatsd },
39+
],
40+
}).compile();
41+
};
42+
43+
async function clearChallengeKeys() {
44+
const keys = await redis.keys('passkey:challenge:*');
45+
if (keys.length > 0) {
46+
await redis.del(keys);
47+
}
48+
}
49+
50+
beforeAll(async () => {
51+
redis = new Redis({ host: 'localhost' });
52+
53+
config = new PasskeyConfig();
54+
config.rpId = 'localhost';
55+
config.allowedOrigins = ['http://localhost'];
56+
config.challengeTimeout = 1000 * 60 * 5; // 5 minutes
57+
58+
const moduleRef = await buildTestModule(redis, config, mockLogger);
59+
60+
manager = moduleRef.get(PasskeyChallengeManager);
61+
});
62+
63+
beforeEach(async () => {
64+
await clearChallengeKeys();
65+
});
66+
67+
afterAll(async () => {
68+
await clearChallengeKeys();
69+
await redis.quit();
70+
});
71+
72+
describe('PasskeyChallengeManager (integration)', () => {
73+
describe('generateRegistrationChallenge', () => {
74+
it('stores challenge with uid and type=registration', async () => {
75+
const challenge = await manager.generateRegistrationChallenge({
76+
uid: 'deadbeef',
77+
});
78+
79+
const stored = await manager.validateChallenge(challenge, 'registration');
80+
81+
expect(stored?.challenge).toBe(challenge);
82+
expect(stored?.type).toBe('registration');
83+
expect(stored?.uid).toBe('deadbeef');
84+
});
85+
});
86+
87+
describe('generateAuthenticationChallenge', () => {
88+
it('stores challenge with no uid and type=authentication', async () => {
89+
const challenge = await manager.generateAuthenticationChallenge();
90+
91+
const stored = await manager.validateChallenge(
92+
challenge,
93+
'authentication'
94+
);
95+
96+
expect(stored?.challenge).toBe(challenge);
97+
expect(stored?.type).toBe('authentication');
98+
expect(stored?.uid).toBeUndefined();
99+
});
100+
});
101+
102+
describe('generateUpgradeChallenge', () => {
103+
it('stores challenge with uid and type=upgrade', async () => {
104+
const challenge = await manager.generateUpgradeChallenge({
105+
uid: 'cafebabe',
106+
});
107+
108+
const stored = await manager.validateChallenge(challenge, 'upgrade');
109+
110+
expect(stored?.challenge).toBe(challenge);
111+
expect(stored?.type).toBe('upgrade');
112+
expect(stored?.uid).toBe('cafebabe');
113+
});
114+
});
115+
116+
describe('validateChallenge', () => {
117+
it('returns the stored challenge data', async () => {
118+
const challenge = await manager.generateRegistrationChallenge({
119+
uid: 'deadbeef',
120+
});
121+
122+
const stored = await manager.validateChallenge(challenge, 'registration');
123+
124+
expect(stored?.challenge).toBe(challenge);
125+
expect(stored?.type).toBe('registration');
126+
expect(stored?.uid).toBe('deadbeef');
127+
});
128+
129+
it('returns null on second validate (single-use enforcement)', async () => {
130+
const challenge = await manager.generateRegistrationChallenge({
131+
uid: 'deadbeef',
132+
});
133+
134+
await manager.validateChallenge(challenge, 'registration');
135+
136+
const secondValidation = await manager.validateChallenge(
137+
challenge,
138+
'registration'
139+
);
140+
expect(secondValidation).toBeNull();
141+
});
142+
143+
it('returns null for an unknown challenge', async () => {
144+
const result = await manager.validateChallenge(
145+
'nonexistent-challenge',
146+
'registration'
147+
);
148+
expect(result).toBeNull();
149+
});
150+
});
151+
152+
describe('TTL expiry', () => {
153+
it('challenge is gone from Redis after the TTL elapses', async () => {
154+
const shortConfig = new PasskeyConfig();
155+
shortConfig.rpId = 'localhost';
156+
shortConfig.allowedOrigins = ['http://localhost'];
157+
shortConfig.challengeTimeout = 1000;
158+
159+
const moduleRef = await buildTestModule(redis, shortConfig, mockLogger);
160+
161+
const shortManager = moduleRef.get(PasskeyChallengeManager);
162+
const challenge = await shortManager.generateRegistrationChallenge({
163+
uid: 'deadbeef',
164+
});
165+
166+
// Wait longer than the TTL to ensure the challenge expires
167+
await new Promise((resolve) => setTimeout(resolve, 1100));
168+
169+
const result = await shortManager.validateChallenge(
170+
challenge,
171+
'registration'
172+
);
173+
expect(result).toBeNull();
174+
}, 5_000);
175+
});
176+
177+
describe('deleteChallenge', () => {
178+
it('removes the key from Redis', async () => {
179+
const challenge = await manager.generateRegistrationChallenge({
180+
uid: 'deadbeef',
181+
});
182+
183+
await manager.deleteChallenge(challenge, 'registration');
184+
185+
const result = await manager.validateChallenge(challenge, 'registration');
186+
187+
expect(result).toBeNull();
188+
});
189+
190+
it('does not throw when the key does not exist', async () => {
191+
const result = await manager.deleteChallenge(
192+
'nonexistent-challenge',
193+
'registration'
194+
);
195+
expect(result).toBeUndefined();
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)