Skip to content

Commit b628af4

Browse files
Merge pull request #20342 from mozilla/FXA-13402
refactor(passkey): restrict NewPasskey type to repository layer
2 parents 54969c8 + 72baca7 commit b628af4

9 files changed

Lines changed: 44 additions & 82 deletions

File tree

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

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,20 @@ describe('PasskeyManager (Integration)', () => {
9393
it('persists the passkey with correct field values', async () => {
9494
const uid = await createTestAccount();
9595
// Pass lastUsedAt: null explicitly — PasskeyFactory may randomise it
96-
const newPasskey = PasskeyFactory({
96+
const passkey = PasskeyFactory({
9797
uid,
98-
backupEligible: 1,
99-
backupState: 0,
100-
prfEnabled: 0,
98+
backupEligible: true,
99+
backupState: false,
100+
prfEnabled: false,
101101
lastUsedAt: null,
102102
});
103103

104-
await manager.registerPasskey(newPasskey);
104+
await manager.registerPasskey(passkey);
105105

106-
const found = await findPasskeyByCredentialId(
107-
db,
108-
newPasskey.credentialId
109-
);
106+
const found = await findPasskeyByCredentialId(db, passkey.credentialId);
110107
expect(found?.uid).toEqual(uid);
111-
expect(found?.credentialId).toEqual(newPasskey.credentialId);
112-
expect(found?.name).toBe(newPasskey.name);
108+
expect(found?.credentialId).toEqual(passkey.credentialId);
109+
expect(found?.name).toBe(passkey.name);
113110
expect(found?.backupEligible).toBe(true);
114111
expect(found?.backupState).toBe(false);
115112
expect(found?.prfEnabled).toBe(false);
@@ -146,7 +143,7 @@ describe('PasskeyManager (Integration)', () => {
146143
describe('updatePasskeyAfterAuth', () => {
147144
it('updates signCount, backupState, and lastUsedAt', async () => {
148145
const uid = await createTestAccount();
149-
const passkey = PasskeyFactory({ uid, signCount: 0, backupState: 0 });
146+
const passkey = PasskeyFactory({ uid, signCount: 0, backupState: false });
150147
await manager.registerPasskey(passkey);
151148

152149
const updated = await manager.updatePasskeyAfterAuth(
@@ -166,7 +163,7 @@ describe('PasskeyManager (Integration)', () => {
166163

167164
it('sets backupState to false when passed false', async () => {
168165
const uid = await createTestAccount();
169-
const passkey = PasskeyFactory({ uid, backupState: 1 });
166+
const passkey = PasskeyFactory({ uid, backupState: true });
170167
await manager.registerPasskey(passkey);
171168

172169
await manager.updatePasskeyAfterAuth(
@@ -228,7 +225,7 @@ describe('PasskeyManager (Integration)', () => {
228225

229226
it('succeeds when both stored and new signCount are zero', async () => {
230227
const uid = await createTestAccount();
231-
const passkey = PasskeyFactory({ uid, signCount: 0, backupState: 0 });
228+
const passkey = PasskeyFactory({ uid, signCount: 0, backupState: false });
232229
await manager.registerPasskey(passkey);
233230

234231
// Authenticators that never increment always return 0 — this is valid per WebAuthn spec

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,22 +74,22 @@ describe('PasskeyManager', () => {
7474

7575
describe('registerPasskey', () => {
7676
it('checks the limit and inserts the passkey', async () => {
77-
const newPasskey = PasskeyFactory();
77+
const passkey = PasskeyFactory();
7878

7979
(PasskeyRepository.countPasskeysByUid as jest.Mock).mockResolvedValue(1);
8080
(PasskeyRepository.insertPasskey as jest.Mock).mockResolvedValue(
8181
undefined
8282
);
8383

84-
await manager.registerPasskey(newPasskey);
84+
await manager.registerPasskey(passkey);
8585

8686
expect(PasskeyRepository.countPasskeysByUid).toHaveBeenCalledWith(
8787
mockDb,
88-
newPasskey.uid
88+
passkey.uid
8989
);
9090
expect(PasskeyRepository.insertPasskey).toHaveBeenCalledWith(
9191
mockDb,
92-
newPasskey
92+
passkey
9393
);
9494
});
9595

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
AccountDatabase,
88
AccountDbProvider,
99
Passkey,
10-
NewPasskey,
1110
} from '@fxa/shared/db/mysql/account';
1211
import { LOGGER_PROVIDER } from '@fxa/shared/log';
1312
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
@@ -57,7 +56,7 @@ export class PasskeyManager {
5756
* @throws {AppError} (passkeyLimitReached) if the user has reached the maximum passkey count
5857
* @throws {AppError} (passkeyAlreadyRegistered) if credentialId is already registered
5958
*/
60-
async registerPasskey(passkey: NewPasskey): Promise<void> {
59+
async registerPasskey(passkey: Passkey): Promise<void> {
6160
const uidHex = passkey.uid.toString('hex');
6261

6362
await this.checkPasskeyCount(passkey.uid);

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,20 @@ export async function findPasskeyByCredentialId(
8888
* Insert a new passkey record.
8989
*
9090
* @param db - Database instance
91-
* @param passkey - New passkey data to insert
91+
* @param passkey - Passkey data to insert
9292
*/
9393
export async function insertPasskey(
9494
db: AccountDatabase,
95-
passkey: NewPasskey
95+
passkey: Passkey
9696
): Promise<void> {
97-
await db.insertInto('passkeys').values(passkey).execute();
97+
const newPasskey: NewPasskey = {
98+
...passkey,
99+
transports: JSON.stringify(passkey.transports),
100+
backupEligible: passkey.backupEligible ? 1 : 0,
101+
backupState: passkey.backupState ? 1 : 0,
102+
prfEnabled: passkey.prfEnabled ? 1 : 0,
103+
};
104+
await db.insertInto('passkeys').values(newPasskey).execute();
98105
}
99106

100107
/**

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

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe('PasskeyService', () => {
5151
name: 'Test Passkey',
5252
createdAt: Date.now(),
5353
lastUsedAt: null,
54-
transports: '[]',
54+
transports: [],
5555
aaguid: Buffer.alloc(16),
5656
};
5757

@@ -203,7 +203,7 @@ describe('PasskeyService', () => {
203203
aaguid: MOCK_AAGUID_ZEROS,
204204
backupEligible: false,
205205
backupState: false,
206-
prfEnabled: false,
206+
prfEnabled: true,
207207
};
208208

209209
beforeEach(() => {
@@ -283,7 +283,7 @@ describe('PasskeyService', () => {
283283
).toHaveBeenCalledWith(MOCK_CHALLENGE, MOCK_UID.toString('hex'));
284284
});
285285

286-
it('calls passkeyManager.registerPasskey with correct NewPasskey shape', async () => {
286+
it('calls passkeyManager.registerPasskey with correct Passkey shape', async () => {
287287
await service.createPasskeyFromRegistrationResponse(
288288
MOCK_UID,
289289
mockResponse,
@@ -293,40 +293,9 @@ describe('PasskeyService', () => {
293293
expect(mockManager.registerPasskey).toHaveBeenCalledWith(
294294
expect.objectContaining({
295295
uid: MOCK_UID,
296-
credentialId: MOCK_CREDENTIAL_ID,
297-
publicKey: MOCK_PUBLIC_KEY,
298-
signCount: 0,
299-
transports: JSON.stringify(['internal']),
300-
aaguid: MOCK_AAGUID_ZEROS,
296+
createdAt: expect.any(Number),
301297
lastUsedAt: null,
302-
backupEligible: 0,
303-
backupState: 0,
304-
prfEnabled: 0,
305-
})
306-
);
307-
});
308-
309-
it('sets backupEligible=1, backupState=1 and prfEnabled=1 when flags are true', async () => {
310-
mockVerifyWebauthnRegistrationResponse.mockResolvedValue({
311-
verified: true,
312-
data: {
313298
...mockVerifiedData,
314-
backupEligible: true,
315-
backupState: true,
316-
prfEnabled: true,
317-
},
318-
});
319-
await service.createPasskeyFromRegistrationResponse(
320-
MOCK_UID,
321-
mockResponse,
322-
MOCK_CHALLENGE
323-
);
324-
325-
expect(mockManager.registerPasskey).toHaveBeenCalledWith(
326-
expect.objectContaining({
327-
backupEligible: 1,
328-
backupState: 1,
329-
prfEnabled: 1,
330299
})
331300
);
332301
});
@@ -350,7 +319,7 @@ describe('PasskeyService', () => {
350319
lastUsedAt: null,
351320
backupEligible: false,
352321
backupState: false,
353-
prfEnabled: false,
322+
prfEnabled: true,
354323
})
355324
);
356325
});

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

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
import { LOGGER_PROVIDER } from '@fxa/shared/log';
1111
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
1212
import * as Sentry from '@sentry/nestjs';
13-
import type { NewPasskey, Passkey } from '@fxa/shared/db/mysql/account';
13+
import type { Passkey } from '@fxa/shared/db/mysql/account';
1414
import type {
1515
AuthenticatorTransportFuture,
1616
PublicKeyCredentialCreationOptionsJSON,
@@ -200,17 +200,7 @@ export class PasskeyService {
200200
...result.data,
201201
};
202202

203-
// TODO: update repository, manager, and service layers to accept/return a
204-
// Passkey object directly instead of mapping to NewPasskey. See FXA-13402.
205-
const newPasskey: NewPasskey = {
206-
...passkey,
207-
transports: JSON.stringify(passkey.transports),
208-
backupEligible: passkey.backupEligible ? 1 : 0,
209-
backupState: passkey.backupState ? 1 : 0,
210-
prfEnabled: passkey.prfEnabled ? 1 : 0,
211-
};
212-
213-
await this.passkeyManager.registerPasskey(newPasskey);
203+
await this.passkeyManager.registerPasskey(passkey);
214204

215205
this.metrics.increment('passkey.registration.success');
216206
this.log?.log('passkey.registered', { uid: uidHex });

libs/shared/db/mysql/account/src/lib/factories.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
Account,
99
AccountCustomer,
1010
NewCart,
11-
NewPasskey,
11+
Passkey,
1212
PaypalCustomer,
1313
SessionToken,
1414
UnverifiedToken,
@@ -184,16 +184,16 @@ export const RecoveryPhoneFactory = (override?: Partial<RecoveryPhone>) => ({
184184
...override,
185185
});
186186

187-
export const PasskeyFactory = (override?: Partial<NewPasskey>): NewPasskey => ({
187+
export const PasskeyFactory = (override?: Partial<Passkey>): Passkey => ({
188188
uid: getHexBuffer(32),
189189
credentialId: getHexBuffer(faker.number.int({ min: 32, max: 128 })),
190190
publicKey: getHexBuffer(128),
191191
signCount: 0,
192192
transports: faker.helpers.arrayElement([
193-
JSON.stringify(['internal']),
194-
JSON.stringify(['usb']),
195-
JSON.stringify(['internal', 'hybrid']),
196-
JSON.stringify([]),
193+
['internal'],
194+
['usb'],
195+
['internal', 'hybrid'],
196+
[],
197197
]),
198198
aaguid: faker.datatype.boolean()
199199
? getHexBuffer(32) // Real AAGUID (32 hex chars = 16 bytes)
@@ -207,8 +207,8 @@ export const PasskeyFactory = (override?: Partial<NewPasskey>): NewPasskey => ({
207207
]),
208208
createdAt: faker.date.recent().getTime(),
209209
lastUsedAt: faker.datatype.boolean() ? faker.date.recent().getTime() : null,
210-
backupEligible: faker.helpers.arrayElement([0, 1]),
211-
backupState: faker.helpers.arrayElement([0, 1]),
212-
prfEnabled: faker.helpers.arrayElement([0, 1]),
210+
backupEligible: faker.datatype.boolean(),
211+
backupState: faker.datatype.boolean(),
212+
prfEnabled: faker.datatype.boolean(),
213213
...override,
214214
});

libs/shared/db/mysql/account/src/lib/kysely-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export interface Passkeys {
248248
credentialId: Buffer;
249249
publicKey: Buffer;
250250
signCount: number;
251-
transports: Json;
251+
transports: JSONColumnType<string[]>;
252252
aaguid: Buffer;
253253
name: string;
254254
createdAt: number;

libs/shared/db/mysql/account/src/test/passkeys.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ CREATE TABLE `passkeys` (
33
`credentialId` varbinary(1023) NOT NULL,
44
`publicKey` blob NOT NULL,
55
`signCount` int unsigned NOT NULL DEFAULT '0',
6-
`transports` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
6+
`transports` JSON NOT NULL,
77
`aaguid` binary(16) DEFAULT NULL,
88
`name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
99
`createdAt` bigint(20) unsigned NOT NULL,

0 commit comments

Comments
 (0)