Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
405 changes: 405 additions & 0 deletions libs/accounts/passkey/PASSKEY_FIELDS.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions libs/accounts/passkey/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ Unlike `libs/shared/nestjs/*`, this library **does not export a NestJS module**.

This pattern gives consuming applications full control over DI setup.

## Database Schema

See [PASSKEY_FIELDS.md](./PASSKEY_FIELDS.md) for complete field documentation including:

- Field types and constraints
- WebAuthn backup flags (BE/BS)
- Type conversion details (ColumnType)
- Usage examples and patterns

## WebAuthn / Passkey Background

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

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

## Error Handling

Expand Down
7 changes: 7 additions & 0 deletions libs/accounts/passkey/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@
* Usage:
* - PasskeyService: High-level business logic for passkey operations
* - PasskeyManager: Database access layer for passkey storage
* - Repository functions: Pure data access functions (findPasskeysByUid, etc.)
* - PasskeyError: Base error class for passkey-specific errors
* - PasskeyConfig: Configuration class
*
* Types (import directly from shared):
* ```typescript
* import { Passkey, NewPasskey, PasskeyUpdate } from '@fxa/shared/db/mysql/account';
* ```
*
* @packageDocumentation
*/
export * from './lib/passkey.service';
export * from './lib/passkey.manager';
export * from './lib/passkey.repository';
export * from './lib/passkey.errors';
export * from './lib/passkey.config';
8 changes: 2 additions & 6 deletions libs/accounts/passkey/src/lib/passkey.manager.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ describe('PasskeyManager (Integration)', () => {

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

const moduleRef = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -48,10 +47,7 @@ describe('PasskeyManager (Integration)', () => {
}
});

// TODO: Add actual integration tests once:
// 1. Passkey database schema is defined
// 2. PasskeyManager methods are implemented
// 3. Test data factories are created
// TODO: Add actual integration tests once PasskeyManager methods are implemented
it('should be defined', () => {
expect(manager).toBeDefined();
});
Expand Down
156 changes: 156 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { faker } from '@faker-js/faker';
import {
AccountDatabase,
testAccountDatabaseSetup,
PasskeyFactory,
} from '@fxa/shared/db/mysql/account';
import { AccountManager } from '@fxa/shared/account/account';
import * as PasskeyRepository from './passkey.repository';

describe('PasskeyRepository (Integration)', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

let db: AccountDatabase;
let accountManager: AccountManager;

beforeAll(async () => {
try {
db = await testAccountDatabaseSetup(['accounts', 'emails', 'passkeys']);
accountManager = new AccountManager(db);
} catch (error) {
console.warn('\n⚠️ Integration tests require database infrastructure.');
console.warn(
' Run "yarn start infrastructure" to enable these tests.\n'
);
throw error;
}
});

// Helper to create an account for testing
async function createTestAccount() {
const email = faker.internet.email();
const uidHex = await accountManager.createAccountStub(email, 1, 'en-US');
return Buffer.from(uidHex, 'hex');
}

afterAll(async () => {
if (db) {
await db.destroy();
}
});

describe('insert and find operations', () => {
it('should insert and retrieve a passkey', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({ uid });

await PasskeyRepository.insertPasskey(db, passkey);

const found = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId
);

expect(found).toBeDefined();
expect(found?.uid).toEqual(passkey.uid);
expect(found?.name).toBe(passkey.name);
expect(found?.transports).toBeDefined(); // JSON field
expect(found?.aaguid).toEqual(passkey.aaguid); // NOT NULL
});

it('should find all passkeys for a user', async () => {
const uid = await createTestAccount();
const passkey1 = PasskeyFactory({ uid });
const passkey2 = PasskeyFactory({ uid });

await PasskeyRepository.insertPasskey(db, passkey1);
await PasskeyRepository.insertPasskey(db, passkey2);

const passkeys = await PasskeyRepository.findPasskeysByUid(db, uid);

expect(passkeys.length).toBeGreaterThanOrEqual(2);
});
});

describe('update operations', () => {
it('should update passkey name', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({ uid });
await PasskeyRepository.insertPasskey(db, passkey);

const rowsUpdated = await PasskeyRepository.updatePasskeyName(
db,
passkey.credentialId,
'New Name'
);

expect(rowsUpdated).toBe(1);

const updated = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId
);
expect(updated?.name).toBe('New Name');
});

it('should update counter and lastUsed after authentication', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({ uid });
await PasskeyRepository.insertPasskey(db, passkey);

const success = await PasskeyRepository.updatePasskeyCounterAndLastUsed(
db,
passkey.credentialId,
5,
1
);

expect(success).toBe(true);

const updated = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId
);
expect(updated?.signCount).toBe(5);
expect(updated?.backupState).toBe(true);
expect(updated?.lastUsedAt).toBeGreaterThan(0);
});
});

describe('delete operations', () => {
it('should delete a specific passkey', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({ uid });
await PasskeyRepository.insertPasskey(db, passkey);

const deleted = await PasskeyRepository.deletePasskey(
db,
passkey.uid,
passkey.credentialId
);

expect(deleted).toBe(true);

const found = await PasskeyRepository.findPasskeyByCredentialId(
db,
passkey.credentialId
);
expect(found).toBeUndefined();
});

it('should count passkeys for a user', async () => {
const uid = await createTestAccount();
const passkey1 = PasskeyFactory({ uid });
const passkey2 = PasskeyFactory({ uid });

await PasskeyRepository.insertPasskey(db, passkey1);
await PasskeyRepository.insertPasskey(db, passkey2);

const count = await PasskeyRepository.countPasskeysByUid(db, uid);

expect(count).toBeGreaterThanOrEqual(2);
});
});
});
Loading
Loading