Skip to content

Commit 4ddb34f

Browse files
feat(passkey): create passkey authentication methods
Because: * we want passkey authentication methods This commit: * creates passkey authentication methods Closes #FXA-13062
1 parent 0ec1dec commit 4ddb34f

5 files changed

Lines changed: 535 additions & 98 deletions

File tree

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

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,11 @@ afterAll(async () => {
7272
describe('PasskeyChallengeManager (integration)', () => {
7373
describe('generateRegistrationChallenge', () => {
7474
it('stores challenge with uid and type=registration', async () => {
75-
const challenge =
76-
await manager.generateRegistrationChallenge('deadbeef');
75+
const challenge = await manager.generateRegistrationChallenge('deadbeef');
7776

7877
const stored = await manager.consumeRegistrationChallenge(
79-
'deadbeef',
80-
challenge
78+
challenge,
79+
'deadbeef'
8180
);
8281

8382
expect(stored?.challenge).toBe(challenge);
@@ -87,29 +86,24 @@ describe('PasskeyChallengeManager (integration)', () => {
8786
});
8887

8988
describe('generateAuthenticationChallenge', () => {
90-
it('stores challenge with uid and type=authentication', async () => {
91-
const challenge =
92-
await manager.generateAuthenticationChallenge('deadbeef');
89+
it('stores challenge with no uid and type=authentication', async () => {
90+
const challenge = await manager.generateAuthenticationChallenge();
9391

94-
const stored = await manager.consumeAuthenticationChallenge(
95-
'deadbeef',
96-
challenge
97-
);
92+
const stored = await manager.consumeAuthenticationChallenge(challenge);
9893

9994
expect(stored?.challenge).toBe(challenge);
10095
expect(stored?.type).toBe('authentication');
101-
expect(stored?.uid).toBe('deadbeef');
96+
expect(stored?.uid).toBeUndefined();
10297
});
10398
});
10499

105100
describe('generateUpgradeChallenge', () => {
106101
it('stores challenge with uid and type=upgrade', async () => {
107-
const challenge =
108-
await manager.generateUpgradeChallenge('cafebabe');
102+
const challenge = await manager.generateUpgradeChallenge('cafebabe');
109103

110104
const stored = await manager.consumeUpgradeChallenge(
111-
'cafebabe',
112-
challenge
105+
challenge,
106+
'cafebabe'
113107
);
114108

115109
expect(stored?.challenge).toBe(challenge);
@@ -120,22 +114,21 @@ describe('PasskeyChallengeManager (integration)', () => {
120114

121115
describe('consumeChallenge', () => {
122116
it('returns null on second consume (single-use enforcement)', async () => {
123-
const challenge =
124-
await manager.generateRegistrationChallenge('deadbeef');
117+
const challenge = await manager.generateRegistrationChallenge('deadbeef');
125118

126-
await manager.consumeRegistrationChallenge('deadbeef', challenge);
119+
await manager.consumeRegistrationChallenge(challenge, 'deadbeef');
127120

128121
const secondAttempt = await manager.consumeRegistrationChallenge(
129-
'deadbeef',
130-
challenge
122+
challenge,
123+
'deadbeef'
131124
);
132125
expect(secondAttempt).toBeNull();
133126
});
134127

135128
it('returns null for an unknown challenge', async () => {
136129
const result = await manager.consumeRegistrationChallenge(
137-
'deadbeef',
138-
'nonexistent-challenge'
130+
'nonexistent-challenge',
131+
'deadbeef'
139132
);
140133
expect(result).toBeNull();
141134
});
@@ -158,32 +151,31 @@ describe('PasskeyChallengeManager (integration)', () => {
158151
await new Promise((resolve) => setTimeout(resolve, 1100));
159152

160153
const result = await shortManager.consumeRegistrationChallenge(
161-
'deadbeef',
162-
challenge
154+
challenge,
155+
'deadbeef'
163156
);
164157
expect(result).toBeNull();
165158
}, 5_000);
166159
});
167160

168161
describe('deleteChallenge', () => {
169162
it('removes the key from Redis', async () => {
170-
const challenge =
171-
await manager.generateRegistrationChallenge('deadbeef');
163+
const challenge = await manager.generateRegistrationChallenge('deadbeef');
172164

173-
await manager.deleteChallenge(challenge, 'registration', 'deadbeef');
165+
await manager.deleteChallenge('registration', challenge, 'deadbeef');
174166

175167
const result = await manager.consumeRegistrationChallenge(
176-
'deadbeef',
177-
challenge
168+
challenge,
169+
'deadbeef'
178170
);
179171

180172
expect(result).toBeNull();
181173
});
182174

183175
it('does not throw when the key does not exist', async () => {
184176
const result = await manager.deleteChallenge(
185-
'nonexistent-challenge',
186177
'registration',
178+
'nonexistent-challenge',
187179
'deadbeef'
188180
);
189181
expect(result).toBeUndefined();

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

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('PasskeyChallengeManager', () => {
6464
await manager.generateRegistrationChallenge('deadbeef');
6565

6666
expect(mockRedis.set).toHaveBeenCalledWith(
67-
`passkey:challenge:deadbeef:registration:${MOCK_CHALLENGE}`,
67+
`passkey:challenge:registration:${MOCK_CHALLENGE}:deadbeef`,
6868
expect.any(String),
6969
'EX',
7070
CHALLENGE_TIMEOUT_MS / 1000
@@ -109,18 +109,16 @@ describe('PasskeyChallengeManager', () => {
109109
});
110110

111111
describe('generateAuthenticationChallenge', () => {
112-
it('stores the challenge with type=authentication and uid', async () => {
112+
it('stores the challenge with type=authentication and no uid', async () => {
113113
mockRedis.set.mockResolvedValue('OK');
114-
await manager.generateAuthenticationChallenge('deadbeef');
114+
await manager.generateAuthenticationChallenge();
115115

116116
const [key, rawJson] = mockRedis.set.mock.calls[0];
117117
const stored: StoredChallenge = JSON.parse(rawJson);
118118

119-
expect(key).toBe(
120-
`passkey:challenge:deadbeef:authentication:${MOCK_CHALLENGE}`
121-
);
119+
expect(key).toBe(`passkey:challenge:authentication:${MOCK_CHALLENGE}`);
122120
expect(stored.type).toBe('authentication');
123-
expect(stored.uid).toBe('deadbeef');
121+
expect(stored.uid).toBeUndefined();
124122
});
125123
});
126124

@@ -132,9 +130,7 @@ describe('PasskeyChallengeManager', () => {
132130
const [key, rawJson] = mockRedis.set.mock.calls[0];
133131
const stored: StoredChallenge = JSON.parse(rawJson);
134132

135-
expect(key).toBe(
136-
`passkey:challenge:cafebabe:upgrade:${MOCK_CHALLENGE}`
137-
);
133+
expect(key).toBe(`passkey:challenge:upgrade:${MOCK_CHALLENGE}:cafebabe`);
138134
expect(stored.type).toBe('upgrade');
139135
expect(stored.uid).toBe('cafebabe');
140136
});
@@ -170,8 +166,8 @@ describe('PasskeyChallengeManager', () => {
170166
mockRedis.getdel.mockResolvedValue(JSON.stringify(stored));
171167

172168
const result = await manager.consumeRegistrationChallenge(
173-
'deadbeef',
174-
MOCK_CHALLENGE
169+
MOCK_CHALLENGE,
170+
'deadbeef'
175171
);
176172

177173
expect(result).toEqual(stored);
@@ -185,8 +181,8 @@ describe('PasskeyChallengeManager', () => {
185181
mockRedis.getdel.mockResolvedValue(null);
186182

187183
const result = await manager.consumeRegistrationChallenge(
188-
'deadbeef',
189-
MOCK_CHALLENGE
184+
MOCK_CHALLENGE,
185+
'deadbeef'
190186
);
191187

192188
expect(result).toBeNull();
@@ -200,8 +196,8 @@ describe('PasskeyChallengeManager', () => {
200196
mockRedis.getdel.mockResolvedValue('not a valid json string');
201197

202198
const result = await manager.consumeRegistrationChallenge(
203-
'deadbeef',
204-
MOCK_CHALLENGE
199+
MOCK_CHALLENGE,
200+
'deadbeef'
205201
);
206202

207203
expect(result).toBeNull();
@@ -215,32 +211,28 @@ describe('PasskeyChallengeManager', () => {
215211
const stored = makeStored({ type: 'authentication' });
216212
mockRedis.getdel.mockResolvedValue(JSON.stringify(stored));
217213

218-
await manager.consumeAuthenticationChallenge('deadbeef', MOCK_CHALLENGE);
214+
await manager.consumeAuthenticationChallenge(MOCK_CHALLENGE);
219215

220216
expect(mockRedis.getdel).toHaveBeenCalledWith(
221-
`passkey:challenge:deadbeef:authentication:${MOCK_CHALLENGE}`
217+
`passkey:challenge:authentication:${MOCK_CHALLENGE}`
222218
);
223219
});
224220
});
225221

226222
describe('deleteChallenge', () => {
227223
it('calls redis.del with the correct key', async () => {
228224
mockRedis.del.mockResolvedValue(1);
229-
await manager.deleteChallenge(
230-
MOCK_CHALLENGE,
231-
'registration',
232-
'deadbeef'
233-
);
225+
await manager.deleteChallenge('registration', MOCK_CHALLENGE, 'deadbeef');
234226

235227
expect(mockRedis.del).toHaveBeenCalledWith(
236-
`passkey:challenge:deadbeef:registration:${MOCK_CHALLENGE}`
228+
`passkey:challenge:registration:${MOCK_CHALLENGE}:deadbeef`
237229
);
238230
});
239231

240232
it('does not throw when the key does not exist (del returns 0)', async () => {
241233
mockRedis.del.mockResolvedValue(0);
242234
await expect(
243-
manager.deleteChallenge(MOCK_CHALLENGE, 'authentication', 'deadbeef')
235+
manager.deleteChallenge('authentication', MOCK_CHALLENGE, 'deadbeef')
244236
).resolves.toBeUndefined();
245237
});
246238
});

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

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export class PasskeyChallengeManager {
114114
/**
115115
* Generates a registration challenge for the WebAuthn attestation ceremony.
116116
*
117-
* @param input - Must include the hex-encoded uid of the user.
117+
* @param uid - Hex-encoded uid of the user.
118118
* @returns Base64url-encoded 32-byte challenge string.
119119
*/
120120
async generateRegistrationChallenge(uid: string): Promise<string> {
@@ -126,14 +126,14 @@ export class PasskeyChallengeManager {
126126
*
127127
* @returns Base64url-encoded 32-byte challenge string.
128128
*/
129-
async generateAuthenticationChallenge(uid: string): Promise<string> {
130-
return this.generateChallenge('authentication', uid);
129+
async generateAuthenticationChallenge(): Promise<string> {
130+
return this.generateChallenge('authentication');
131131
}
132132

133133
/**
134134
* Generates an upgrade challenge for the WebAuthn PRF key-wrapping ceremony.
135135
*
136-
* @param input - Must include the hex-encoded uid of the user.
136+
* @param uid - Hex-encoded uid of the user.
137137
* @returns Base64url-encoded 32-byte challenge string.
138138
*/
139139
async generateUpgradeChallenge(uid: string): Promise<string> {
@@ -142,42 +142,42 @@ export class PasskeyChallengeManager {
142142

143143
/**
144144
* Fetches and deletes a registration challenge from Redis.
145-
* @param uid The hex-encoded uid of the user.
146-
* @param challenge The base64url-encoded challenge string.
145+
*
146+
* @param challenge - The base64url-encoded challenge string.
147+
* @param uid - Hex-encoded uid of the user.
147148
* @returns The stored challenge metadata or null if not found or expired.
148149
*/
149150
async consumeRegistrationChallenge(
150-
uid: string,
151-
challenge: string
151+
challenge: string,
152+
uid: string
152153
): Promise<StoredChallenge | null> {
153-
return this.consumeChallenge(uid, 'registration', challenge);
154+
return this.consumeChallenge('registration', challenge, uid);
154155
}
155156

156157
/**
157158
* Fetches and deletes an authentication challenge from Redis.
158159
*
159-
* @param uid The hex-encoded uid of the user.
160160
* @param challenge The base64url-encoded challenge string.
161161
* @returns The stored challenge metadata or null if not found or expired.
162162
*/
163163
async consumeAuthenticationChallenge(
164-
uid: string,
165164
challenge: string
166165
): Promise<StoredChallenge | null> {
167-
return this.consumeChallenge(uid, 'authentication', challenge);
166+
return this.consumeChallenge('authentication', challenge);
168167
}
169168

170169
/**
171170
* Fetches and deletes an upgrade challenge from Redis.
172-
* @param uid The hex-encoded uid of the user.
173-
* @param challenge The base64url-encoded challenge string.
171+
*
172+
* @param challenge - The base64url-encoded challenge string.
173+
* @param uid - Hex-encoded uid of the user.
174174
* @returns The stored challenge metadata or null if not found or expired.
175175
*/
176176
async consumeUpgradeChallenge(
177-
uid: string,
178-
challenge: string
177+
challenge: string,
178+
uid: string
179179
): Promise<StoredChallenge | null> {
180-
return this.consumeChallenge(uid, 'upgrade', challenge);
180+
return this.consumeChallenge('upgrade', challenge, uid);
181181
}
182182

183183
/**
@@ -187,13 +187,13 @@ export class PasskeyChallengeManager {
187187
* has already been consumed). GETDEL in validateChallenge handles the normal case.
188188
* This method succeeds silently even if the key does not exist.
189189
*
190-
* @param challenge - The base64url-encoded challenge to delete.
191190
* @param type - The challenge type (used to reconstruct the Redis key).
191+
* @param challenge - The base64url-encoded challenge to delete.
192192
* @param uid - The hex-encoded uid of the user.
193193
*/
194194
async deleteChallenge(
195-
challenge: string,
196195
type: ChallengeType,
196+
challenge: string,
197197
uid: string
198198
): Promise<void> {
199199
const key = this.buildKey(type, challenge, uid);
@@ -209,14 +209,15 @@ export class PasskeyChallengeManager {
209209
* operation, enforcing single-use semantics. Returns null if not found, expired,
210210
* or if the stored value is invalid JSON.
211211
*
212-
* @param challenge - The base64url-encoded challenge from the WebAuthn ceremony.
213212
* @param type - The expected challenge type (prevents cross-ceremony attacks).
213+
* @param challenge - The base64url-encoded challenge from the WebAuthn ceremony.
214+
* @param uid - Optional hex-encoded uid
214215
* @returns The stored challenge metadata (uid, type, timestamps) or null if not found expired.
215216
*/
216217
private async consumeChallenge(
217-
uid: string,
218218
type: ChallengeType,
219-
challenge: string
219+
challenge: string,
220+
uid?: string
220221
): Promise<StoredChallenge | null> {
221222
const key = this.buildKey(type, challenge, uid);
222223
const raw = await this.redis.getdel(key);
@@ -242,7 +243,7 @@ export class PasskeyChallengeManager {
242243

243244
private async generateChallenge(
244245
type: ChallengeType,
245-
uid: string
246+
uid?: string
246247
): Promise<string> {
247248
const challenge = randomBytes(32).toString('base64url');
248249
const now = Date.now();
@@ -269,8 +270,10 @@ export class PasskeyChallengeManager {
269270
private buildKey(
270271
type: ChallengeType,
271272
challenge: string,
272-
uid: string
273+
uid?: string
273274
): string {
274-
return `passkey:challenge:${uid}:${type}:${challenge}`;
275+
return uid
276+
? `passkey:challenge:${type}:${challenge}:${uid}`
277+
: `passkey:challenge:${type}:${challenge}`;
275278
}
276279
}

0 commit comments

Comments
 (0)