/** * 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 } from "../../context.js"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { createTestUser, describeE2E, destroySharedDb, getSharedDb, initTestDb, KNOWN_AAGUIDS, TEST_RP, uniqueTestId, withTestTransaction, } from "@reviq/test-helpers"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { router } from "../../router.js"; import { COOKIE_NAMES } from "../../utils/cookies.js"; import { hashToken } from "../../utils/crypto.js"; import { getUserPasskeys } from "../../utils/webauthn.js"; /** Session expiry duration: 24 hours in milliseconds */ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; /** * Create an API context with optional session token */ function createAPIContext( db: Kysely, sessionToken?: string, ): APIContext { const reqHeaders = new Headers(); if (sessionToken) { reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`); } return { db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, reqHeaders, resHeaders: new Headers(), }; } /** * Create a real session in the database and return the token */ async function createSession( db: Kysely, userId: number, ): Promise { const token = `test-session-${uniqueTestId()}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); await db .insertInto("sessions") .values({ user_id: userId, token_hash: tokenHashValue, ip_address: "127.0.0.1", user_agent: "test-agent", expires_at: expiresAt, trusted_mode: false, }) .execute(); return token; } /** * Create a login request in the database and return ID and token */ async function createLoginRequest( db: Kysely, userId: number, email: string, ): Promise<{ id: number; token: string }> { const token = `test-login-${uniqueTestId()}`; const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const result = await db .insertInto("login_requests") .values({ user_id: userId, email, token, expires_at: expiresAt, }) .returning("id") .executeTakeFirstOrThrow(); return { id: Number(result.id), token }; } /** * Create an authenticated API context for a user (creates session + context) */ async function createUserAPIContext( db: Kysely, userId: number, ): Promise { const sessionToken = await createSession(db, userId); return createAPIContext(db, sessionToken); } /** * Create an API context with login request cookie */ function createLoginRequestContext( db: Kysely, loginToken: string, ): APIContext { const reqHeaders = new Headers(); reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`); return { db, origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, reqHeaders, resHeaders: new Headers(), }; } /** * 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( db: Kysely, userId: number, email: string, authenticator: VirtualAuthenticator, ) { const apiCtx = createAPIContext(db); const authCtx = await createUserAPIContext(db, userId); 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( db: Kysely, userId: number, email: string, authenticator: VirtualAuthenticator, ) { const { token: loginToken } = await createLoginRequest(db, userId, email); const loginCtx = createLoginRequestContext(db, loginToken); 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 }, ); } describeE2E("webauthn", () => { beforeAll(async () => { await initTestDb(); }); afterAll(async () => { await destroySharedDb(); }); describe("registration flow", () => { test("creates registration options with challenge stored in DB via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "reg-options@test.com", }); const ctx = createAPIContext(db); // 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", "=", challengeId.toString()) .executeTakeFirst(); expect(challengeRow).toBeDefined(); }); }); test("verifies valid registration and stores passkey via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "reg-verify@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Create registration options via router const apiCtx = createAPIContext(db); 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 (requires authenticated session) const authCtx = await createUserAPIContext(db, user.id); await call( router.auth.webauthn.verifyRegistration, { challengeId, response }, { context: authCtx }, ); // Verify passkey is stored in database const passkeys = await getUserPasskeys(db, 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "exclude-test@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); const apiCtx = createAPIContext(db); const authCtx = await createUserAPIContext(db, user.id); // 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { 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(db); const authCtx = await createUserAPIContext(db, user.id); 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(db, 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "cleanup-test@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); const apiCtx = createAPIContext(db); const authCtx = await createUserAPIContext(db, user.id); 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", "=", challengeId.toString()) .executeTakeFirst(); expect(challengeRow).toBeUndefined(); }); }); test("rejects expired/missing challenges via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "expired-test@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); const apiCtx = createAPIContext(db); const authCtx = await createUserAPIContext(db, user.id); // 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", () => { test("creates authentication options with user's passkeys via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "auth-options@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register a passkey first via router const regResponse = await registerPasskey( db, user.id, user.email, authenticator, ); // Create authentication options via router const { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "auth-verify@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router await registerPasskey(db, user.id, user.email, authenticator); // Authenticate via router const { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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(db, user.id); const firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); expect(firstPasskey.counter).toBe(1); }); }); test("updates last_used_at timestamp via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "last-used@test.com" }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router await registerPasskey(db, user.id, user.email, authenticator); // Check initial state let passkeys = await getUserPasskeys(db, user.id); let firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); expect(firstPasskey.lastUsedAt).toBeNull(); // Authenticate via router const { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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(db, user.id); firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); expect(firstPasskey.lastUsedAt).not.toBeNull(); }); }); test("cleans up challenge after authentication via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "auth-cleanup@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router await registerPasskey(db, user.id, user.email, authenticator); // Authenticate via router const { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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", "=", authChallengeId.toString()) .executeTakeFirst(); expect(challengeRow).toBeUndefined(); }); }); test("rejects unknown credential IDs", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "unknown-cred@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router await registerPasskey(db, user.id, user.email, authenticator); // Create auth options via router const { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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(db); 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", () => { test("rejects replayed credentials (counter check) via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "counter-replay@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router const regResponse = await registerPasskey( db, user.id, user.email, authenticator, ); // First authentication should succeed await authenticate(db, user.id, user.email, authenticator); // Verify counter was updated to 1 let passkeys = await getUserPasskeys(db, 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 { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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(db, user.id); firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); expect(firstPasskey.counter).toBe(1); }); }); test("rejects tampered authentication response", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "tampered-response@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router await registerPasskey(db, user.id, user.email, authenticator); // Create authentication challenge const { token: loginToken } = await createLoginRequest( db, user.id, user.email, ); const loginCtx = createLoginRequestContext(db, loginToken); 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", () => { test("register → authenticate → add second passkey → authenticate with either via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { 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(db, user.id, user.email, authenticator1); // Authenticate with first passkey via router await authenticate(db, user.id, user.email, authenticator1); // Register second passkey via router await registerPasskey(db, user.id, user.email, authenticator2); // Verify user now has 2 passkeys const passkeys = await getUserPasskeys(db, user.id); expect(passkeys).toHaveLength(2); // Authenticate with second passkey via router await authenticate(db, user.id, user.email, authenticator2); // Authenticate with first passkey again via router await authenticate(db, user.id, user.email, authenticator1); }); }); test("register → authenticate multiple times → counter increments via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "counter-test@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); // Register passkey via router await registerPasskey(db, user.id, user.email, authenticator); // Verify initial counter let passkeys = await getUserPasskeys(db, 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(db, user.id, user.email, authenticator); // Verify counter incremented passkeys = await getUserPasskeys(db, user.id); firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); expect(firstPasskey.counter).toBe(i); } }); }); }); describe("passkey management", () => { test("lists passkeys with correct data via router", async () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { 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(db, user.id, user.email, authenticator1); await registerPasskey(db, user.id, user.email, authenticator2); // List passkeys via router handler const ctx = await createUserAPIContext(db, user.id); 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "device-type@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); await registerPasskey(db, user.id, user.email, authenticator); const passkeys = await getUserPasskeys(db, 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "rename-test@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); await registerPasskey(db, user.id, user.email, authenticator); const ctx = await createUserAPIContext(db, user.id); 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user1 = await createTestUser(db, { email: "rename-user1@test.com", }); const user2 = await createTestUser(db, { email: "rename-user2@test.com", }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); await registerPasskey(db, user1.id, user1.email, auth1); await registerPasskey(db, user2.id, user2.email, auth2); const ctx1 = await createUserAPIContext(db, user1.id); const ctx2 = await createUserAPIContext(db, user2.id); 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 throw NOT_FOUND) try { await call( router.me.passkeys.rename, { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, { context: ctx1 }, ); throw new Error("Expected rename to fail with NOT_FOUND"); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("Passkey not found"); } // 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); }); }); // Note: This test uses getSharedDb() directly because the delete passkey // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("deletes passkey when user has password via router", async () => { const db = getSharedDb(); const user = await createTestUser(db, { email: "delete-with-password@test.com", passwordHash: "fake-password-hash", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); await registerPasskey(db, user.id, user.email, authenticator); const ctx = await createUserAPIContext(db, user.id); 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); }); // Note: This test uses getSharedDb() directly because the delete passkey // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("deletes passkey when user has multiple passkeys via router", async () => { const db = getSharedDb(); const user = await createTestUser(db, { email: "delete-multi@test.com", }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); await registerPasskey(db, user.id, user.email, auth1); await registerPasskey(db, user.id, user.email, auth2); const ctx = await createUserAPIContext(db, user.id); 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); }); // Note: This test uses getSharedDb() directly because the delete passkey // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("prevents deleting last passkey without password via router", async () => { const db = getSharedDb(); const user = await createTestUser(db, { email: "delete-last@test.com", // No password set }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); await registerPasskey(db, user.id, user.email, authenticator); const ctx = await createUserAPIContext(db, user.id); 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); }); // Note: This test uses getSharedDb() directly because the delete passkey // procedure internally uses db.transaction(), and Kysely doesn't support nested transactions. test("delete does not affect other user's passkeys via router", async () => { const db = getSharedDb(); const user1 = await createTestUser(db, { email: "delete-user1@test.com", passwordHash: "fake-hash", }); const user2 = await createTestUser(db, { 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(db, user1.id, user1.email, auth1); await registerPasskey(db, user2.id, user2.email, auth2); const ctx1 = await createUserAPIContext(db, user1.id); const ctx2 = await createUserAPIContext(db, user2.id); 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 throw NOT_FOUND) try { await call( router.me.passkeys.delete, { passkeyId: user2FirstPasskey.id }, { context: ctx1 }, ); throw new Error("Expected delete to fail with NOT_FOUND"); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain("Passkey not found"); } // 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "credential-id@test.com", }); const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin }); const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin }); await registerPasskey(db, user.id, user.email, auth1); await registerPasskey(db, user.id, user.email, auth2); const passkeys = await getUserPasskeys(db, 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 () => { await withTestTransaction(getSharedDb(), async (db) => { const user = await createTestUser(db, { email: "transports@test.com", }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin, }); await registerPasskey(db, user.id, user.email, authenticator); const passkeys = await getUserPasskeys(db, 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"); }); }); }); }); // Close outer describe.skipIf