Skip to content

Commit 512c441

Browse files
committed
address PR review comments
1 parent c6cb243 commit 512c441

8 files changed

Lines changed: 309 additions & 76 deletions

File tree

libs/accounts/passkey/PASSKEY_FIELDS.md

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ This document provides detailed information about the passkey data model and fie
1313

1414
## Quick Reference
1515

16-
| Field | Type | Required | Description |
17-
| -------------- | ------------------ | --------------------------- | ----------------------------------------- |
18-
| uid | Buffer(16) | Yes | User ID (FK to accounts.uid) |
19-
| credentialId | Buffer(1-1023) | Yes | WebAuthn credential ID |
20-
| publicKey | Buffer | Yes | COSE-encoded public key |
21-
| signCount | number | Yes | Signature counter (default 0) |
22-
| transports | string \| null | No | JSON array of transport types |
23-
| aaguid | Buffer(16) \| null | No | Authenticator AAGUID |
24-
| name | string \| null | No | Friendly name (recommended auto-generate) |
25-
| createdAt | number | Yes | Unix timestamp (ms) |
26-
| lastUsedAt | number \| null | Field: Yes, Value: Nullable | Last auth timestamp (ms) |
27-
| backupEligible | boolean\* | No (default: 0) | Can be backed up |
28-
| backupState | boolean\* | No (default: 0) | Is currently backed up |
29-
| prfEnabled | boolean\* | No (default: 0) | PRF extension enabled |
16+
| Field | Type | Required | Description |
17+
| -------------- | -------------- | --------------------------- | --------------------------------------- |
18+
| uid | Buffer(16) | Yes | User ID (FK to accounts.uid) |
19+
| credentialId | Buffer(1-1023) | Yes | WebAuthn credential ID |
20+
| publicKey | Buffer | Yes | COSE-encoded public key |
21+
| signCount | number | Yes | Signature counter (default 0) |
22+
| transports | string[] | Yes | Array of transport types (JSON in DB) |
23+
| aaguid | Buffer(16) | Yes | Authenticator AAGUID (may be all-zeros) |
24+
| name | string | Yes | Friendly name (auto-generate required) |
25+
| createdAt | number | Yes | Unix timestamp (ms) |
26+
| lastUsedAt | number \| null | Field: Yes, Value: Nullable | Last auth timestamp (ms) |
27+
| backupEligible | boolean\* | No (default: 0) | Can be backed up |
28+
| backupState | boolean\* | No (default: 0) | Is currently backed up |
29+
| prfEnabled | boolean\* | No (default: 0) | PRF extension enabled |
3030

3131
\*Stored as TINYINT(1) in MySQL with DEFAULT 0, converted to boolean by Kysely
3232

@@ -84,27 +84,30 @@ This document provides detailed information about the passkey data model and fie
8484

8585
### transports
8686

