From bd9be3e441db91fbd992b96bc2c302f89329d5dc Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 16:46:02 +0800 Subject: [PATCH] Add comprehensive WebAuthn e2e/unit tests and virtual authenticator package - Create @reviq/virtual-authenticator package with cryptographically valid WebAuthn credential generation for testing - Add e2e tests for WebAuthn registration, authentication, passkey management - Add unit tests for passkey-helpers and VirtualAuthenticator - Add security tests for counter replay and tampered responses - Configure test database environment in devenv.nix - Add turbo.json test tasks and workspace configuration Test results: 98 tests passing (54 virtual-authenticator, 25 e2e, 19 unit) Co-Authored-By: Claude Opus 4.5 --- apps/api-server/README.md | 210 ++++ apps/api-server/package.json | 7 +- .../src/__tests__/e2e/webauthn.test.ts | 1039 +++++++++++++++++ .../src/__tests__/helpers/test-constants.ts | 26 + .../src/__tests__/helpers/test-db.ts | 222 ++++ .../__tests__/unit/passkey-helpers.test.ts | 191 +++ apps/api-server/src/router.ts | 15 +- apps/api-server/src/utils/passkey-helpers.ts | 4 +- bun.lock | 20 + db/schema.sql | 4 +- devenv.nix | 9 +- docs/initial-app.md | 22 +- package.json | 3 +- .../virtual-authenticator/eslint.config.js | 12 + .../virtual-authenticator/package.json | 26 + .../__tests__/virtual-authenticator.test.ts | 437 +++++++ .../virtual-authenticator/src/index.ts | 8 + .../src/virtual-authenticator.ts | 434 +++++++ .../virtual-authenticator/tsconfig.json | 15 + turbo.json | 11 + 20 files changed, 2694 insertions(+), 21 deletions(-) create mode 100644 apps/api-server/README.md create mode 100644 apps/api-server/src/__tests__/e2e/webauthn.test.ts create mode 100644 apps/api-server/src/__tests__/helpers/test-constants.ts create mode 100644 apps/api-server/src/__tests__/helpers/test-db.ts create mode 100644 apps/api-server/src/__tests__/unit/passkey-helpers.test.ts create mode 100644 packages/testing/virtual-authenticator/eslint.config.js create mode 100644 packages/testing/virtual-authenticator/package.json create mode 100644 packages/testing/virtual-authenticator/src/__tests__/virtual-authenticator.test.ts create mode 100644 packages/testing/virtual-authenticator/src/index.ts create mode 100644 packages/testing/virtual-authenticator/src/virtual-authenticator.ts create mode 100644 packages/testing/virtual-authenticator/tsconfig.json diff --git a/apps/api-server/README.md b/apps/api-server/README.md new file mode 100644 index 0000000..9821102 --- /dev/null +++ b/apps/api-server/README.md @@ -0,0 +1,210 @@ +# API Server + +Backend API server for the publisher dashboard. + +## Development + +```bash +# Start development server +bun run dev + +# Type check +bun run typecheck + +# Lint +bun run lint +``` + +## Testing + +### Running Tests + +```bash +# Run e2e tests (requires PostgreSQL) +bun run test:e2e + +# Run unit tests +bun run test:unit +``` + +### E2E Test Setup + +E2E tests use a real PostgreSQL database. The test infrastructure handles: + +1. **Database creation** - Creates the test database if it doesn't exist +2. **Migrations** - Runs dbmate migrations before tests +3. **Cleanup** - Truncates tables between test files + +#### Environment + +Set `TEST_DATABASE_URL` in your environment (devenv.nix sets this automatically): + +```bash +TEST_DATABASE_URL=postgres://reviq:reviq@localhost/reviq-dashboard_test +``` + +### Writing E2E Tests + +Create test files in `src/__tests__/e2e/`. E2E tests should call router handlers directly using the `call` function from `@orpc/server`. + +#### Basic Setup + +```typescript +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import type { Kysely } from "kysely"; +import type { Database } from "@reviq/db-schema"; +import { call } from "@orpc/server"; +import { router } from "../../router.js"; +import type { AuthenticatedContext } from "../../context.js"; +import { + createTestDb, + createTestUser, + destroyTestDb, + runMigrations, + truncateAllTables, +} from "../helpers/test-db.js"; + +let db: Kysely; + +beforeAll(async () => { + await runMigrations(); + db = createTestDb(); +}); + +afterAll(async () => { + await destroyTestDb(db); +}); + +describe("my feature", () => { + beforeAll(async () => { + await truncateAllTables(db); + }); + + test("does something", async () => { + const user = await createTestUser(db, { email: "test@example.com" }); + expect(user.id).toBeGreaterThan(0); + }); +}); +``` + +#### Calling Router Handlers + +Use `call()` from `@orpc/server` to invoke router handlers directly with the appropriate context: + +```typescript +import { call } from "@orpc/server"; +import { router } from "../../router.js"; +import type { AuthenticatedContext } from "../../context.js"; + +// Create a context object for authenticated endpoints +function createAuthContext(userId: number, email: string): AuthenticatedContext { + return { + db, + origin: "http://localhost:3000", + allowedOrigins: ["http://localhost:3000"], + rpName: "Test App", + user: { + id: userId, + email, + displayName: null, + emailVerifiedAt: null, + isSuperuser: false, + }, + session: { + id: 1, + trustedMode: false, + createdAt: new Date(), + }, + }; +} + +test("lists passkeys via router", async () => { + const user = await createTestUser(db, { email: "test@example.com" }); + const ctx = createAuthContext(user.id, user.email); + + // Call router handler directly + const passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + + expect(passkeys).toHaveLength(0); +}); + +test("renames passkey via router", async () => { + const user = await createTestUser(db, { email: "test@example.com" }); + const ctx = createAuthContext(user.id, user.email); + + // Call with input + await call( + router.me.passkeys.rename, + { passkeyId: 1, name: "My Key" }, + { context: ctx } + ); +}); + +test("handles errors from router", async () => { + const user = await createTestUser(db, { email: "test@example.com" }); + const ctx = createAuthContext(user.id, user.email); + + // Expect router to throw + await expect( + call(router.me.passkeys.delete, { passkeyId: 999 }, { context: ctx }) + ).rejects.toThrow(); +}); +``` + +#### Context Types + +Different endpoints require different context types: + +| Context Type | Use Case | +|-------------|----------| +| `APIContext` | Public endpoints (no auth required) | +| `AuthenticatedContext` | Protected endpoints (requires user session) | +| `LoginRequestContext` | Login flow endpoints | + +See `src/context.ts` for the full interface definitions. + +### Test Helpers + +#### `test-db.ts` + +| Function | Description | +|----------|-------------| +| `createTestDb()` | Creates a Kysely connection to the test database | +| `runMigrations()` | Runs dbmate migrations (creates DB if needed) | +| `truncateAllTables(db)` | Truncates all tables with CASCADE | +| `createTestUser(db, overrides?)` | Creates a test user with optional overrides | +| `destroyTestDb(db)` | Closes the database connection | + +#### `test-constants.ts` + +Test constants for RP configuration and known values: + +```typescript +import { TEST_RP, KNOWN_AAGUIDS } from "../helpers/test-constants.js"; +``` + +#### VirtualAuthenticator + +For WebAuthn testing, generates real cryptographic credentials. Available from the `@reviq/virtual-authenticator` package: + +```typescript +import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; + +const authenticator = new VirtualAuthenticator({ + origin: "http://localhost:3000", +}); + +// Registration +const regResponse = await authenticator.createCredential(regOptions); + +// Authentication +const authResponse = await authenticator.getAssertion(authOptions); +``` + +### Test Isolation + +- Tests run serially (`--no-parallel`) to avoid database conflicts +- Each test file should call `truncateAllTables()` in `beforeAll` +- Use unique emails/identifiers per test to avoid collisions within a file diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 7326ef2..3319240 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -8,7 +8,9 @@ "build": "bun build src/index.ts --outdir dist", "typecheck": "tsc --noEmit", "lint": "eslint . --cache", - "clean": "rm -rf dist .eslintcache" + "clean": "rm -rf dist .eslintcache", + "test:e2e": "bun test src/__tests__/e2e --no-parallel", + "test:unit": "bun test src/__tests__/unit" }, "dependencies": { "@orpc/server": "^1.13.2", @@ -22,8 +24,11 @@ "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", + "@reviq/virtual-authenticator": "workspace:*", "@types/bun": "catalog:", + "@types/pg": "^8.16.0", "eslint": "catalog:", + "pg": "^8.16.3", "typescript": "catalog:" } } diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts new file mode 100644 index 0000000..fce99dd --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -0,0 +1,1039 @@ +/** + * End-to-end tests for WebAuthn functionality + * + * These tests use a real PostgreSQL database and a virtual authenticator + * to exercise the full WebAuthn registration and authentication flows. + * + * All tests call router handlers directly via `call()` from @orpc/server. + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; +import type { + APIContext, + AuthenticatedContext, + LoginRequestContext, +} from "../../context.js"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { call } from "@orpc/server"; +import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; +import { router } from "../../router.js"; +import { getUserPasskeys } from "../../utils/webauthn.js"; +import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; +import { + createTestDb, + createTestUser, + destroyTestDb, + runMigrations, + truncateAllTables, +} from "../helpers/test-db.js"; + +let db: Kysely | undefined; + +/** + * Get the database connection, throwing if not initialized + */ +function getDb(): Kysely { + if (!db) { + throw new Error("Database not initialized"); + } + return db; +} + +/** + * Create an API context (for public endpoints) + */ +function createAPIContext(): APIContext { + return { + db: getDb(), + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + }; +} + +/** + * Create an authenticated context (for protected endpoints) + */ +function createAuthenticatedContext( + userId: number, + email: string, +): AuthenticatedContext { + return { + ...createAPIContext(), + user: { + id: userId, + email, + displayName: null, + emailVerifiedAt: null, + isSuperuser: false, + }, + session: { + id: 1, + trustedMode: false, + createdAt: new Date(), + }, + }; +} + +/** + * Create a login request context (for login flow endpoints) + */ +function createLoginRequestContext( + userId: number, + email: string, +): LoginRequestContext { + return { + ...createAPIContext(), + loginRequestId: 1, + user: { + id: userId, + email, + displayName: null, + emailVerifiedAt: null, + isSuperuser: false, + }, + }; +} + +/** + * Helper to get the first element of an array, throwing if empty. + * Provides consistent null checking across tests. + */ +function expectFirst(arr: T[], message: string): T { + const first = arr[0]; + if (!first) { + throw new Error(message); + } + return first; +} + +/** + * Register a passkey using router handlers. + * Shared helper to avoid duplication across test suites. + */ +async function registerPasskey( + userId: number, + email: string, + authenticator: VirtualAuthenticator, +) { + const apiCtx = createAPIContext(); + const authCtx = createAuthenticatedContext(userId, email); + + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email }, + { context: apiCtx }, + ); + const response = authenticator.createCredential(options); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + return response; +} + +/** + * Authenticate using router handlers. + * Shared helper to avoid duplication across test suites. + */ +async function authenticate( + userId: number, + email: string, + authenticator: VirtualAuthenticator, +) { + const loginCtx = createLoginRequestContext(userId, email); + + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const response = authenticator.getAssertion(options); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId, response }, + { context: loginCtx }, + ); +} + +beforeAll(async () => { + // Run migrations and create test database connection + await runMigrations(); + db = createTestDb(); +}); + +afterAll(async () => { + if (db) { + await destroyTestDb(db); + } +}); + +describe("registration flow", () => { + beforeAll(async () => { + await truncateAllTables(getDb()); + }); + + test("creates registration options with challenge stored in DB via router", async () => { + const user = await createTestUser(getDb(), { + email: "reg-options@test.com", + }); + const ctx = createAPIContext(); + + // Call router handler directly + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: ctx }, + ); + + // Verify options structure + expect(options.challenge).toBeDefined(); + expect(options.rp.name).toBe(TEST_RP.rpName); + expect(options.rp.id).toBe(TEST_RP.rpID); + // user.name is displayName if available, otherwise email + expect(options.user.name).toBe("Test User"); + expect(challengeId).toBeGreaterThan(0); + + // Verify challenge is stored in database + const challengeRow = await db + ?.selectFrom("webauthn_challenges") + .select("id") + .where("id", "=", String(challengeId)) + .executeTakeFirst(); + + expect(challengeRow).toBeDefined(); + }); + + test("verifies valid registration and stores passkey via router", async () => { + const user = await createTestUser(getDb(), { + email: "reg-verify@test.com", + }); + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + + // Create registration options via router + const apiCtx = createAPIContext(); + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + + // Create credential with virtual authenticator + const response = authenticator.createCredential(options); + + // Verify registration via router + const authCtx = createAuthenticatedContext(user.id, user.email); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + + // Verify passkey is stored in database + const passkeys = await getUserPasskeys(getDb(), user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.rpid).toBe(TEST_RP.rpID); + expect(firstPasskey.counter).toBe(0); + }); + + test("excludes existing passkeys for returning users via router", async () => { + const user = await createTestUser(getDb(), { + email: "exclude-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const apiCtx = createAPIContext(); + const authCtx = createAuthenticatedContext(user.id, user.email); + + // Register first passkey via router + const { options: options1, challengeId: challengeId1 } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response1 = authenticator.createCredential(options1); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: challengeId1, response: response1 }, + { context: authCtx }, + ); + + // Get second registration options via router + const { options: options2 } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + + // Should have excludeCredentials with the first passkey + expect(options2.excludeCredentials).toHaveLength(1); + const excludedCred = expectFirst( + options2.excludeCredentials ?? [], + "Expected excluded credential to exist", + ); + expect(excludedCred.id).toBe(response1.id); + }); + + test("assigns friendly name from known AAGUID via router", async () => { + const user = await createTestUser(getDb(), { + email: "aaguid-test@test.com", + }); + + // Use iCloud Keychain AAGUID + const authenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, + }); + + const apiCtx = createAPIContext(); + const authCtx = createAuthenticatedContext(user.id, user.email); + + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response = authenticator.createCredential(options); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + + const passkeys = await getUserPasskeys(getDb(), user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.name).toBe("iCloud Keychain"); + }); + + test("cleans up challenge after verification via router", async () => { + const user = await createTestUser(getDb(), { + email: "cleanup-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const apiCtx = createAPIContext(); + const authCtx = createAuthenticatedContext(user.id, user.email); + + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response = authenticator.createCredential(options); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId, response }, + { context: authCtx }, + ); + + // Challenge should be deleted + const challengeRow = await db + ?.selectFrom("webauthn_challenges") + .select("id") + .where("id", "=", String(challengeId)) + .executeTakeFirst(); + + expect(challengeRow).toBeUndefined(); + }); + + test("rejects expired/missing challenges via router", async () => { + const user = await createTestUser(getDb(), { + email: "expired-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const apiCtx = createAPIContext(); + const authCtx = createAuthenticatedContext(user.id, user.email); + + // Create options via router + const { options } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + const response = authenticator.createCredential(options); + + // Use a non-existent challenge ID - should fail + try { + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: 999999, response }, + { context: authCtx }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Registration timed out"); + } + }); +}); + +describe("authentication flow", () => { + beforeAll(async () => { + await truncateAllTables(getDb()); + }); + + test("creates authentication options with user's passkeys via router", async () => { + const user = await createTestUser(getDb(), { + email: "auth-options@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register a passkey first via router + const regResponse = await registerPasskey( + user.id, + user.email, + authenticator, + ); + + // Create authentication options via router + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + + expect(options.challenge).toBeDefined(); + expect(options.rpId).toBe(TEST_RP.rpID); + expect(options.allowCredentials).toHaveLength(1); + const allowedCred = expectFirst( + options.allowCredentials ?? [], + "Expected allowed credential to exist", + ); + expect(allowedCred.id).toBe(regResponse.id); + expect(challengeId).toBeGreaterThan(0); + }); + + test("verifies valid authentication and updates counter via router", async () => { + const user = await createTestUser(getDb(), { + email: "auth-verify@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + await registerPasskey(user.id, user.email, authenticator); + + // Authenticate via router + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const authResponse = authenticator.getAssertion(authOptions); + + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: loginCtx }, + ); + + // Verify counter was updated + const passkeys = await getUserPasskeys(getDb(), user.id); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); + }); + + test("updates last_used_at timestamp via router", async () => { + const user = await createTestUser(getDb(), { email: "last-used@test.com" }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + await registerPasskey(user.id, user.email, authenticator); + + // Check initial state + let passkeys = await getUserPasskeys(getDb(), user.id); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.lastUsedAt).toBeNull(); + + // Authenticate via router + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const authResponse = authenticator.getAssertion(authOptions); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: loginCtx }, + ); + + // Check last_used_at is now set + passkeys = await getUserPasskeys(getDb(), user.id); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.lastUsedAt).not.toBeNull(); + }); + + test("cleans up challenge after authentication via router", async () => { + const user = await createTestUser(getDb(), { + email: "auth-cleanup@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + await registerPasskey(user.id, user.email, authenticator); + + // Authenticate via router + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + const authResponse = authenticator.getAssertion(authOptions); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: loginCtx }, + ); + + // Challenge should be deleted + const challengeRow = await db + ?.selectFrom("webauthn_challenges") + .select("id") + .where("id", "=", String(authChallengeId)) + .executeTakeFirst(); + + expect(challengeRow).toBeUndefined(); + }); + + test("rejects unknown credential IDs", async () => { + const user = await createTestUser(getDb(), { + email: "unknown-cred@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + await registerPasskey(user.id, user.email, authenticator); + + // Create auth options via router + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options: authOptions } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + + // Use a fresh authenticator that doesn't have the registered credential + const freshAuthenticator = new VirtualAuthenticator({ + origin: TEST_RP.origin, + }); + // First create a credential so the authenticator has something (use same registration options) + const apiCtx = createAPIContext(); + const { options: regOptions } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: apiCtx }, + ); + freshAuthenticator.createCredential(regOptions); + + // This should fail because the fresh authenticator doesn't have the right credential + try { + freshAuthenticator.getAssertion(authOptions); + throw new Error("Expected assertion to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "No matching credential found", + ); + } + }); +}); + +describe("security tests", () => { + beforeAll(async () => { + await truncateAllTables(getDb()); + }); + + test("rejects replayed credentials (counter check) via router", async () => { + const user = await createTestUser(getDb(), { + email: "counter-replay@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + const regResponse = await registerPasskey( + user.id, + user.email, + authenticator, + ); + + // First authentication should succeed + await authenticate(user.id, user.email, authenticator); + + // Verify counter was updated to 1 + let passkeys = await getUserPasskeys(getDb(), user.id); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); + + // Reset the authenticator's sign count to 0 (simulating replay attack) + authenticator.setSignCount(regResponse.id, 0); + + // Create a new authentication challenge + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + + // Get assertion with replayed (lower) counter + const authResponse = authenticator.getAssertion(options); + + // Verify authentication should fail due to counter replay (throws an error) + try { + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId, response: authResponse }, + { context: loginCtx }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("counter"); + } + + // Counter should not have changed + passkeys = await getUserPasskeys(getDb(), user.id); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(1); + }); + + test("rejects tampered authentication response", async () => { + const user = await createTestUser(getDb(), { + email: "tampered-response@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + await registerPasskey(user.id, user.email, authenticator); + + // Create authentication challenge + const loginCtx = createLoginRequestContext(user.id, user.email); + const { options, challengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: loginCtx }, + ); + + // Get valid assertion + const authResponse = authenticator.getAssertion(options); + + // Tamper with the authenticatorData (flip some bits in the middle) + // This causes signature verification to fail without breaking ASN.1 parsing + const tamperedAuthData = Buffer.from( + authResponse.response.authenticatorData, + "base64url", + ); + // Ensure buffer is long enough (authenticatorData is always > 37 bytes) + if (tamperedAuthData.length < 21) { + throw new Error("authenticatorData too short for tampering"); + } + const originalByte = tamperedAuthData[20]; + if (originalByte === undefined) { + throw new Error("Failed to read byte at index 20"); + } + tamperedAuthData[20] = originalByte ^ 0xff; // Flip bits in a byte (within rpIdHash) + const tamperedResponse = { + ...authResponse, + response: { + ...authResponse.response, + authenticatorData: tamperedAuthData.toString("base64url"), + }, + }; + + // Verify authentication should fail due to tampering (throws an error) + try { + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId, response: tamperedResponse }, + { context: loginCtx }, + ); + throw new Error("Expected verification to fail"); + } catch (error) { + // Tampering should cause verification to fail with an error + expect(error).toBeInstanceOf(Error); + // Should not be our sentinel error + expect((error as Error).message).not.toBe( + "Expected verification to fail", + ); + } + }); +}); + +describe("full passkey lifecycle", () => { + beforeAll(async () => { + await truncateAllTables(getDb()); + }); + + test("register → authenticate → add second passkey → authenticate with either via router", async () => { + const user = await createTestUser(getDb(), { email: "lifecycle@test.com" }); + const authenticator1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const authenticator2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register first passkey via router + await registerPasskey(user.id, user.email, authenticator1); + + // Authenticate with first passkey via router + await authenticate(user.id, user.email, authenticator1); + + // Register second passkey via router + await registerPasskey(user.id, user.email, authenticator2); + + // Verify user now has 2 passkeys + const passkeys = await getUserPasskeys(getDb(), user.id); + expect(passkeys).toHaveLength(2); + + // Authenticate with second passkey via router + await authenticate(user.id, user.email, authenticator2); + + // Authenticate with first passkey again via router + await authenticate(user.id, user.email, authenticator1); + }); + + test("register → authenticate multiple times → counter increments via router", async () => { + const user = await createTestUser(getDb(), { + email: "counter-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + // Register passkey via router + await registerPasskey(user.id, user.email, authenticator); + + // Verify initial counter + let passkeys = await getUserPasskeys(getDb(), user.id); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(0); + + // Authenticate 5 times via router + for (let i = 1; i <= 5; i++) { + await authenticate(user.id, user.email, authenticator); + + // Verify counter incremented + passkeys = await getUserPasskeys(getDb(), user.id); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.counter).toBe(i); + } + }); +}); + +describe("passkey management", () => { + beforeAll(async () => { + await truncateAllTables(getDb()); + }); + + test("lists passkeys with correct data via router", async () => { + const user = await createTestUser(getDb(), { + email: "list-passkeys@test.com", + }); + const authenticator1 = new VirtualAuthenticator({ + origin: TEST_RP.origin, + aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN, + }); + const authenticator2 = new VirtualAuthenticator({ + origin: TEST_RP.origin, + aaguid: KNOWN_AAGUIDS.GOOGLE_PASSWORD_MANAGER, + }); + + // Register two passkeys + await registerPasskey(user.id, user.email, authenticator1); + await registerPasskey(user.id, user.email, authenticator2); + + // List passkeys via router handler + const ctx = createAuthenticatedContext(user.id, user.email); + const passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + + expect(passkeys).toHaveLength(2); + + // Verify first passkey data (router returns id, name, createdAt, lastUsedAt) + const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain"); + if (!icloudPasskey) { + throw new Error("Expected iCloud Keychain passkey to exist"); + } + expect(icloudPasskey.id).toBeGreaterThan(0); + expect(icloudPasskey.createdAt).toBeInstanceOf(Date); + expect(icloudPasskey.lastUsedAt).toBeNull(); + + // Verify second passkey data + const googlePasskey = passkeys.find( + (p) => p.name === "Google Password Manager", + ); + if (!googlePasskey) { + throw new Error("Expected Google Password Manager passkey to exist"); + } + }); + + test("passkey stores correct device type and backup status", async () => { + const user = await createTestUser(getDb(), { + email: "device-type@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, authenticator); + + const passkeys = await getUserPasskeys(getDb(), user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + + // Virtual authenticator creates singleDevice passkeys (no backup flags set) + expect(firstPasskey.deviceType).toBe("singleDevice"); + expect(firstPasskey.backupEligible).toBe(false); + expect(firstPasskey.backupStatus).toBe(false); + }); + + test("renames passkey successfully via router", async () => { + const user = await createTestUser(getDb(), { + email: "rename-test@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, authenticator); + + const ctx = createAuthenticatedContext(user.id, user.email); + let passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const passkeyId = firstPasskey.id; + const originalName = firstPasskey.name; + + // Rename the passkey via router handler + const newName = "My MacBook Pro"; + await call( + router.me.passkeys.rename, + { passkeyId, name: newName }, + { context: ctx }, + ); + + // Verify name changed + passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.name).toBe(newName); + expect(firstPasskey.name).not.toBe(originalName); + }); + + test("rename does not affect other user's passkeys", async () => { + const user1 = await createTestUser(getDb(), { + email: "rename-user1@test.com", + }); + const user2 = await createTestUser(getDb(), { + email: "rename-user2@test.com", + }); + const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user1.id, user1.email, auth1); + await registerPasskey(user2.id, user2.email, auth2); + + const ctx1 = createAuthenticatedContext(user1.id, user1.email); + const ctx2 = createAuthenticatedContext(user2.id, user2.email); + + const user2Passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx2, + }); + const user2FirstPasskey = user2Passkeys[0]; + if (!user2FirstPasskey) { + throw new Error("Expected user2 passkey to exist"); + } + + // Try to rename user2's passkey using user1's context (should not work) + await call( + router.me.passkeys.rename, + { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, + { context: ctx1 }, + ); + + // User2's passkey should be unchanged + const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { + context: ctx2, + }); + const user2FirstPasskeyAfter = user2PasskeysAfter[0]; + if (!user2FirstPasskeyAfter) { + throw new Error("Expected user2 passkey to exist after"); + } + expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name); + }); + + test("deletes passkey when user has password via router", async () => { + const user = await createTestUser(getDb(), { + email: "delete-with-password@test.com", + passwordHash: "fake-password-hash", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, authenticator); + + const ctx = createAuthenticatedContext(user.id, user.email); + let passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const passkeyId = firstPasskey.id; + + // Delete the passkey via router (should work because user has password) + await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); + + // Verify passkey is deleted + passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); + expect(passkeys).toHaveLength(0); + }); + + test("deletes passkey when user has multiple passkeys via router", async () => { + const user = await createTestUser(getDb(), { + email: "delete-multi@test.com", + }); + const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, auth1); + await registerPasskey(user.id, user.email, auth2); + + const ctx = createAuthenticatedContext(user.id, user.email); + let passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeys).toHaveLength(2); + let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const firstPasskeyId = firstPasskey.id; + + // Delete first passkey via router (should work because user has another) + await call( + router.me.passkeys.delete, + { passkeyId: firstPasskeyId }, + { context: ctx }, + ); + + // Verify only one passkey remains + passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); + expect(passkeys).toHaveLength(1); + firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + expect(firstPasskey.id).not.toBe(firstPasskeyId); + }); + + test("prevents deleting last passkey without password via router", async () => { + const user = await createTestUser(getDb(), { + email: "delete-last@test.com", + // No password set + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, authenticator); + + const ctx = createAuthenticatedContext(user.id, user.email); + const passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + const passkeyId = firstPasskey.id; + + // Try to delete the only passkey via router (should fail) + try { + await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); + throw new Error("Expected deletion to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Cannot delete the last passkey when you have no password set", + ); + } + + // Verify passkey still exists + const passkeysAfter = await call(router.me.passkeys.list, undefined, { + context: ctx, + }); + expect(passkeysAfter).toHaveLength(1); + }); + + test("delete does not affect other user's passkeys via router", async () => { + const user1 = await createTestUser(getDb(), { + email: "delete-user1@test.com", + passwordHash: "fake-hash", + }); + const user2 = await createTestUser(getDb(), { + email: "delete-user2@test.com", + passwordHash: "fake-hash", + }); + const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user1.id, user1.email, auth1); + await registerPasskey(user2.id, user2.email, auth2); + + const ctx1 = createAuthenticatedContext(user1.id, user1.email); + const ctx2 = createAuthenticatedContext(user2.id, user2.email); + + const user2Passkeys = await call(router.me.passkeys.list, undefined, { + context: ctx2, + }); + const user2FirstPasskey = user2Passkeys[0]; + if (!user2FirstPasskey) { + throw new Error("Expected user2 passkey to exist"); + } + + // Try to delete user2's passkey using user1's context (should not affect user2) + await call( + router.me.passkeys.delete, + { passkeyId: user2FirstPasskey.id }, + { context: ctx1 }, + ); + + // User2's passkey should still exist + const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { + context: ctx2, + }); + expect(user2PasskeysAfter).toHaveLength(1); + }); + + test("passkey credentialId is unique and stored correctly", async () => { + const user = await createTestUser(getDb(), { + email: "credential-id@test.com", + }); + const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, auth1); + await registerPasskey(user.id, user.email, auth2); + + const passkeys = await getUserPasskeys(getDb(), user.id); + expect(passkeys).toHaveLength(2); + const firstPasskey = passkeys[0]; + const secondPasskey = passkeys[1]; + if (!(firstPasskey && secondPasskey)) { + throw new Error("Expected both passkeys to exist"); + } + + // Credential IDs should be unique + expect(firstPasskey.credentialId).not.toBe(secondPasskey.credentialId); + + // Credential IDs should be base64url encoded + expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); + expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test("passkey transports are stored and retrieved correctly", async () => { + const user = await createTestUser(getDb(), { + email: "transports@test.com", + }); + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + + await registerPasskey(user.id, user.email, authenticator); + + const passkeys = await getUserPasskeys(getDb(), user.id); + expect(passkeys).toHaveLength(1); + const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); + + // Virtual authenticator sets transports to ["internal", "hybrid"] + expect(firstPasskey.transports).toContain("internal"); + expect(firstPasskey.transports).toContain("hybrid"); + }); +}); diff --git a/apps/api-server/src/__tests__/helpers/test-constants.ts b/apps/api-server/src/__tests__/helpers/test-constants.ts new file mode 100644 index 0000000..5602385 --- /dev/null +++ b/apps/api-server/src/__tests__/helpers/test-constants.ts @@ -0,0 +1,26 @@ +/** + * Test constants for WebAuthn e2e tests + */ + +/** Test Relying Party configuration */ +export const TEST_RP = { + rpName: "Test App", + rpID: "localhost", + origin: "http://localhost:3000", + allowedOrigins: ["http://localhost:3000"], +} as const; + +/** + * Known AAGUIDs for testing passkey name assignment. + * These match the AAGUID_MAP in webauthn.ts + */ +export const KNOWN_AAGUIDS = { + ICLOUD_KEYCHAIN: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd", + GOOGLE_PASSWORD_MANAGER: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4", + CHROME_ON_MAC: "adce0002-35bc-c60a-648b-0b25f1f05503", + WINDOWS_HELLO: "6028b017-b1d4-4c02-b4b3-afcdafc96bb2", + ONEPASSWORD: "bada5566-a7aa-401f-bd96-45619a55120d", +} as const; + +/** Default AAGUID for virtual authenticator (unknown authenticator) */ +export const DEFAULT_TEST_AAGUID = "00000000-0000-0000-0000-000000000000"; diff --git a/apps/api-server/src/__tests__/helpers/test-db.ts b/apps/api-server/src/__tests__/helpers/test-db.ts new file mode 100644 index 0000000..f81cf72 --- /dev/null +++ b/apps/api-server/src/__tests__/helpers/test-db.ts @@ -0,0 +1,222 @@ +/** + * Test database utilities for e2e tests + */ + +import type { Database } from "@reviq/db-schema"; +import { join } from "node:path"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import pg from "pg"; + +const { Pool, Client } = pg; + +/** Tables to truncate between tests (in order that respects foreign keys) */ +const TABLES_TO_TRUNCATE = [ + "sessions", + "api_tokens", + "login_requests", + "passkeys", + "user_devices", + "webauthn_challenges", + "email_verifications", + "password_resets", + "org_invites", + "org_sites", + "org_members", + "orgs", + "users", +] as const; + +/** + * Creates a test database connection. + * Uses TEST_DATABASE_URL env var, falls back to DATABASE_URL with _test suffix. + */ +export function createTestDb(): Kysely { + const connectionString = getTestDatabaseUrl(); + + if (!connectionString) { + throw new Error( + "Test database URL not configured. Set TEST_DATABASE_URL environment variable.", + ); + } + + const dialect = new PostgresDialect({ + pool: new Pool({ connectionString }), + }); + + return new Kysely({ dialect }); +} + +/** + * Gets the test database URL from environment. + * Adds sslmode=disable for local development. + */ +export function getTestDatabaseUrl(): string { + let url: string; + + // Prefer explicit TEST_DATABASE_URL + if (Bun.env.TEST_DATABASE_URL) { + url = Bun.env.TEST_DATABASE_URL; + } else if (Bun.env.DATABASE_URL) { + // Fall back to DATABASE_URL with _test suffix + const parsed = new URL(Bun.env.DATABASE_URL); + parsed.pathname = `${parsed.pathname}_test`; + url = parsed.toString(); + } else { + return ""; + } + + // Add sslmode=disable for local postgres + const parsed = new URL(url); + if (!parsed.searchParams.has("sslmode")) { + parsed.searchParams.set("sslmode", "disable"); + } + return parsed.toString(); +} + +/** + * Parses a postgres URL to extract components + */ +function parsePostgresUrl(url: string): { + host: string; + port: number; + user: string; + password: string; + database: string; +} { + const parsed = new URL(url); + return { + host: parsed.hostname, + port: Number.parseInt(parsed.port || "5432", 10), + user: parsed.username, + password: parsed.password, + database: parsed.pathname.slice(1), // Remove leading / + }; +} + +/** + * Creates the test database if it doesn't exist. + */ +async function ensureTestDatabaseExists(): Promise { + const testDbUrl = getTestDatabaseUrl(); + if (!testDbUrl) { + throw new Error("Test database URL not configured"); + } + + const { host, port, user, password, database } = parsePostgresUrl(testDbUrl); + + // Connect to 'postgres' database to create the test database + const client = new Client({ + host, + port, + user, + password, + database: "postgres", + }); + + try { + await client.connect(); + + // Check if database exists + const result = await client.query( + "SELECT 1 FROM pg_database WHERE datname = $1", + [database], + ); + + if (result.rows.length === 0) { + // Create the database + // Note: database names can't be parameterized, but we control this value + await client.query(`CREATE DATABASE "${database}"`); + console.log(`Created test database: ${database}`); + } + } finally { + await client.end(); + } +} + +/** + * Runs database migrations using dbmate CLI. + * Creates the database if it doesn't exist. + * Should be called once before running test suite. + */ +export async function runMigrations(): Promise { + const testDbUrl = getTestDatabaseUrl(); + if (!testDbUrl) { + throw new Error("Test database URL not configured"); + } + + // Ensure the database exists first + await ensureTestDatabaseExists(); + + // Find the repo root (where db/migrations lives) + // From apps/api-server/src/__tests__/helpers/ -> repo root is 5 levels up + const repoRoot = join(import.meta.dir, "../../../../.."); + + const proc = Bun.spawn(["dbmate", "up"], { + env: { ...process.env, DATABASE_URL: testDbUrl }, + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error( + `Migration failed with code ${String(exitCode)}: ${stderr}`, + ); + } +} + +/** + * Truncates all tables to reset database state. + * Uses TRUNCATE ... CASCADE to handle foreign keys. + */ +export async function truncateAllTables(db: Kysely): Promise { + // Use a single TRUNCATE statement with CASCADE for efficiency + const tableList = TABLES_TO_TRUNCATE.join(", "); + await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute( + db, + ); +} + +/** + * Creates a test user for e2e tests. + * Returns the created user with ID. + */ +export async function createTestUser( + db: Kysely, + overrides: Partial<{ + email: string; + displayName: string; + fullName: string; + passwordHash: string; + emailVerifiedAt: Date; + isSuperuser: boolean; + }> = {}, +): Promise<{ id: number; email: string }> { + const email = overrides.email ?? `test-${String(Date.now())}@example.com`; + + const result = await db + .insertInto("users") + .values({ + email, + display_name: overrides.displayName ?? "Test User", + full_name: overrides.fullName ?? null, + password_hash: overrides.passwordHash ?? null, + email_verified_at: overrides.emailVerifiedAt ?? null, + is_superuser: overrides.isSuperuser ?? false, + }) + .returning(["id", "email"]) + .executeTakeFirstOrThrow(); + + return result; +} + +/** + * Destroys the database connection pool. + * Call this in afterAll() to clean up. + */ +export async function destroyTestDb(db: Kysely): Promise { + await db.destroy(); +} diff --git a/apps/api-server/src/__tests__/unit/passkey-helpers.test.ts b/apps/api-server/src/__tests__/unit/passkey-helpers.test.ts new file mode 100644 index 0000000..39f2e33 --- /dev/null +++ b/apps/api-server/src/__tests__/unit/passkey-helpers.test.ts @@ -0,0 +1,191 @@ +/** + * Unit tests for passkey-helpers utility functions + */ + +import type { PasskeyRow } from "../../utils/passkey-helpers.js"; +import { describe, expect, test } from "bun:test"; +import { + base64urlToUint8Array, + formatPasskeyDate, + parsePasskeyRow, + uint8ArrayToBase64url, +} from "../../utils/passkey-helpers.js"; + +describe("base64urlToUint8Array", () => { + test("converts base64url string to Uint8Array", () => { + // "Hello" in base64url + const base64url = "SGVsbG8"; + const result = base64urlToUint8Array(base64url); + + expect(result).toBeInstanceOf(Uint8Array); + expect(Buffer.from(result).toString()).toBe("Hello"); + }); + + test("handles empty string", () => { + const result = base64urlToUint8Array(""); + expect(result).toHaveLength(0); + }); + + test("handles base64url without padding", () => { + // "abc" in base64url (no padding needed) + const base64url = "YWJj"; + const result = base64urlToUint8Array(base64url); + expect(Buffer.from(result).toString()).toBe("abc"); + }); + + test("handles binary data with special characters", () => { + // Binary data that would have + and / in standard base64 + const original = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]); + const base64url = Buffer.from(original).toString("base64url"); + const result = base64urlToUint8Array(base64url); + + expect(result).toEqual(original); + }); +}); + +describe("uint8ArrayToBase64url", () => { + test("converts Uint8Array to base64url string", () => { + // "Hello" as bytes + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = uint8ArrayToBase64url(bytes); + + expect(result).toBe("SGVsbG8"); + }); + + test("handles empty array", () => { + const result = uint8ArrayToBase64url(new Uint8Array([])); + expect(result).toBe(""); + }); + + test("produces URL-safe output (no +, /, or =)", () => { + // Binary data that would produce + and / in standard base64 + const bytes = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]); + const result = uint8ArrayToBase64url(bytes); + + expect(result).not.toContain("+"); + expect(result).not.toContain("/"); + expect(result).not.toContain("="); + }); + + test("roundtrips correctly with base64urlToUint8Array", () => { + const original = new Uint8Array([1, 2, 3, 4, 5, 100, 200, 255]); + const encoded = uint8ArrayToBase64url(original); + const decoded = base64urlToUint8Array(encoded); + + expect(decoded).toEqual(original); + }); +}); + +describe("parsePasskeyRow", () => { + const createMockRow = (overrides: Partial = {}): PasskeyRow => ({ + id: 1, + user_id: 100, + credential_id: new Uint8Array([1, 2, 3, 4, 5]), + public_key: new Uint8Array([10, 20, 30, 40, 50]), + webauthn_user_id: "webauthn-user-123", + counter: "42", + device_type: "multiDevice", + backup_eligible: true, + backup_status: true, + transports: ["internal", "hybrid"], + rpid: "localhost", + name: "Test Passkey", + last_used_at: new Date("2024-01-15T10:00:00Z"), + created_at: new Date("2024-01-01T00:00:00Z"), + ...overrides, + }); + + test("converts DB row to ParsedPasskey format", () => { + const row = createMockRow(); + const result = parsePasskeyRow(row); + + expect(result.id).toBe(1); + expect(result.credentialId).toBe(uint8ArrayToBase64url(row.credential_id)); + expect(result.publicKey).toBeInstanceOf(Uint8Array); + expect(result.counter).toBe(42); + expect(result.deviceType).toBe("multiDevice"); + expect(result.backupEligible).toBe(true); + expect(result.backupStatus).toBe(true); + expect(result.transports).toEqual(["internal", "hybrid"]); + expect(result.rpid).toBe("localhost"); + expect(result.name).toBe("Test Passkey"); + }); + + test("handles string counter", () => { + const row = createMockRow({ counter: "100" }); + const result = parsePasskeyRow(row); + expect(result.counter).toBe(100); + }); + + test("handles number counter", () => { + const row = createMockRow({ counter: 200 }); + const result = parsePasskeyRow(row); + expect(result.counter).toBe(200); + }); + + test("handles bigint counter", () => { + const row = createMockRow({ counter: BigInt(300) }); + const result = parsePasskeyRow(row); + expect(result.counter).toBe(300); + }); + + test("handles null transports", () => { + const row = createMockRow({ transports: null }); + const result = parsePasskeyRow(row); + expect(result.transports).toBeNull(); + }); + + test("handles null last_used_at", () => { + const row = createMockRow({ last_used_at: null }); + const result = parsePasskeyRow(row); + expect(result.lastUsedAt).toBeNull(); + }); + + test("preserves created_at date", () => { + const createdAt = new Date("2024-06-15T12:00:00Z"); + const row = createMockRow({ created_at: createdAt }); + const result = parsePasskeyRow(row); + expect(result.createdAt).toEqual(createdAt); + }); + + test("handles singleDevice device type", () => { + const row = createMockRow({ device_type: "singleDevice" }); + const result = parsePasskeyRow(row); + expect(result.deviceType).toBe("singleDevice"); + }); +}); + +describe("formatPasskeyDate", () => { + test("formats date with month, day, year, and time", () => { + const date = new Date("2024-01-15T10:30:00"); + const result = formatPasskeyDate(date); + + // Should contain month abbreviation + expect(result).toContain("Jan"); + // Should contain day + expect(result).toContain("15"); + // Should contain year + expect(result).toContain("2024"); + }); + + test("includes time component", () => { + const date = new Date("2024-06-20T14:45:00"); + const result = formatPasskeyDate(date); + + // Should contain time in some format + expect(result).toMatch(/\d{1,2}:\d{2}/); + }); + + test("handles different months", () => { + const dates = [ + { date: new Date("2024-03-01"), month: "Mar" }, + { date: new Date("2024-07-15"), month: "Jul" }, + { date: new Date("2024-12-31"), month: "Dec" }, + ]; + + for (const { date, month } of dates) { + const result = formatPasskeyDate(date); + expect(result).toContain(month); + } + }); +}); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 1c56c35..467d3f9 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -68,11 +68,20 @@ const createRegistrationOptions = const ctx = context as APIContext; const { email } = input; - // For signup flow, we don't have a user yet - // The user will be created when signup is called with the passkeyInfo + // Look up existing user by email to exclude their credentials + const existingUser = await ctx.db + .selectFrom("users") + .select(["id", "display_name"]) + .where("email", "=", email) + .executeTakeFirst(); + const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); - const result = await createRegOptions(ctx.db, rpInfo, { email }); + const result = await createRegOptions(ctx.db, rpInfo, { + id: existingUser?.id, + email, + displayName: existingUser?.display_name, + }); return result; }, ); diff --git a/apps/api-server/src/utils/passkey-helpers.ts b/apps/api-server/src/utils/passkey-helpers.ts index b8083b5..63909f7 100644 --- a/apps/api-server/src/utils/passkey-helpers.ts +++ b/apps/api-server/src/utils/passkey-helpers.ts @@ -40,7 +40,7 @@ export interface ParsedPasskey { * Raw passkey row from database */ export interface PasskeyRow { - id: number; + id: string | number; // Int8 from DB comes as string user_id: number; credential_id: Uint8Array; public_key: Uint8Array; @@ -64,7 +64,7 @@ export const parsePasskeyRow = (row: PasskeyRow): ParsedPasskey => { const publicKeyBytes = new Uint8Array(row.public_key); return { - id: row.id, + id: Number(row.id), // Convert Int8 (string) to number credentialId: uint8ArrayToBase64url(row.credential_id), publicKey: publicKeyBytes, counter: Number(row.counter), diff --git a/bun.lock b/bun.lock index 697f209..9d8f147 100644 --- a/bun.lock +++ b/bun.lock @@ -26,8 +26,11 @@ "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", + "@reviq/virtual-authenticator": "workspace:*", "@types/bun": "catalog:", + "@types/pg": "^8.16.0", "eslint": "catalog:", + "pg": "^8.16.3", "typescript": "catalog:", }, }, @@ -134,6 +137,21 @@ "typescript": "catalog:", }, }, + "packages/testing/virtual-authenticator": { + "name": "@reviq/virtual-authenticator", + "version": "0.0.1", + "dependencies": { + "@simplewebauthn/types": "^12.0.0", + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "latest", + "@types/node": "^25.0.3", + "eslint": "catalog:", + "typescript": "catalog:", + }, + }, }, "catalog": { "@macalinao/eslint-config": "^7.0.3", @@ -334,6 +352,8 @@ "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], + "@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="], diff --git a/db/schema.sql b/db/schema.sql index 58cb066..385b5c7 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict ociGb4MsWN1fhg6id8BhboNMDdeJ0f1xyci4ALu7scxk6gFiVAy9pFRDOJdhjfZ +\restrict Trg340CgUaHnQsqUDFepZ6WnV8O2lwkEMfhS9CGxBAJbWOA8qTnig08shTgrMcE -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -1069,7 +1069,7 @@ ALTER TABLE ONLY public.user_devices -- PostgreSQL database dump complete -- -\unrestrict ociGb4MsWN1fhg6id8BhboNMDdeJ0f1xyci4ALu7scxk6gFiVAy9pFRDOJdhjfZ +\unrestrict Trg340CgUaHnQsqUDFepZ6WnV8O2lwkEMfhS9CGxBAJbWOA8qTnig08shTgrMcE -- diff --git a/devenv.nix b/devenv.nix index 788b6d4..1ea45c5 100644 --- a/devenv.nix +++ b/devenv.nix @@ -18,10 +18,14 @@ services.postgres = { enable = true; - initialDatabases = [ { name = "reviq-dashboard"; } ]; + initialDatabases = [ + { name = "reviq-dashboard"; } + { name = "reviq-dashboard_test"; } + ]; initialScript = '' CREATE USER reviq WITH PASSWORD 'reviq' SUPERUSER; GRANT ALL PRIVILEGES ON DATABASE "reviq-dashboard" TO reviq; + GRANT ALL PRIVILEGES ON DATABASE "reviq-dashboard_test" TO reviq; ''; listen_addresses = "localhost"; }; @@ -32,6 +36,9 @@ "api-server".exec = "bun run --cwd apps/api-server dev"; }; + env.DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard"; + env.TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test"; + scripts = { "db-up".exec = "dbmate up"; "db-new".exec = "dbmate new \"$1\""; diff --git a/docs/initial-app.md b/docs/initial-app.md index e126777..0135fcf 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2197,23 +2197,23 @@ All Phase 1 tasks can run in parallel. #### Workstream A: Database & Schema -- [ ] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes -- [ ] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen -- [ ] **A3**: Set up `@publisher-dashboard/db` package with Kysely client +- [x] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes +- [x] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen +- [x] **A3**: Set up `@publisher-dashboard/db` package with Kysely client #### Workstream B: API Contract -- [ ] **B1**: Create `@publisher-dashboard/api-contract` package structure -- [ ] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin) -- [ ] **B3**: Define oRPC contract with all procedure signatures +- [x] **B1**: Create `@publisher-dashboard/api-contract` package structure +- [x] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin) +- [x] **B3**: Define oRPC contract with all procedure signatures #### Workstream C: Project Infrastructure -- [ ] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`) -- [ ] **C2**: Set up `apps/api-server` with Bun.serve entry point -- [ ] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query -- [ ] **C4**: Set up `apps/cli` with stricli framework -- [ ] **C5**: Create `devenv.nix` with scripts and environment variables +- [x] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`) +- [x] **C2**: Set up `apps/api-server` with Bun.serve entry point +- [x] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query +- [x] **C4**: Set up `apps/cli` with stricli framework +- [x] **C5**: Create `devenv.nix` with scripts and environment variables --- diff --git a/package.json b/package.json index 843882b..38abaac 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "private": true, "workspaces": [ "apps/*", - "packages/*" + "packages/*", + "packages/testing/*" ], "scripts": { "dev": "turbo dev", diff --git a/packages/testing/virtual-authenticator/eslint.config.js b/packages/testing/virtual-authenticator/eslint.config.js new file mode 100644 index 0000000..ee789e3 --- /dev/null +++ b/packages/testing/virtual-authenticator/eslint.config.js @@ -0,0 +1,12 @@ +import { configs } from "@macalinao/eslint-config"; + +export default [ + ...configs.fast, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/testing/virtual-authenticator/package.json b/packages/testing/virtual-authenticator/package.json new file mode 100644 index 0000000..13e3eb4 --- /dev/null +++ b/packages/testing/virtual-authenticator/package.json @@ -0,0 +1,26 @@ +{ + "name": "@reviq/virtual-authenticator", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc", + "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", + "lint": "eslint . --cache", + "test": "bun test" + }, + "dependencies": { + "@simplewebauthn/types": "^12.0.0" + }, + "devDependencies": { + "@macalinao/eslint-config": "catalog:", + "@macalinao/tsconfig": "catalog:", + "@types/bun": "latest", + "@types/node": "^25.0.3", + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/testing/virtual-authenticator/src/__tests__/virtual-authenticator.test.ts b/packages/testing/virtual-authenticator/src/__tests__/virtual-authenticator.test.ts new file mode 100644 index 0000000..71e5b31 --- /dev/null +++ b/packages/testing/virtual-authenticator/src/__tests__/virtual-authenticator.test.ts @@ -0,0 +1,437 @@ +import type { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, +} from "@simplewebauthn/types"; +import { describe, expect, test } from "bun:test"; +import { + base64urlToUint8Array, + DEFAULT_AAGUID, + parseAaguid, + uint8ArrayToBase64url, + VirtualAuthenticator, +} from "../virtual-authenticator.js"; + +describe("base64url encoding helpers", () => { + test("uint8ArrayToBase64url encodes bytes correctly", () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const encoded = uint8ArrayToBase64url(bytes); + expect(encoded).toBe("SGVsbG8"); + }); + + test("base64urlToUint8Array decodes bytes correctly", () => { + const decoded = base64urlToUint8Array("SGVsbG8"); + expect(Array.from(decoded)).toEqual([72, 101, 108, 108, 111]); + }); + + test("round-trip encoding preserves data", () => { + const original = new Uint8Array([0, 1, 2, 255, 254, 253]); + const encoded = uint8ArrayToBase64url(original); + const decoded = base64urlToUint8Array(encoded); + expect(Array.from(decoded)).toEqual(Array.from(original)); + }); + + test("handles empty arrays", () => { + const empty = new Uint8Array([]); + const encoded = uint8ArrayToBase64url(empty); + expect(encoded).toBe(""); + const decoded = base64urlToUint8Array(""); + expect(decoded.length).toBe(0); + }); + + test("handles URL-unsafe characters correctly", () => { + // Base64url should use - instead of + and _ instead of / + const bytes = new Uint8Array([251, 255]); // Would be +/8= in standard base64 + const encoded = uint8ArrayToBase64url(bytes); + expect(encoded).not.toContain("+"); + expect(encoded).not.toContain("/"); + expect(encoded).not.toContain("="); + }); +}); + +describe("parseAaguid", () => { + test("parses default AAGUID (all zeros)", () => { + const bytes = parseAaguid(DEFAULT_AAGUID); + expect(bytes.length).toBe(16); + expect(Array.from(bytes)).toEqual(new Array(16).fill(0)); + }); + + test("parses AAGUID with dashes", () => { + const bytes = parseAaguid("fbfc3007-154e-4ecc-8c0b-6e020557d7bd"); + expect(bytes.length).toBe(16); + expect(bytes[0]).toBe(0xfb); + expect(bytes[1]).toBe(0xfc); + expect(bytes[2]).toBe(0x30); + expect(bytes[3]).toBe(0x07); + }); + + test("parses specific byte values correctly", () => { + const bytes = parseAaguid("01020304-0506-0708-090a-0b0c0d0e0f10"); + expect(Array.from(bytes)).toEqual([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, + ]); + }); +}); + +describe("VirtualAuthenticator", () => { + const createRegistrationOptions = ( + challenge: string, + userId: string, + ): PublicKeyCredentialCreationOptionsJSON => ({ + challenge, + rp: { + name: "Test App", + id: "localhost", + }, + user: { + id: userId, + name: "test@example.com", + displayName: "Test User", + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + timeout: 60000, + attestation: "none", + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + const createAuthenticationOptions = ( + challenge: string, + credentialId: string, + ): PublicKeyCredentialRequestOptionsJSON => ({ + challenge, + rpId: "localhost", + timeout: 60000, + userVerification: "preferred", + allowCredentials: [ + { + type: "public-key", + id: credentialId, + }, + ], + }); + + describe("constructor", () => { + test("uses default AAGUID when not specified", () => { + const auth = new VirtualAuthenticator(); + expect(auth.getCredentials()).toEqual([]); + }); + + test("uses default origin when not specified", () => { + const auth = new VirtualAuthenticator(); + // We can verify this by checking clientDataJSON origin in created credentials + expect(auth.getCredentials()).toEqual([]); + }); + + test("accepts custom origin", () => { + const auth = new VirtualAuthenticator({ + origin: "https://example.com", + }); + expect(auth.getCredentials()).toEqual([]); + }); + + test("accepts custom AAGUID", () => { + const auth = new VirtualAuthenticator({ + aaguid: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd", + }); + expect(auth.getCredentials()).toEqual([]); + }); + }); + + describe("createCredential", () => { + test("creates a valid registration response", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const options = createRegistrationOptions("test-challenge", "dXNlci0x"); + + const response = auth.createCredential(options); + + expect(response.type).toBe("public-key"); + expect(response.id).toBeDefined(); + expect(response.rawId).toBe(response.id); + expect(response.authenticatorAttachment).toBe("platform"); + expect(response.response.clientDataJSON).toBeDefined(); + expect(response.response.attestationObject).toBeDefined(); + expect(response.response.transports).toContain("internal"); + expect(response.response.publicKeyAlgorithm).toBe(-7); // ES256 + }); + + test("stores credential after creation", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const options = createRegistrationOptions("test-challenge", "dXNlci0x"); + + expect(auth.getCredentials()).toHaveLength(0); + + const response = auth.createCredential(options); + + const credentials = auth.getCredentials(); + expect(credentials).toHaveLength(1); + expect(credentials[0]?.credentialId).toBe(response.id); + expect(credentials[0]?.rpId).toBe("localhost"); + expect(credentials[0]?.signCount).toBe(0); + }); + + test("creates unique credential IDs for each registration", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + + const response1 = auth.createCredential( + createRegistrationOptions("challenge-1", "dXNlci0x"), + ); + const response2 = auth.createCredential( + createRegistrationOptions("challenge-2", "dXNlci0y"), + ); + + expect(response1.id).not.toBe(response2.id); + expect(auth.getCredentials()).toHaveLength(2); + }); + + test("includes challenge in clientDataJSON", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const challenge = "unique-test-challenge"; + const options = createRegistrationOptions(challenge, "dXNlci0x"); + + const response = auth.createCredential(options); + + const clientDataJSON = JSON.parse( + Buffer.from(response.response.clientDataJSON, "base64url").toString(), + ) as { type: string; challenge: string; origin: string }; + expect(clientDataJSON.type).toBe("webauthn.create"); + expect(clientDataJSON.challenge).toBe(challenge); + expect(clientDataJSON.origin).toBe("http://localhost:3000"); + }); + }); + + describe("getAssertion", () => { + test("creates a valid authentication response", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const regOptions = createRegistrationOptions("reg-challenge", "dXNlci0x"); + const regResponse = auth.createCredential(regOptions); + + const authOptions = createAuthenticationOptions( + "auth-challenge", + regResponse.id, + ); + const authResponse = auth.getAssertion(authOptions); + + expect(authResponse.type).toBe("public-key"); + expect(authResponse.id).toBe(regResponse.id); + expect(authResponse.response.authenticatorData).toBeDefined(); + expect(authResponse.response.signature).toBeDefined(); + expect(authResponse.response.userHandle).toBeDefined(); + expect(authResponse.response.clientDataJSON).toBeDefined(); + }); + + test("increments sign count on each assertion", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const regOptions = createRegistrationOptions("reg-challenge", "dXNlci0x"); + const regResponse = auth.createCredential(regOptions); + + const credentials = auth.getCredentials(); + expect(credentials[0]?.signCount).toBe(0); + + auth.getAssertion( + createAuthenticationOptions("challenge-1", regResponse.id), + ); + expect(auth.getCredentials()[0]?.signCount).toBe(1); + + auth.getAssertion( + createAuthenticationOptions("challenge-2", regResponse.id), + ); + expect(auth.getCredentials()[0]?.signCount).toBe(2); + }); + + test("throws error when no matching credential found", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const authOptions = createAuthenticationOptions( + "auth-challenge", + "non-existent-credential-id", + ); + + expect(() => auth.getAssertion(authOptions)).toThrow( + "No matching credential found", + ); + }); + + test("includes challenge in clientDataJSON", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const regResponse = auth.createCredential( + createRegistrationOptions("reg-challenge", "dXNlci0x"), + ); + + const challenge = "unique-auth-challenge"; + const authResponse = auth.getAssertion( + createAuthenticationOptions(challenge, regResponse.id), + ); + + const clientDataJSON = JSON.parse( + Buffer.from( + authResponse.response.clientDataJSON, + "base64url", + ).toString(), + ) as { type: string; challenge: string; origin: string }; + expect(clientDataJSON.type).toBe("webauthn.get"); + expect(clientDataJSON.challenge).toBe(challenge); + }); + }); + + describe("credential management", () => { + test("clear removes all credentials", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + auth.createCredential( + createRegistrationOptions("challenge-1", "dXNlci0x"), + ); + auth.createCredential( + createRegistrationOptions("challenge-2", "dXNlci0y"), + ); + + expect(auth.getCredentials()).toHaveLength(2); + + auth.clear(); + + expect(auth.getCredentials()).toHaveLength(0); + }); + + test("setSignCount updates credential sign count", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const response = auth.createCredential( + createRegistrationOptions("challenge", "dXNlci0x"), + ); + + expect(auth.getCredentials()[0]?.signCount).toBe(0); + + auth.setSignCount(response.id, 100); + + expect(auth.getCredentials()[0]?.signCount).toBe(100); + }); + + test("setSignCount throws for non-existent credential", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + + expect(() => { + auth.setSignCount("non-existent", 100); + }).toThrow("Credential not found"); + }); + + test("getCredentials returns credential info without private keys", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + auth.createCredential(createRegistrationOptions("challenge", "dXNlci0x")); + + const credentials = auth.getCredentials(); + expect(credentials).toHaveLength(1); + + const cred = credentials[0]; + expect(cred).toHaveProperty("credentialId"); + expect(cred).toHaveProperty("rpId"); + expect(cred).toHaveProperty("signCount"); + // Should NOT expose private key + expect(cred).not.toHaveProperty("privateKey"); + expect(cred).not.toHaveProperty("publicKey"); + }); + }); + + describe("authenticator data structure", () => { + test("registration authenticator data has correct flags", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const response = auth.createCredential( + createRegistrationOptions("challenge", "dXNlci0x"), + ); + + if (!response.response.authenticatorData) { + throw new Error("authenticatorData missing from registration response"); + } + const authData = base64urlToUint8Array( + response.response.authenticatorData, + ); + + // Authenticator data structure: + // - 32 bytes: RP ID hash + // - 1 byte: flags + // - 4 bytes: sign count + // - variable: attested credential data (for registration) + expect(authData.length).toBeGreaterThan(37); + + // Flags byte (index 32): UP (0x01) + UV (0x04) + AT (0x40) = 0x45 + const flags = authData[32]; + expect(flags).toBe(0x45); + }); + + test("assertion authenticator data has correct flags", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const regResponse = auth.createCredential( + createRegistrationOptions("reg-challenge", "dXNlci0x"), + ); + + const authResponse = auth.getAssertion( + createAuthenticationOptions("auth-challenge", regResponse.id), + ); + + const authData = base64urlToUint8Array( + authResponse.response.authenticatorData, + ); + + // Assertion authenticator data: 32 + 1 + 4 = 37 bytes (no attested credential data) + expect(authData.length).toBe(37); + + // Flags byte: UP (0x01) + UV (0x04) = 0x05 (no AT flag for assertions) + const flags = authData[32]; + expect(flags).toBe(0x05); + }); + + test("sign count is encoded in big-endian", () => { + const auth = new VirtualAuthenticator({ + origin: "http://localhost:3000", + }); + const regResponse = auth.createCredential( + createRegistrationOptions("reg-challenge", "dXNlci0x"), + ); + + // Set a specific sign count + auth.setSignCount(regResponse.id, 0x01020304); + + const authResponse = auth.getAssertion( + createAuthenticationOptions("auth-challenge", regResponse.id), + ); + + const authData = base64urlToUint8Array( + authResponse.response.authenticatorData, + ); + + // Sign count is at bytes 33-36 (after RP ID hash and flags) + // After getAssertion, sign count will be incremented by 1 + const signCountBytes = authData.slice(33, 37); + expect(signCountBytes[0]).toBe(0x01); + expect(signCountBytes[1]).toBe(0x02); + expect(signCountBytes[2]).toBe(0x03); + expect(signCountBytes[3]).toBe(0x05); // 0x01020304 + 1 = 0x01020305 + }); + }); +}); diff --git a/packages/testing/virtual-authenticator/src/index.ts b/packages/testing/virtual-authenticator/src/index.ts new file mode 100644 index 0000000..b8f95e5 --- /dev/null +++ b/packages/testing/virtual-authenticator/src/index.ts @@ -0,0 +1,8 @@ +export type { VirtualAuthenticatorOptions } from "./virtual-authenticator.js"; +export { + base64urlToUint8Array, + DEFAULT_AAGUID, + parseAaguid, + uint8ArrayToBase64url, + VirtualAuthenticator, +} from "./virtual-authenticator.js"; diff --git a/packages/testing/virtual-authenticator/src/virtual-authenticator.ts b/packages/testing/virtual-authenticator/src/virtual-authenticator.ts new file mode 100644 index 0000000..eb2456d --- /dev/null +++ b/packages/testing/virtual-authenticator/src/virtual-authenticator.ts @@ -0,0 +1,434 @@ +/** + * Virtual WebAuthn Authenticator for testing + * + * Generates real cryptographically-signed WebAuthn credentials that pass + * @simplewebauthn/server verification without needing a browser or physical device. + */ + +import type { KeyObject } from "node:crypto"; +import type { + AuthenticationResponseJSON, + AuthenticatorTransportFuture, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/types"; +import { createHash, createSign, generateKeyPairSync } from "node:crypto"; + +/** Default AAGUID for virtual authenticator (unknown authenticator) */ +export const DEFAULT_AAGUID = "00000000-0000-0000-0000-000000000000"; + +/** Stored credential in the virtual authenticator */ +interface StoredCredential { + credentialId: Uint8Array; + privateKey: KeyObject; + publicKey: KeyObject; + rpId: string; + userHandle: Uint8Array; + signCount: number; +} + +/** Options for creating a virtual authenticator */ +export interface VirtualAuthenticatorOptions { + /** AAGUID to use (defaults to all zeros) */ + aaguid?: string; + /** Origin for clientDataJSON */ + origin?: string; +} + +/** + * Virtual WebAuthn authenticator for testing. + * + * Usage: + * ```ts + * const authenticator = new VirtualAuthenticator(); + * const regResponse = await authenticator.createCredential(regOptions); + * // ... verify registration ... + * const authResponse = await authenticator.getAssertion(authOptions); + * // ... verify authentication ... + * ``` + */ +export class VirtualAuthenticator { + private _credentials = new Map(); + private _aaguid: Uint8Array; + private _origin: string; + + constructor(options: VirtualAuthenticatorOptions = {}) { + this._aaguid = parseAaguid(options.aaguid ?? DEFAULT_AAGUID); + this._origin = options.origin ?? "http://localhost:3000"; + } + + /** + * Creates a new credential (registration). + * Returns a RegistrationResponseJSON that can be verified by @simplewebauthn/server. + */ + createCredential( + options: PublicKeyCredentialCreationOptionsJSON, + ): RegistrationResponseJSON { + // Generate ECDSA P-256 key pair + const { privateKey, publicKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + + // Generate random credential ID (32 bytes) + const credentialId = new Uint8Array(32); + crypto.getRandomValues(credentialId); + const credentialIdBase64 = uint8ArrayToBase64url(credentialId); + + // Parse user handle from options + const userHandle = base64urlToUint8Array(options.user.id); + + // Store the credential + const rpId = options.rp.id ?? new URL(this._origin).hostname; + this._credentials.set(credentialIdBase64, { + credentialId, + privateKey, + publicKey, + rpId, + userHandle, + signCount: 0, + }); + + // Build authenticator data with attested credential data + const authData = this._buildAuthenticatorData( + rpId, + true, // attestedCredentialData included + 0, // initial sign count + { + credentialId, + publicKey, + }, + ); + + // Build clientDataJSON + const clientDataJSON = JSON.stringify({ + type: "webauthn.create", + challenge: options.challenge, + origin: this._origin, + crossOrigin: false, + }); + const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON); + + // Build attestation object (using "none" attestation) + // Manually encode to avoid cbor-x adding tags that break parsing + const attestationObject = encodeAttestationObject(authData); + + // Get public key in SPKI format for the response + const publicKeySpki = publicKey.export({ format: "der", type: "spki" }); + + return { + id: credentialIdBase64, + rawId: credentialIdBase64, + type: "public-key", + response: { + clientDataJSON: uint8ArrayToBase64url(clientDataJSONBytes), + attestationObject: uint8ArrayToBase64url(attestationObject), + transports: ["internal", "hybrid"] as AuthenticatorTransportFuture[], + publicKeyAlgorithm: -7, // ES256 + publicKey: uint8ArrayToBase64url(new Uint8Array(publicKeySpki)), + authenticatorData: uint8ArrayToBase64url(authData), + }, + clientExtensionResults: {}, + authenticatorAttachment: "platform", + }; + } + + /** + * Creates an assertion (authentication). + * Returns an AuthenticationResponseJSON that can be verified by @simplewebauthn/server. + */ + getAssertion( + options: PublicKeyCredentialRequestOptionsJSON, + ): AuthenticationResponseJSON { + // Find a matching credential + const allowedIds = options.allowCredentials?.map((c) => c.id) ?? []; + let credential: StoredCredential | undefined; + let credentialIdBase64: string | undefined; + + for (const id of allowedIds) { + if (this._credentials.has(id)) { + credential = this._credentials.get(id); + credentialIdBase64 = id; + break; + } + } + + if (!(credential && credentialIdBase64)) { + throw new Error("No matching credential found in virtual authenticator"); + } + + // Increment sign count + credential.signCount++; + + // Build authenticator data (no attested credential data for assertions) + const rpId = options.rpId ?? new URL(this._origin).hostname; + const authData = this._buildAuthenticatorData( + rpId, + false, // no attestedCredentialData + credential.signCount, + ); + + // Build clientDataJSON + const clientDataJSON = JSON.stringify({ + type: "webauthn.get", + challenge: options.challenge, + origin: this._origin, + crossOrigin: false, + }); + const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON); + + // Create signature over authData || sha256(clientDataJSON) + const clientDataHash = createHash("sha256") + .update(clientDataJSONBytes) + .digest(); + const signedData = Buffer.concat([authData, clientDataHash]); + + const signature = createSign("sha256") + .update(signedData) + .sign(credential.privateKey); + + return { + id: credentialIdBase64, + rawId: credentialIdBase64, + type: "public-key", + response: { + clientDataJSON: uint8ArrayToBase64url(clientDataJSONBytes), + authenticatorData: uint8ArrayToBase64url(authData), + signature: uint8ArrayToBase64url(new Uint8Array(signature)), + userHandle: uint8ArrayToBase64url(credential.userHandle), + }, + clientExtensionResults: {}, + authenticatorAttachment: "platform", + }; + } + + /** + * Gets all stored credentials (for test assertions). + */ + getCredentials(): { + credentialId: string; + rpId: string; + signCount: number; + }[] { + return Array.from(this._credentials.entries()).map(([id, cred]) => ({ + credentialId: id, + rpId: cred.rpId, + signCount: cred.signCount, + })); + } + + /** + * Clears all stored credentials. + */ + clear(): void { + this._credentials.clear(); + } + + /** + * Sets the sign count for a credential (for testing counter replay attacks). + * @param credentialId - The base64url-encoded credential ID + * @param signCount - The sign count to set + */ + setSignCount(credentialId: string, signCount: number): void { + const credential = this._credentials.get(credentialId); + if (!credential) { + throw new Error(`Credential not found: ${credentialId}`); + } + credential.signCount = signCount; + } + + /** + * Builds WebAuthn authenticator data. + */ + private _buildAuthenticatorData( + rpId: string, + includeAttestedCredentialData: boolean, + signCount: number, + attestedCredentialData?: { + credentialId: Uint8Array; + publicKey: KeyObject; + }, + ): Uint8Array { + // RP ID hash (32 bytes) + const rpIdHash = createHash("sha256").update(rpId).digest(); + + // Flags (1 byte) + // Bit 0: User Present (UP) = 1 + // Bit 2: User Verified (UV) = 1 + // Bit 6: Attested credential data included (AT) = 1 if registration + let flags = 0x01 | 0x04; // UP + UV + if (includeAttestedCredentialData) { + flags |= 0x40; // AT + } + + // Sign count (4 bytes, big-endian) + const signCountBytes = new Uint8Array(4); + new DataView(signCountBytes.buffer).setUint32(0, signCount, false); + + if (!(includeAttestedCredentialData && attestedCredentialData)) { + // Simple authenticator data (for assertions) + return new Uint8Array([...rpIdHash, flags, ...signCountBytes]); + } + + // Build attested credential data for registration + const { credentialId, publicKey } = attestedCredentialData; + + // AAGUID (16 bytes) + const aaguid = this._aaguid; + + // Credential ID length (2 bytes, big-endian) + const credIdLenBytes = new Uint8Array(2); + new DataView(credIdLenBytes.buffer).setUint16( + 0, + credentialId.length, + false, + ); + + // COSE public key + const coseKey = this._buildCosePublicKey(publicKey); + + return new Uint8Array([ + ...rpIdHash, + flags, + ...signCountBytes, + ...aaguid, + ...credIdLenBytes, + ...credentialId, + ...coseKey, + ]); + } + + /** + * Builds a COSE-encoded public key for ES256 (P-256). + * + * COSE key format for ES256: + * { + * 1: 2, // kty: EC2 + * 3: -7, // alg: ES256 + * -1: 1, // crv: P-256 + * -2: x, // x-coordinate (32 bytes) + * -3: y // y-coordinate (32 bytes) + * } + */ + private _buildCosePublicKey(publicKey: KeyObject): Uint8Array { + // Export public key in JWK format to get x and y coordinates + const jwk = publicKey.export({ format: "jwk" }); + + if (!(jwk.x && jwk.y)) { + throw new Error("Failed to export public key coordinates"); + } + + const x = base64urlToUint8Array(jwk.x); + const y = base64urlToUint8Array(jwk.y); + + // Build COSE key manually to avoid cbor-x adding tags to Map + // CBOR map with 5 items: A5 + // Key 1 (kty): 01 -> Value 2: 02 + // Key 3 (alg): 03 -> Value -7: 26 (negative int -7 = 0x20 + 6) + // Key -1 (crv): 20 (negative int -1 = 0x20 + 0) -> Value 1: 01 + // Key -2 (x): 21 (negative int -2 = 0x20 + 1) -> Value: bytes(32) + // Key -3 (y): 22 (negative int -3 = 0x20 + 2) -> Value: bytes(32) + const coseKey = new Uint8Array([ + 0xa5, // map(5) + 0x01, + 0x02, // 1: 2 (kty: EC2) + 0x03, + 0x26, // 3: -7 (alg: ES256) + 0x20, + 0x01, // -1: 1 (crv: P-256) + 0x21, + 0x58, + 0x20, + ...x, // -2: bytes(32) x-coordinate + 0x22, + 0x58, + 0x20, + ...y, // -3: bytes(32) y-coordinate + ]); + + return coseKey; + } +} + +/** + * Parses an AAGUID string (e.g., "fbfc3007-154e-4ecc-8c0b-6e020557d7bd") to bytes. + */ +export function parseAaguid(aaguid: string): Uint8Array { + const hex = aaguid.replace(/-/g, ""); + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Converts a Uint8Array to a base64url-encoded string. + */ +export function uint8ArrayToBase64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64url"); +} + +/** + * Converts a base64url-encoded string to a Uint8Array. + */ +export function base64urlToUint8Array(base64url: string): Uint8Array { + return new Uint8Array(Buffer.from(base64url, "base64url")); +} + +/** + * Manually encode the attestation object in CBOR format. + * This avoids cbor-x adding tags that break @simplewebauthn/server parsing. + * + * CBOR structure: + * A3 - map(3) + * 63 "fmt" -> 64 "none" + * 67 "attStmt" -> A0 (empty map) + * 68 "authData" -> bytes(authData) + */ +function encodeAttestationObject(authData: Uint8Array): Uint8Array { + // Encode "fmt" key (text string, 3 bytes) + const fmtKey = new Uint8Array([0x63, 0x66, 0x6d, 0x74]); // text(3) "fmt" + // Encode "none" value (text string, 4 bytes) + const noneValue = new Uint8Array([0x64, 0x6e, 0x6f, 0x6e, 0x65]); // text(4) "none" + + // Encode "attStmt" key (text string, 7 bytes) + const attStmtKey = new Uint8Array([ + 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, + ]); // text(7) "attStmt" + // Encode empty map value + const emptyMap = new Uint8Array([0xa0]); // map(0) + + // Encode "authData" key (text string, 8 bytes) + const authDataKey = new Uint8Array([ + 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, + ]); // text(8) "authData" + + // Encode authData value as bytes + // For lengths 24-255, use 0x58 + 1-byte length + // For lengths 256-65535, use 0x59 + 2-byte length + let authDataEncoded: Uint8Array; + if (authData.length < 24) { + authDataEncoded = new Uint8Array([0x40 + authData.length, ...authData]); + } else if (authData.length < 256) { + authDataEncoded = new Uint8Array([0x58, authData.length, ...authData]); + } else { + authDataEncoded = new Uint8Array([ + 0x59, + (authData.length >> 8) & 0xff, + authData.length & 0xff, + ...authData, + ]); + } + + // Combine into final CBOR map(3) + return new Uint8Array([ + 0xa3, // map(3) + ...fmtKey, + ...noneValue, + ...attStmtKey, + ...emptyMap, + ...authDataKey, + ...authDataEncoded, + ]); +} diff --git a/packages/testing/virtual-authenticator/tsconfig.json b/packages/testing/virtual-authenticator/tsconfig.json new file mode 100644 index 0000000..03b601c --- /dev/null +++ b/packages/testing/virtual-authenticator/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@macalinao/tsconfig/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "composite": true, + "types": ["node", "bun"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/turbo.json b/turbo.json index b97dea9..9e0a980 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,17 @@ }, "clean": { "cache": false + }, + "test:e2e": { + "dependsOn": ["^build"], + "inputs": ["src/**/*.ts", "src/**/*.test.ts"], + "env": ["TEST_DATABASE_URL"], + "cache": false + }, + "test:unit": { + "dependsOn": ["^build"], + "inputs": ["src/**/*.ts", "src/**/*.test.ts"], + "cache": false } } }