Skip to content

Commit c6cb243

Browse files
committed
task(libs): Add passkeys data model
Because: * We are adding WebAuthn/FIDO2 passkey support This commit: * Adds database migration creating passkeys table with composite primary key and proper indexing * Generates Kysely types (Passkey, NewPasskey, PasskeyUpdate) with type guards for validation * Implements 8 pure repository functions for CRUD operations following FxA patterns * Creates PasskeyFactory for generating test data and updates integration test infrastructure * Provides comprehensive field documentation in PASSKEY_FIELDS.md covering WebAuthn spec and usage Closes #FXA-12901
1 parent 1f92bad commit c6cb243

16 files changed

Lines changed: 717 additions & 28 deletions

File tree

libs/accounts/passkey/PASSKEY_FIELDS.md

Lines changed: 370 additions & 0 deletions
Large diffs are not rendered by default.

libs/accounts/passkey/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ Unlike `libs/shared/nestjs/*`, this library **does not export a NestJS module**.
6363

6464
This pattern gives consuming applications full control over DI setup.
6565

66+
## Database Schema
67+
68+
See [PASSKEY_FIELDS.md](./PASSKEY_FIELDS.md) for complete field documentation including:
69+
70+
- Field types and constraints
71+
- WebAuthn backup flags (BE/BS)
72+
- Type conversion details (ColumnType)
73+
- Usage examples and patterns
74+
6675
## WebAuthn / Passkey Background
6776

6877
Passkeys are a WebAuthn-based authentication method that replaces passwords:
@@ -84,6 +93,7 @@ Key WebAuthn concepts:
8493

