Skip to content

Commit c0e3466

Browse files
Merge pull request #20341 from mozilla/FXA-13403
fix(passkey): fix prfEnabled not saved to DB
2 parents 62e0d5b + 9d22d7c commit c0e3466

4 files changed

Lines changed: 200 additions & 115 deletions

File tree

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

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,18 @@ import { PasskeyChallengeManager } from './passkey.challenge.manager';
1717
import { AppError } from '@fxa/accounts/errors';
1818

1919
jest.mock('./webauthn-adapter', () => ({
20-
generateRegistrationOptions: jest.fn(),
21-
verifyRegistrationResponse: jest.fn(),
22-
generateAuthenticationOptions: jest.fn(),
23-
verifyAuthenticationResponse: jest.fn(),
20+
generateWebauthnRegistrationOptions: jest.fn(),
21+
verifyWebauthnRegistrationResponse: jest.fn(),
22+
generateWebauthnAuthenticationOptions: jest.fn(),
23+
verifyWebauthnAuthenticationResponse: jest.fn(),
2424
}));
2525

2626
import * as webauthnAdapter from './webauthn-adapter';
2727

28-
const mockGenerateRegistrationOptions =
29-
webauthnAdapter.generateRegistrationOptions as jest.Mock;
30-
const mockVerifyRegistrationResponse =
31-
webauthnAdapter.verifyRegistrationResponse as jest.Mock;
28+
const mockGenerateWebauthnRegistrationOptions =
29+
webauthnAdapter.generateWebauthnRegistrationOptions as jest.Mock;
30+
const mockVerifyWebauthnRegistrationResponse =
31+
webauthnAdapter.verifyWebauthnRegistrationResponse as jest.Mock;
3232

