Skip to content

Commit 30e2ded

Browse files
authored
Merge pull request #20019 from mozilla/FXA-12901
task(libs): Add passkeys data model and db migration
2 parents 48e4d68 + 512c441 commit 30e2ded

17 files changed

Lines changed: 949 additions & 27 deletions

File tree

libs/accounts/passkey/PASSKEY_FIELDS.md

Lines changed: 405 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
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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 { faker } from '@faker-js/faker';
6+
import {
7+
AccountDatabase,
8+
testAccountDatabaseSetup,
9+
PasskeyFactory,
10+
} from '@fxa/shared/db/mysql/account';
11+
import { AccountManager } from '@fxa/shared/account/account';
12+
import * as PasskeyRepository from './passkey.repository';
13+
14+
describe('PasskeyRepository (Integration)', () => {
15+
let db: AccountDatabase;
16+
let accountManager: AccountManager;
17+
18+
beforeAll(async () => {
19+
try {
20+
db = await testAccountDatabaseSetup(['accounts', 'emails', 'passkeys']);
21+
accountManager = new AccountManager(db);
22+
} catch (error) {
23+
console.warn('\n⚠️ Integration tests require database infrastructure.');
24+
console.warn(
25+
' Run "yarn start infrastructure" to enable these tests.\n'
26+
);
27+
throw error;
28+
}
29+
});
30+
31+
// Helper to create an account for testing
32+
async function createTestAccount() {
33+
const email = faker.internet.email();
34+
const uidHex = await accountManager.createAccountStub(email, 1, 'en-US');
35+
return Buffer.from(uidHex, 'hex');
36+
}
37+
38+
afterAll(async () => {
39+
if (db) {
40+
await db.destroy();
41+
}
42+
});
43+
44+
describe('insert and find operations', () => {
45+
it('should insert and retrieve a passkey', async () => {
46+
const uid = await createTestAccount();
47+
const passkey = PasskeyFactory({ uid });
48+
49+
await PasskeyRepository.insertPasskey(db, passkey);
50+
51+
const found = await PasskeyRepository.findPasskeyByCredentialId(
52+
db,
53+
passkey.credentialId
54+
);
55+
56+
expect(found).toBeDefined();
57+
expect(found?.uid).toEqual(passkey.uid);
58+
expect(found?.name).toBe(passkey.name);
59+
expect(found?.transports).toBeDefined(); // JSON field
60+
expect(found?.aaguid).toEqual(passkey.aaguid); // NOT NULL
61+
});
62+
63+
it('should find all passkeys for a user', async () => {
64+
const uid = await createTestAccount();
65+
const passkey1 = PasskeyFactory({ uid });
66+
const passkey2 = PasskeyFactory({ uid });
67+
68+
await PasskeyRepository.insertPasskey(db, passkey1);
69+
await PasskeyRepository.insertPasskey(db, passkey2);
70+
71+
const passkeys = await PasskeyRepository.findPasskeysByUid(db, uid);
72+
73+
expect(passkeys.length).toBeGreaterThanOrEqual(2);
74+
});
75+
});
76+
77+
describe('update operations', () => {
78+
it('should update passkey name', async () => {
79+
const uid = await createTestAccount();
80+
const passkey = PasskeyFactory({ uid });
81+
await PasskeyRepository.insertPasskey(db, passkey);
82+
83+
const rowsUpdated = await PasskeyRepository.updatePasskeyName(
84+
db,
85+
passkey.credentialId,
86+
'New Name'
87+
);
88+
89+
expect(rowsUpdated).toBe(1);
90+
91+
const updated = await PasskeyRepository.findPasskeyByCredentialId(
92+
db,
93+
passkey.credentialId
94+
);
95+
expect(updated?.name).toBe('New Name');
96+
});
97+
98+
it('should update counter and lastUsed after authentication', async () => {
99+
const uid = await createTestAccount();
100+
const passkey = PasskeyFactory({ uid });
101+
await PasskeyRepository.insertPasskey(db, passkey);
102+
103+
const success = await PasskeyRepository.updatePasskeyCounterAndLastUsed(
104+
db,
105+
passkey.credentialId,
106+
5,
107+
1
108+
);
109+
110+
expect(success).toBe(true);
111+
112+
const updated = await PasskeyRepository.findPasskeyByCredentialId(
113+
db,
114+
passkey.credentialId
115+
);
116+
expect(updated?.signCount).toBe(5);
117+
expect(updated?.backupState).toBe(true);
118+
expect(updated?.lastUsedAt).toBeGreaterThan(0);
119+
});
120+
});
121+
122+
describe('delete operations', () => {
123+
it('should delete a specific passkey', async () => {
124+
const uid = await createTestAccount();
125+
const passkey = PasskeyFactory({ uid });
126+
await PasskeyRepository.insertPasskey(db, passkey);
127+
128+
const deleted = await PasskeyRepository.deletePasskey(
129+
db,
130+
passkey.uid,
131+
passkey.credentialId
132+
);
133+
134+
expect(deleted).toBe(true);
135+
136+
const found = await PasskeyRepository.findPasskeyByCredentialId(
137+
db,
138+
passkey.credentialId
139+
);
140+
expect(found).toBeUndefined();
141+
});
142+
143+
it('should count passkeys for a user', async () => {
144+
const uid = await createTestAccount();
145+
const passkey1 = PasskeyFactory({ uid });
146+
const passkey2 = PasskeyFactory({ uid });
147+
148+
await PasskeyRepository.insertPasskey(db, passkey1);
149+
await PasskeyRepository.insertPasskey(db, passkey2);
150+
151+
const count = await PasskeyRepository.countPasskeysByUid(db, uid);
152+
153+
expect(count).toBeGreaterThanOrEqual(2);
154+
});
155+
});
156+
});

0 commit comments

Comments
 (0)