87-
- **Type**: string | null
88-
- **Storage**: VARCHAR(255)
89-
- **Description**: JSON-encoded array of authenticator transport methods
87+
- **Type**: string[] (JSON array in database)
88+
- **Required**: Yes
89+
- **Storage**: JSON NOT NULL
90+
- **Description**: Array of authenticator transport methods
9091
- **Spec**: [AuthenticatorTransport enum](https://www.w3.org/TR/webauthn-3/#enum-transport)
9192
- **Source**: From [PublicKeyCredentialDescriptor.transports](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-transports)
9293
- **Validation**: Provided by WebAuthn library (e.g., @simplewebauthn/server) which validates spec compliance
94+
- **Storage Pattern**: Store `[]` (empty array) when not provided, or JSON array like `["internal","hybrid"]`
9395
- **Valid Values**:
9496
- `"internal"` - Platform authenticator (Touch ID, Windows Hello)
9597
- `"usb"` - USB security key
9698
- `"nfc"` - NFC-enabled authenticator
9799
- `"ble"` - Bluetooth Low Energy
98100
- `"hybrid"` - Cloud-assisted BLE (formerly caBLE), per [CTAP 2.2](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html)
99101
- `"smart-card"` - Smart card authenticator
100-
- **Example**: `'["internal","hybrid"]'` for iCloud Keychain
102+
- **Example**: `["internal","hybrid"]` for iCloud Keychain
101103
- **Usage**: Helps UI show appropriate icons and authentication prompts
102-
- **Storage Format**: JSON array as string for efficient querying
104+
- **Database Type**: MySQL JSON for automatic validation and efficient JSON queries
103105

104106
### aaguid
105107

106-
- **Type**: Buffer(16) | null
107-
- **Storage**: BINARY(16)
108+
- **Type**: Buffer(16)
109+
- **Required**: Yes
110+
- **Storage**: BINARY(16) NOT NULL
108111
- **Description**: Authenticator Attestation GUID
109112
- **Spec**: [AAGUID](https://www.w3.org/TR/webauthn-3/#aaguid)
110113
- **Source**: From authenticator's [attestedCredentialData](https://www.w3.org/TR/webauthn-3/#attested-credential-data)
@@ -118,34 +121,37 @@ This document provides detailed information about the passkey data model and fie
118121
- Software authenticators (browser built-in passkey managers)
119122
- Privacy-focused hardware keys
120123
- Platform authenticators (depending on policy)
121-
- **Normalization**: The PasskeyService normalizes all-zeros AAGUID to `NULL` before storage
122-
- WebAuthn libraries return the raw AAGUID buffer from authenticator data
123-
- Service layer converts all-zeros to NULL (no meaningful identifier)
124-
- Only actual AAGUID values that identify specific authenticator models are stored
124+
- **Storage**: Store the AAGUID exactly as provided by the authenticator (including all-zeros)
125+
- No normalization needed - all-zeros is a valid value per WebAuthn spec
126+
- All-zeros means "privacy-preserving, no authenticator identifier"
127+
- Simplifies implementation by avoiding special handling
125128

126-
**When to expect meaningful AAGUIDs:**
129+
**When AAGUIDs identify specific authenticators:**
127130

128131
- Hardware security keys (YubiKey, Titan Key, Feitian)
129132
- Some platform authenticators (Windows Hello, Touch ID on certain configurations)
130133
- Enterprise authenticators
131134

132-
- **Usage**:
133-
- Track which authenticator models are in use
134-
- Apply vendor-specific quirks if needed
135-
- Analytics on authenticator adoption
136-
- Security policies (e.g., allow/deny specific authenticator models)
137-
- Auto-generate passkey names via FIDO MDS lookup (when not all-zeros)
135+
**Usage:**
136+
137+
- Track which authenticator models are in use
138+
- Apply vendor-specific quirks if needed
139+
- Analytics on authenticator adoption
140+
- Security policies (e.g., allow/deny specific authenticator models)
141+
- Auto-generate passkey names via FIDO MDS lookup (skip if all-zeros)
138142
- **Example**: YubiKey 5 has AAGUID `2fc0579f-8113-47ea-b116-bb5a8db9202a`, Windows Hello uses `08987058-cadc-4b81-b6e1-30de50dcbe96`
139143
- **Registry**: [FIDO Alliance Metadata Service](https://fidoalliance.org/metadata/) provides AAGUID registry
140144

141145
## User Management Fields
142146

143147
### name
144148

145-
- **Type**: string | null
146-
- **Storage**: VARCHAR(255)
149+
- **Type**: string
150+
- **Required**: Yes
151+
- **Storage**: VARCHAR(255) NOT NULL
147152
- **Description**: User-friendly name for the passkey
148153
- **Note**: FxA-specific field for UX, not part of WebAuthn spec
154+
- **Important**: Must always be provided during registration (auto-generated or user-provided)
149155
- **Examples**:
150156
- "Touch ID" (auto-generated from platform)
151157
- "YubiKey 5" (auto-generated from AAGUID)
@@ -172,12 +178,19 @@ Generate a descriptive default name using available metadata:
172178
- "iPhone Face ID"
173179
- "YubiKey 5 (Work Laptop)"
174180

181+
**Database Requirement:**
182+
183+
- The `name` field is **NOT NULL** in the database schema
184+
- Implementation **MUST** always provide a name during registration
185+
- Never attempt to insert a passkey without a name (will fail DB constraint)
186+
175187
**Best Practices:**
176188

177-
- Always set a default name on registration (never leave NULL)
178-
- Allow users to rename credentials later
189+
- Auto-generate descriptive names using the strategy above
190+
- Allow users to rename credentials after registration
179191
- Include date/device info for multiple credentials of same type
180192
- Example: "Touch ID (MacBook Pro, Jan 2025)"
193+
- Minimum fallback: Use "Passkey" if all metadata lookups fail
181194

182195
### createdAt
183196

@@ -314,15 +327,37 @@ The PRF (Pseudo-Random Function) extension allows authenticators to derive deter
314327

315328
### Type Conversion (Kysely ColumnType)
316329

317-
Backup flags use `Generated<ColumnType<boolean, number, number>>`:
330+
**Backup flags** use `Generated<ColumnType<boolean, number, number>>`:
318331

319332
- **SELECT**: Returns `boolean` (true/false) - always present
320333
- **INSERT**: Accepts `number | undefined` (0, 1, or omit for default 0) - optional
321334
- **UPDATE**: Accepts `number | undefined` (0, 1, or omit to keep current) - optional
322335

323336
The `Generated<...>` wrapper makes these fields optional on INSERT/UPDATE (matching the database DEFAULT 0), while ensuring they're always present as booleans on SELECT.
324337

325-
This provides ergonomic boolean usage in application code while storing efficiently in MySQL.
338+
**JSON fields** (`transports`):
339+
340+
- **Database**: MySQL JSON type for automatic validation
341+
- **TypeScript**: `Json` type from Kysely (represents JSON values)
342+
- **INSERT/UPDATE**: Provide as JavaScript value (e.g., `['internal', 'hybrid']`) or JSON string
343+
- **SELECT**: Returns parsed JSON value
344+
- **Important**: Always provide at least `[]` (empty array) - never undefined/null
345+
- **Example**:
346+
347+
```typescript
348+
// Insert
349+
await db.insertInto('passkeys').values({
350+
transports: ['internal', 'hybrid'], // Kysely handles serialization
351+
// ... other fields
352+
});
353+
354+
// Query result
355+
const passkey = await db
356+
.selectFrom('passkeys')
357+
.selectAll()
358+
.executeTakeFirst();
359+
const transports = passkey.transports; // Already parsed, e.g., ['internal', 'hybrid']
360+
```
326361

327362
### Indexes
328363

@@ -333,8 +368,8 @@ Note: No additional indexes needed. The number of passkeys per user is constrain
333368

334369
### Foreign Keys
335370

336-
- `uid` references `accounts(uid)` with `ON DELETE CASCADE`
337-
- When an account is deleted, all associated passkeys are automatically removed
371+
- `uid` references `accounts(uid)` (no CASCADE - handled by deleteAccount stored procedure)
372+
- When an account is deleted, passkeys are explicitly deleted via `deleteAccount_22` stored procedure
338373

339374
## Implementation Resources
340375

@@ -366,5 +401,5 @@ For generating fallback names when AAGUID is unavailable:
366401

367402
## Migration References
368403

369-
- **Forward Migration**: `packages/db-migrations/databases/fxa/patches/patch-182-183.sql`
370-
- **Rollback Migration**: `packages/db-migrations/databases/fxa/patches/patch-183-182.sql`
404+
- **Forward Migration**: `packages/db-migrations/databases/fxa/patches/patch-184-185.sql`
405+
- **Rollback Migration**: `packages/db-migrations/databases/fxa/patches/patch-185-184.sql`
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)