8594
- [WebAuthn Spec](https://www.w3.org/TR/webauthn-3/)
8695
- [Passkey Developer Guide](https://passkeys.dev/)
96+
- [Field Documentation](./PASSKEY_FIELDS.md) (detailed schema reference)
8797

8898
## Error Handling
8999

libs/accounts/passkey/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@
99
* Usage:
1010
* - PasskeyService: High-level business logic for passkey operations
1111
* - PasskeyManager: Database access layer for passkey storage
12+
* - Repository functions: Pure data access functions (findPasskeysByUid, etc.)
1213
* - PasskeyError: Base error class for passkey-specific errors
1314
* - PasskeyConfig: Configuration class
1415
*
16+
* Types (import directly from shared):
17+
* ```typescript
18+
* import { Passkey, NewPasskey, PasskeyUpdate } from '@fxa/shared/db/mysql/account';
19+
* ```
20+
*
1521
* @packageDocumentation
1622
*/
1723
export * from './lib/passkey.service';
1824
export * from './lib/passkey.manager';
25+
export * from './lib/passkey.repository';
1926
export * from './lib/passkey.errors';
2027
export * from './lib/passkey.config';

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ describe('PasskeyManager (Integration)', () => {
1616

1717
beforeAll(async () => {
1818
// Set up real database connection for integration tests
19-
// TODO: Add 'passkeys' table to the setup array once the table schema is created
2019
try {
21-
db = await testAccountDatabaseSetup(['accounts']);
20+
db = await testAccountDatabaseSetup(['accounts', 'passkeys']);
2221

2322
const moduleRef = await Test.createTestingModule({
2423
providers: [
@@ -48,10 +47,7 @@ describe('PasskeyManager (Integration)', () => {
4847
}
4948
});
5049

51-
// TODO: Add actual integration tests once:
52-
// 1. Passkey database schema is defined
53-
// 2. PasskeyManager methods are implemented
54-
// 3. Test data factories are created
50+
// TODO: Add actual integration tests once PasskeyManager methods are implemented
5551
it('should be defined', () => {
5652
expect(manager).toBeDefined();
5753
});

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

Lines changed: 188 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,192 @@
55
/**
66
* Pure data access functions for passkey storage using Kysely query builder.
77
*
8-
* TODO: Implement repository functions once database migration is applied in FXA-12901.
9-
* Expected functions:
10-
* - findPasskeysByUid(db, uid)
11-
* - findPasskeyByCredentialId(db, credentialId)
12-
* - insertPasskey(db, passkey)
13-
* - updatePasskeyCounterAndLastUsed(db, credentialId, signCount)
14-
* - deletePasskey(db, uid, credentialId)
15-
* - deleteAllPasskeysForUser(db, uid)
16-
* - countPasskeysByUid(db, uid)
8+
* These are pure functions that take the database instance as the first parameter,
9+
* following the functional repository pattern used throughout FxA.
10+
*
11+
* Note: On successful authentication, update:
12+
* - lastUsedAt: Current timestamp
13+
* - signCount: Value from authenticator data (should increment)
14+
* - backupState: Current backup state flag (BS) from authenticator data (0 or 1)
15+
*
16+
* On registration:
17+
* - Set backupEligible from authenticator data (BE flag) - 0 or 1
18+
* - Set backupState from authenticator data (BS flag) - 0 or 1
19+
* - Leave lastUsedAt as NULL (never used for authentication)
20+
*
21+
* On failed authentication, do not update anything.
22+
*
23+
* Type conversion: Backup flags are stored as TINYINT(1) in MySQL (0 or 1).
24+
* - For INSERT/UPDATE: Pass numbers (0 or 1)
25+
* - For SELECT: Kysely returns booleans (false or true)
26+
*/
27+
28+
import type {
29+
AccountDatabase,
30+
Passkey,
31+
NewPasskey,
32+
} from '@fxa/shared/db/mysql/account';
33+
34+
/**
35+
* Find all passkeys for a given user.
36+
*
37+
* Note: Results are not ordered. The number of passkeys per user will be
38+
* constrained (typically < 10), so ordering is not necessary and clients
39+
* can sort as needed.
40+
*
41+
* @param db - Database instance
42+
* @param uid - User ID (16-byte Buffer)
43+
* @returns Array of passkeys for the user (unordered)
44+
*/
45+
export async function findPasskeysByUid(
46+
db: AccountDatabase,
47+
uid: Buffer
48+
): Promise<Passkey[]> {
49+
return await db
50+
.selectFrom('passkeys')
51+
.selectAll()
52+
.where('uid', '=', uid)
53+
.execute();
54+
}
55+
56+
/**
57+
* Find a passkey by its credential ID.
58+
*
59+
* @param db - Database instance
60+
* @param credentialId - WebAuthn credential ID (Buffer)
61+
* @returns Passkey if found, undefined otherwise
1762
*/
63+
export async function findPasskeyByCredentialId(
64+
db: AccountDatabase,
65+
credentialId: Buffer
66+
): Promise<Passkey | undefined> {
67+
return await db
68+
.selectFrom('passkeys')
69+
.selectAll()
70+
.where('credentialId', '=', credentialId)
71+
.executeTakeFirst();
72+
}
73+
74+
/**
75+
* Insert a new passkey record.
76+
*
77+
* @param db - Database instance
78+
* @param passkey - New passkey data to insert
79+
*/
80+
export async function insertPasskey(
81+
db: AccountDatabase,
82+
passkey: NewPasskey
83+
): Promise<void> {
84+
await db.insertInto('passkeys').values(passkey).execute();
85+
}
86+
87+
/**
88+
* Update passkey metadata after successful authentication.
89+
*
90+
* Updates lastUsedAt, signCount, and backupState for a passkey.
91+
* Should only be called after successful authentication.
92+
*
93+
* @param db - Database instance
94+
* @param credentialId - WebAuthn credential ID (Buffer)
95+
* @param signCount - New signature count from authenticator data
96+
* @param backupState - Current backup state flag (0 or 1) from authenticator data
97+
*/
98+
export async function updatePasskeyCounterAndLastUsed(
99+
db: AccountDatabase,
100+
credentialId: Buffer,
101+
signCount: number,
102+
backupState: number
103+
): Promise<void> {
104+
await db
105+
.updateTable('passkeys')
106+
.set({
107+
lastUsedAt: Date.now(),
108+
signCount: signCount,
109+
backupState: backupState,
110+
})
111+
.where('credentialId', '=', credentialId)
112+
.execute();
113+
}
114+
115+
/**
116+
* Update the friendly name for a passkey.
117+
*
118+
* @param db - Database instance
119+
* @param credentialId - WebAuthn credential ID (Buffer)
120+
* @param name - New friendly name for the passkey
121+
* @returns Number of rows updated (should be 1 if successful)
122+
*/
123+
export async function updatePasskeyName(
124+
db: AccountDatabase,
125+
credentialId: Buffer,
126+
name: string
127+
): Promise<number> {
128+
const result = await db
129+
.updateTable('passkeys')
130+
.set({ name })
131+
.where('credentialId', '=', credentialId)
132+
.execute();
133+
134+
return result.length;
135+
}
136+
137+
/**
138+
* Delete a specific passkey for a user.
139+
*
140+
* @param db - Database instance
141+
* @param uid - User ID (16-byte Buffer)
142+
* @param credentialId - WebAuthn credential ID (Buffer)
143+
* @returns true if a passkey was deleted, false otherwise
144+
*/
145+
export async function deletePasskey(
146+
db: AccountDatabase,
147+
uid: Buffer,
148+
credentialId: Buffer
149+
): Promise<boolean> {
150+
const result = await db
151+
.deleteFrom('passkeys')
152+
.where('uid', '=', uid)
153+
.where('credentialId', '=', credentialId)
154+
.executeTakeFirst();
155+
156+
return result.numDeletedRows === BigInt(1);
157+
}
158+
159+
/**
160+
* Delete all passkeys for a user.
161+
*
162+
* @param db - Database instance
163+
* @param uid - User ID (16-byte Buffer)
164+
* @returns Number of passkeys deleted
165+
*/
166+
export async function deleteAllPasskeysForUser(
167+
db: AccountDatabase,
168+
uid: Buffer
169+
): Promise<number> {
170+
const result = await db
171+
.deleteFrom('passkeys')
172+
.where('uid', '=', uid)
173+
.executeTakeFirst();
174+
175+
return Number(result.numDeletedRows);
176+
}
177+
178+
/**
179+
* Count the number of passkeys for a user.
180+
*
181+
* @param db - Database instance
182+
* @param uid - User ID (16-byte Buffer)
183+
* @returns Number of passkeys for the user
184+
*/
185+
export async function countPasskeysByUid(
186+
db: AccountDatabase,
187+
uid: Buffer
188+
): Promise<number> {
189+
const result = await db
190+
.selectFrom('passkeys')
191+
.select(db.fn.count('credentialId').as('count'))
192+
.where('uid', '=', uid)
193+
.executeTakeFirst();
194+
195+
return Number(result?.count ?? 0);
196+
}

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@ import { PasskeyManager } from './passkey.manager';
1616
* - Passkey management (list, rename, delete)
1717
* - Challenge generation and verification
1818
*
19+
* ## WebAuthn Library Integration
20+
*
21+
* This service will use a WebAuthn library (e.g., @simplewebauthn/server) for:
22+
* - Challenge generation and validation
23+
* - Cryptographic signature verification
24+
* - CBOR/COSE parsing (publicKey, credentialId, authenticator data)
25+
* - Extracting: signCount, transports, aaguid, backup flags (BE/BS)
26+
*
27+
* The library handles WebAuthn spec compliance and crypto operations.
28+
* This service translates between WebAuthn responses and our database model.
29+
*
30+
* ## Data Normalization
31+
*
32+
* When storing passkey data from WebAuthn library responses:
33+
* - **AAGUID**: Normalize all-zeros (00000000-0000-0000-0000-000000000000) to NULL
34+
* Many authenticators return all-zeros for privacy. Store NULL when meaningless.
35+
* - **transports**: Trust library-provided JSON array string (validated by library)
36+
* - **backupEligible/backupState**: Extract from authenticator data flags (BE/BS bits)
37+
*
1938
*/
2039
@Injectable()
2140
export class PasskeyService {
@@ -27,10 +46,24 @@ export class PasskeyService {
2746

2847
// TODO: Add methods for passkey operations such as:
2948
// - generateRegistrationChallenge
30-
// - verifyRegistrationResponse
49+
// - verifyRegistrationResponse (normalize AAGUID here before storing)
3150
// - generateAuthenticationChallenge
32-
// - verifyAuthenticationResponse
51+
// - verifyAuthenticationResponse (extract backup state, signCount, validate rollback)
3352
// - listPasskeysForUser
3453
// - renamePasskey
3554
// - deletePasskey
55+
//
56+
// TODO: Add normalizeAaguid() helper:
57+
// function normalizeAaguid(aaguid: Buffer | null | undefined): Buffer | null {
58+
// if (!aaguid || aaguid.length !== 16) return null;
59+
// if (aaguid.every(byte => byte === 0)) return null;
60+
// return aaguid;
61+
// }
62+
//
63+
// TODO: Add signCount rollback detection in verifyAuthenticationResponse():
64+
// - Fetch existing passkey with current signCount
65+
// - Compare new signCount from authenticator response
66+
// - If new < old AND old > 0: Log security warning (potential cloning attack)
67+
// - Allow authenticators that always return 0 (batch attestation per spec)
68+
// - Log event: 'passkey.signCount.rollback' with uid, credentialId, oldCount, newCount
3669
}

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

Lines changed: 0 additions & 9 deletions
This file was deleted.

libs/shared/db/mysql/account/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
AccountFactory,
1010
AccountCustomerFactory,
1111
PaypalCustomerFactory,
12+
PasskeyFactory,
1213
RecoveryPhoneFactory,
1314
} from './lib/factories';
1415
export { setupAccountDatabase, AccountDbProvider } from './lib/setup';

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
AccountCustomers,
1111
Accounts,
1212
Carts,
13+
Passkeys,
1314
PaypalCustomers,
1415
SessionTokens,
1516
UnverifiedTokens,
@@ -43,3 +44,7 @@ export type CartUpdate = Updateable<Carts>;
4344
export type RecoveryPhone = Selectable<RecoveryPhones>;
4445
export type NewRecoveryPhone = Insertable<RecoveryPhones>;
4546
export type RecoveryPhoneUpdate = Updateable<RecoveryPhones>;
47+
48+
export type Passkey = Selectable<Passkeys>;
49+
export type NewPasskey = Insertable<Passkeys>;
50+
export type PasskeyUpdate = Updateable<Passkeys>;

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Account,
99
AccountCustomer,
1010
NewCart,
11+
NewPasskey,
1112
PaypalCustomer,
1213
SessionToken,
1314
UnverifiedToken,
@@ -182,3 +183,30 @@ export const RecoveryPhoneFactory = (override?: Partial<RecoveryPhone>) => ({
182183
}),
183184
...override,
184185
});
186+
187+
export const PasskeyFactory = (override?: Partial<NewPasskey>): NewPasskey => ({
188+
uid: getHexBuffer(32),
189+
credentialId: getHexBuffer(faker.number.int({ min: 32, max: 128 })),
190+
publicKey: getHexBuffer(128),
191+
signCount: 0,
192+
transports: faker.helpers.arrayElement([
193+
'["internal"]',
194+
'["usb"]',
195+
'["internal","hybrid"]',
196+
null,
197+
]),
198+
aaguid: faker.datatype.boolean() ? getHexBuffer(32) : null,
199+
name: faker.helpers.arrayElement([
200+
'Touch ID',
201+
'YubiKey 5',
202+
'Security Key',
203+
'iPhone Face ID',
204+
null,
205+
]),
206+
createdAt: faker.date.recent().getTime(),
207+
lastUsedAt: faker.datatype.boolean() ? faker.date.recent().getTime() : null,
208+
backupEligible: faker.helpers.arrayElement([0, 1]),
209+
backupState: faker.helpers.arrayElement([0, 1]),
210+
prfEnabled: faker.helpers.arrayElement([0, 1]),
211+
...override,
212+
});

0 commit comments

Comments
 (0)