3333
describe('PasskeyService', () => {
3434
let service: PasskeyService;
@@ -147,7 +147,9 @@ describe('PasskeyService', () => {
147147
mockChallengeManager.generateRegistrationChallenge.mockResolvedValue(
148148
MOCK_CHALLENGE
149149
);
150-
mockGenerateRegistrationOptions.mockResolvedValue(mockWebAuthnOptions);
150+
mockGenerateWebauthnRegistrationOptions.mockResolvedValue(
151+
mockWebAuthnOptions
152+
);
151153
});
152154

153155
it('returns PublicKeyCredentialCreationOptionsJSON from the adapter', async () => {
@@ -177,7 +179,9 @@ describe('PasskeyService', () => {
177179
mockChallengeManager.generateRegistrationChallenge
178180
).toHaveBeenCalledWith(MOCK_UID.toString('hex'));
179181

180-
expect(mockGenerateRegistrationOptions).toHaveBeenCalledWith(
182+
expect(
183+
webauthnAdapter.generateWebauthnRegistrationOptions
184+
).toHaveBeenCalledWith(
181185
mockConfig,
182186
expect.objectContaining({
183187
uid: MOCK_UID,
@@ -195,10 +199,11 @@ describe('PasskeyService', () => {
195199
credentialId: MOCK_CREDENTIAL_ID,
196200
publicKey: MOCK_PUBLIC_KEY,
197201
signCount: 0,
198-
transports: ['internal'] as any,
202+
transports: ['internal'],
199203
aaguid: MOCK_AAGUID_ZEROS,
200204
backupEligible: false,
201205
backupState: false,
206+
prfEnabled: false,
202207
};
203208

204209
beforeEach(() => {
@@ -209,7 +214,7 @@ describe('PasskeyService', () => {
209214
createdAt: Date.now() - 1000,
210215
expiresAt: Date.now() + 299000,
211216
});
212-
mockVerifyRegistrationResponse.mockResolvedValue({
217+
mockVerifyWebauthnRegistrationResponse.mockResolvedValue({
213218
verified: true,
214219
data: mockVerifiedData,
215220
});
@@ -230,11 +235,13 @@ describe('PasskeyService', () => {
230235
message: 'Passkey challenge not found',
231236
code: 404,
232237
});
233-
expect(mockVerifyRegistrationResponse).not.toHaveBeenCalled();
238+
expect(mockVerifyWebauthnRegistrationResponse).not.toHaveBeenCalled();
234239
});
235240

236241
it('throws passkeyRegistrationFailed AppError when adapter returns verified: false', async () => {
237-
mockVerifyRegistrationResponse.mockResolvedValue({ verified: false });
242+
mockVerifyWebauthnRegistrationResponse.mockResolvedValue({
243+
verified: false,
244+
});
238245
await expect(
239246
service.createPasskeyFromRegistrationResponse(
240247
MOCK_UID,
@@ -249,7 +256,7 @@ describe('PasskeyService', () => {
249256
});
250257

251258
it('throws passkeyRegistrationFailed AppError when adapter throws', async () => {
252-
mockVerifyRegistrationResponse.mockRejectedValue(
259+
mockVerifyWebauthnRegistrationResponse.mockRejectedValue(
253260
new Error('Invalid attestation format')
254261
);
255262
await expect(
@@ -294,14 +301,20 @@ describe('PasskeyService', () => {
294301
lastUsedAt: null,
295302
backupEligible: 0,
296303
backupState: 0,
304+
prfEnabled: 0,
297305
})
298306
);
299307
});
300308

301-
it('sets backupEligible=1 and backupState=1 when flags are true', async () => {
302-
mockVerifyRegistrationResponse.mockResolvedValue({
309+
it('sets backupEligible=1, backupState=1 and prfEnabled=1 when flags are true', async () => {
310+
mockVerifyWebauthnRegistrationResponse.mockResolvedValue({
303311
verified: true,
304-
data: { ...mockVerifiedData, backupEligible: true, backupState: true },
312+
data: {
313+
...mockVerifiedData,
314+
backupEligible: true,
315+
backupState: true,
316+
prfEnabled: true,
317+
},
305318
});
306319
await service.createPasskeyFromRegistrationResponse(
307320
MOCK_UID,
@@ -310,7 +323,11 @@ describe('PasskeyService', () => {
310323
);
311324

312325
expect(mockManager.registerPasskey).toHaveBeenCalledWith(
313-
expect.objectContaining({ backupEligible: 1, backupState: 1 })
326+
expect.objectContaining({
327+
backupEligible: 1,
328+
backupState: 1,
329+
prfEnabled: 1,
330+
})
314331
);
315332
});
316333

@@ -386,7 +403,7 @@ describe('PasskeyService', () => {
386403
transports: string[],
387404
aaguid: Buffer = MOCK_AAGUID_ZEROS
388405
): Promise<string> {
389-
mockVerifyRegistrationResponse.mockResolvedValue({
406+
mockVerifyWebauthnRegistrationResponse.mockResolvedValue({
390407
verified: true,
391408
data: { ...mockVerifiedData, transports, aaguid },
392409
});
@@ -489,7 +506,7 @@ describe('PasskeyService', () => {
489506
MOCK_CHALLENGE
490507
);
491508
(
492-
webauthnAdapter.generateAuthenticationOptions as jest.Mock
509+
webauthnAdapter.generateWebauthnAuthenticationOptions as jest.Mock
493510
).mockResolvedValue(mockOptions);
494511
});
495512

@@ -508,7 +525,7 @@ describe('PasskeyService', () => {
508525
it('calls generateAuthenticationOptions with empty allowCredentials when no uid', async () => {
509526
await service.generateAuthenticationChallenge();
510527
expect(
511-
webauthnAdapter.generateAuthenticationOptions
528+
webauthnAdapter.generateWebauthnAuthenticationOptions
512529
).toHaveBeenCalledWith(mockConfig, {
513530
challenge: MOCK_CHALLENGE,
514531
allowCredentials: [],
@@ -527,7 +544,7 @@ describe('PasskeyService', () => {
527544

528545
expect(mockManager.listPasskeysForUser).toHaveBeenCalledWith(MOCK_UID);
529546
expect(
530-
webauthnAdapter.generateAuthenticationOptions
547+
webauthnAdapter.generateWebauthnAuthenticationOptions
531548
).toHaveBeenCalledWith(mockConfig, {
532549
challenge: MOCK_CHALLENGE,
533550
allowCredentials: [MOCK_CREDENTIAL_ID],
@@ -540,23 +557,23 @@ describe('PasskeyService', () => {
540557
await service.generateAuthenticationChallenge(MOCK_UID);
541558

542559
expect(
543-
webauthnAdapter.generateAuthenticationOptions
560+
webauthnAdapter.generateWebauthnAuthenticationOptions
544561
).toHaveBeenCalledWith(mockConfig, {
545562
challenge: MOCK_CHALLENGE,
546563
allowCredentials: [],
547564
});
548565
});
549566
});
550567

551-
describe('verifyAuthenticationResponse', () => {
568+
describe('verifyWebauthnAuthenticationResponse', () => {
552569
beforeEach(() => {
553570
mockManager.findPasskeyByCredentialId.mockResolvedValue(mockPasskey);
554571
mockChallengeManager.consumeAuthenticationChallenge.mockResolvedValue(
555572
mockStoredChallenge
556573
);
557574
mockManager.updatePasskeyAfterAuth.mockResolvedValue(true);
558575
(
559-
webauthnAdapter.verifyAuthenticationResponse as jest.Mock
576+
webauthnAdapter.verifyWebauthnAuthenticationResponse as jest.Mock
560577
).mockResolvedValue({
561578
verified: true,
562579
data: { newSignCount: 6, backupState: false },
@@ -585,18 +602,17 @@ describe('PasskeyService', () => {
585602
).toHaveBeenCalledWith(MOCK_CHALLENGE);
586603
});
587604

588-
it('calls verifyAuthenticationResponse adapter with correct passkey data', async () => {
605+
it('calls verifyWebauthnAuthenticationResponse adapter with correct passkey data', async () => {
589606
await service.verifyAuthenticationResponse(mockResponse, MOCK_CHALLENGE);
590-
expect(webauthnAdapter.verifyAuthenticationResponse).toHaveBeenCalledWith(
591-
mockConfig,
592-
{
593-
response: mockResponse,
594-
challenge: MOCK_CHALLENGE,
595-
credentialId: mockPasskey.credentialId,
596-
publicKey: mockPasskey.publicKey,
597-
signCount: mockPasskey.signCount,
598-
}
599-
);
607+
expect(
608+
webauthnAdapter.verifyWebauthnAuthenticationResponse
609+
).toHaveBeenCalledWith(mockConfig, {
610+
response: mockResponse,
611+
challenge: MOCK_CHALLENGE,
612+
credentialId: mockPasskey.credentialId,
613+
publicKey: mockPasskey.publicKey,
614+
signCount: mockPasskey.signCount,
615+
});
600616
});
601617

602618
it('updates passkey with new signCount and backupState after verification', async () => {
@@ -653,7 +669,7 @@ describe('PasskeyService', () => {
653669

654670
it('throws a passkeyAuthenticationFailed AppError when the assertion is not verified', async () => {
655671
(
656-
webauthnAdapter.verifyAuthenticationResponse as jest.Mock
672+
webauthnAdapter.verifyWebauthnAuthenticationResponse as jest.Mock
657673
).mockResolvedValue({ verified: false });
658674

659675
await expect(
@@ -663,7 +679,7 @@ describe('PasskeyService', () => {
663679

664680
it('throws a passkeyAuthenticationFailed AppError when the adapter throws', async () => {
665681
(
666-
webauthnAdapter.verifyAuthenticationResponse as jest.Mock
682+
webauthnAdapter.verifyWebauthnAuthenticationResponse as jest.Mock
667683
).mockRejectedValue(new Error('crypto error'));
668684

669685
await expect(
@@ -690,7 +706,7 @@ describe('PasskeyService', () => {
690706

691707
it('logs a signCount rollback warning when simplewebauthn throws a counter error', async () => {
692708
(
693-
webauthnAdapter.verifyAuthenticationResponse as jest.Mock
709+
webauthnAdapter.verifyWebauthnAuthenticationResponse as jest.Mock
694710
).mockRejectedValue(
695711
// Exact message thrown by @simplewebauthn/server
696712
new Error('Response counter value 2 was lower than expected 5')
@@ -715,7 +731,7 @@ describe('PasskeyService', () => {
715731

716732
it('does not log a rollback warning for non-counter adapter errors', async () => {
717733
(
718-
webauthnAdapter.verifyAuthenticationResponse as jest.Mock
734+
webauthnAdapter.verifyWebauthnAuthenticationResponse as jest.Mock
719735
).mockRejectedValue(new Error('Invalid signature'));
720736

721737
await expect(

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

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ import { PasskeyConfig } from './passkey.config';
2020
import { PasskeyManager } from './passkey.manager';
2121
import { PasskeyChallengeManager } from './passkey.challenge.manager';
2222
import {
23-
generateRegistrationOptions,
24-
verifyRegistrationResponse as adapterVerifyRegistrationResponse,
23+
generateWebauthnRegistrationOptions,
24+
verifyWebauthnRegistrationResponse,
2525
RegistrationVerificationResult,
2626
AuthenticationVerificationResult,
27-
generateAuthenticationOptions,
28-
verifyAuthenticationResponse,
27+
generateWebauthnAuthenticationOptions,
28+
verifyWebauthnAuthenticationResponse,
2929
} from './webauthn-adapter';
3030
import { AppError } from '@fxa/accounts/errors';
3131

@@ -119,7 +119,7 @@ export class PasskeyService {
119119
const challenge =
120120
await this.challengeManager.generateRegistrationChallenge(uidHex);
121121

122-
const options = await generateRegistrationOptions(this.config, {
122+
const options = await generateWebauthnRegistrationOptions(this.config, {
123123
uid,
124124
email,
125125
challenge,
@@ -167,7 +167,7 @@ export class PasskeyService {
167167

168168
let result: RegistrationVerificationResult;
169169
try {
170-
result = await adapterVerifyRegistrationResponse(this.config, {
170+
result = await verifyWebauthnRegistrationResponse(this.config, {
171171
response,
172172
challenge: storedChallenge.challenge,
173173
});
@@ -186,44 +186,28 @@ export class PasskeyService {
186186
throw AppError.passkeyRegistrationFailed();
187187
}
188188

189-
const {
190-
credentialId,
191-
publicKey,
192-
signCount,
193-
transports,
194-
aaguid,
195-
backupEligible,
196-
backupState,
197-
} = result.data;
189+
const { transports, aaguid } = result.data;
198190

199191
const existingPasskeys = await this.passkeyManager.listPasskeysForUser(uid);
200192

201193
const name = this.generatePasskeyName(aaguid, transports, existingPasskeys);
202194

203195
const passkey: Passkey = {
204196
uid,
205-
credentialId,
206-
publicKey,
207-
signCount,
208-
transports: transports ?? [],
209-
aaguid,
210197
name,
211198
createdAt: Date.now(),
212199
lastUsedAt: null,
213-
backupEligible,
214-
backupState,
215-
// FIXME: prfEnabled needs to be exposed by webauthn-adapter. See FXA-13403.
216-
prfEnabled: false,
200+
...result.data,
217201
};
218202

219203
// TODO: update repository, manager, and service layers to accept/return a
220204
// Passkey object directly instead of mapping to NewPasskey. See FXA-13402.
221205
const newPasskey: NewPasskey = {
222206
...passkey,
223-
transports: JSON.stringify(transports ?? []),
224-
backupEligible: backupEligible ? 1 : 0,
225-
backupState: backupState ? 1 : 0,
226-
prfEnabled: 0,
207+
transports: JSON.stringify(passkey.transports),
208+
backupEligible: passkey.backupEligible ? 1 : 0,
209+
backupState: passkey.backupState ? 1 : 0,
210+
prfEnabled: passkey.prfEnabled ? 1 : 0,
227211
};
228212

229213
await this.passkeyManager.registerPasskey(newPasskey);
@@ -428,7 +412,7 @@ export class PasskeyService {
428412
allowCredentials = passkeys.map((p) => p.credentialId);
429413
}
430414

431-
return await generateAuthenticationOptions(this.config, {
415+
return await generateWebauthnAuthenticationOptions(this.config, {
432416
challenge,
433417
allowCredentials,
434418
});
@@ -484,7 +468,7 @@ export class PasskeyService {
484468

485469
let result: AuthenticationVerificationResult;
486470
try {
487-
result = await verifyAuthenticationResponse(this.config, {
471+
result = await verifyWebauthnAuthenticationResponse(this.config, {
488472
response,
489473
challenge: storedChallenge.challenge,
490474
credentialId: passkey.credentialId,

0 commit comments

Comments
 (0)