From 74b26818ca5b3e81c2807e7e40377a7851b56eb5 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 17:55:39 +0800 Subject: [PATCH 1/2] Add comprehensive e2e tests for all auth procedures Tests cover all login scenarios from docs/initial-app.md: - Signup with password and passkey - Password login with trusted device (immediate completion) - Password login with untrusted device (email confirmation) - Full passkey authentication flow - User with no auth methods (stays pending) - Non-existent email (anti-enumeration with fake token) - Email verification and resend flows - Password reset with session revocation - Logout All auth procedures now have 100% function coverage. 127 tests passing across 3 e2e test files. Co-Authored-By: Claude Opus 4.5 --- .../api-server/src/__tests__/e2e/auth.test.ts | 1985 +++++++++++++++++ 1 file changed, 1985 insertions(+) create mode 100644 apps/api-server/src/__tests__/e2e/auth.test.ts diff --git a/apps/api-server/src/__tests__/e2e/auth.test.ts b/apps/api-server/src/__tests__/e2e/auth.test.ts new file mode 100644 index 0000000..4faa751 --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/auth.test.ts @@ -0,0 +1,1985 @@ +/** + * End-to-end tests for Auth procedures + * + * These tests cover ALL login scenarios from docs/initial-app.md: + * + * SIGNUP FLOWS: + * - Signup with password + * - Signup with passkey + * + * LOGIN FLOWS (from docs/initial-app.md Step 2: Authentication): + * - Has passkey → full passkey authentication flow + * - Has password + trusted device → immediate completion + * - Has password + new device → requires email confirmation + * - No password AND no passkey → polling stays pending + * - Non-existent email → anti-enumeration (fake token, returns pending) + * + * OTHER FLOWS: + * - Email verification flow + * - Password reset flow (with session revocation) + * - Logout + * + * Procedures tested: + * - auth.signup - create account with password or passkey + * - auth.createLoginRequest - first step of login flow + * - auth.loginPassword - password verification + * - auth.loginPasswordConfirm - email confirmation for untrusted devices + * - auth.loginIfRequestIsCompleted - poll for login completion + * - auth.webauthn.createRegistrationOptions - passkey registration + * - auth.webauthn.verifyRegistration - passkey registration verification + * - auth.webauthn.createAuthenticationOptions - passkey authentication + * - auth.webauthn.verifyAuthentication - passkey authentication verification + * - auth.verifyEmail - verify email token + * - auth.resendVerificationEmail - resend verification + * - auth.forgotPassword - request password reset + * - auth.resetPassword - reset with token + * - auth.logout - revoke session + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; +import type { APIContext } from "../../context.js"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { call } from "@orpc/server"; +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 { hashPassword } from "../../utils/password.js"; +import { TEST_RP } from "../helpers/test-constants.js"; +import { + createTestDb, + createTestUser, + destroyTestDb, + runMigrations, + truncateAllTables, +} from "../helpers/test-db.js"; + +/** Session expiry duration: 24 hours in milliseconds */ +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; + +/** Login request expiry: 15 minutes */ +const LOGIN_REQUEST_EXPIRY_MS = 15 * 60 * 1000; + +let db: Kysely | undefined; + +function getDb(): Kysely { + if (!db) { + throw new Error("Database not initialized"); + } + return db; +} + +/** + * Create an API context with optional cookies + */ +function createAPIContext(options?: { + sessionToken?: string; + loginRequestToken?: string; + deviceFingerprint?: string; +}): APIContext { + const reqHeaders = new Headers(); + const cookies: string[] = []; + + if (options?.sessionToken) { + cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); + } + if (options?.loginRequestToken) { + cookies.push( + `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${options.loginRequestToken}`, + ); + } + if (options?.deviceFingerprint) { + cookies.push( + `${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`, + ); + } + if (cookies.length > 0) { + reqHeaders.set("cookie", cookies.join("; ")); + } + + return { + db: getDb(), + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + reqHeaders, + resHeaders: new Headers(), + }; +} + +/** + * Extract cookie value from response headers + */ +function getCookieFromResponse( + headers: Headers, + cookieName: string, +): string | null { + const setCookies = headers.getSetCookie(); + for (const cookie of setCookies) { + if (cookie.startsWith(`${cookieName}=`)) { + const parts = cookie.split(";")[0]?.split("="); + const value = parts?.[1] ?? ""; + // Check if it's a deletion (empty value or max-age=0) + if (cookie.includes("Max-Age=0") || value === "") { + return null; + } + return value; + } + } + return null; +} + +/** + * Assert a value is not null/undefined and return it with narrowed type. + * Use this instead of non-null assertions (!) to satisfy linter. + */ +function assertDefined( + value: T | null | undefined, + message = "Expected value to be defined", +): T { + if (value == null) { + throw new Error(message); + } + return value; +} + +/** + * Create a session for a user and return the token + */ +async function createSession( + userId: number, + options?: { deviceId?: bigint }, +): Promise<{ token: string; sessionId: number }> { + const token = `test-session-${String(Date.now())}${String(Math.random())}`; + const tokenHashValue = await hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); + + const result = await getDb() + .insertInto("sessions") + .values({ + user_id: userId, + device_id: options?.deviceId ? String(options.deviceId) : null, + token_hash: tokenHashValue, + trusted_mode: false, + expires_at: expiresAt, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return { token, sessionId: Number(result.id) }; +} + +/** + * Create a login request for a user + */ +async function createLoginRequest( + userId: number, + email: string, + options?: { + deviceFingerprint?: string; + completedAt?: Date | null; + expiresAt?: Date; + }, +): Promise<{ token: string; id: number }> { + const token = `login_test-${String(Date.now())}${String(Math.random())}`; + const expiresAt = + options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); + + const result = await getDb() + .insertInto("login_requests") + .values({ + user_id: userId, + email, + token, + device_fingerprint: options?.deviceFingerprint ?? "test-fingerprint", + expires_at: expiresAt, + completed_at: options?.completedAt ?? null, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return { token, id: Number(result.id) }; +} + +/** + * Create a trusted device for a user + */ +async function createTrustedDevice( + userId: number, + fingerprint: string, +): Promise { + const result = await getDb() + .insertInto("user_devices") + .values({ + user_id: userId, + device_fingerprint: fingerprint, + is_trusted: true, + user_agent: "Test Browser", + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return BigInt(result.id); +} + +/** + * Create an email verification token + */ +async function createEmailVerification( + userId: number, + options?: { expiresAt?: Date }, +): Promise { + const token = `verify-${String(Date.now())}${String(Math.random())}`; + const expiresAt = + options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000); + + await getDb() + .insertInto("email_verifications") + .values({ + user_id: userId, + token, + expires_at: expiresAt, + }) + .execute(); + + return token; +} + +/** + * Create a password reset token + */ +async function createPasswordReset( + userId: number, + options?: { expiresAt?: Date; usedAt?: Date | null }, +): Promise { + const token = `reset-${String(Date.now())}${String(Math.random())}`; + const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000); + + await getDb() + .insertInto("password_resets") + .values({ + user_id: userId, + token, + expires_at: expiresAt, + used_at: options?.usedAt ?? null, + }) + .execute(); + + return token; +} + +// Test setup +beforeAll(async () => { + await runMigrations(); + db = createTestDb(); +}); + +afterAll(async () => { + if (db) { + await destroyTestDb(db); + } +}); + +beforeEach(async () => { + await truncateAllTables(getDb()); +}); + +// ============================================================================= +// auth.signup tests +// ============================================================================= + +describe("auth.signup", () => { + test("creates user with valid password", async () => { + const ctx = createAPIContext(); + + const result = await call( + router.auth.signup, + { email: "newuser@example.com", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify user was created + const user = await getDb() + .selectFrom("users") + .selectAll() + .where("email", "=", "newuser@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + expect(user?.password_hash).not.toBeNull(); + expect(user?.email_verified_at).toBeNull(); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify session was created in DB + const sessions = await getDb() + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(sessions.length).toBe(1); + + // Verify email verification token was created + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(verifications.length).toBe(1); + }); + + test("normalizes email to lowercase", async () => { + const ctx = createAPIContext(); + + await call( + router.auth.signup, + { email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); + + const user = await getDb() + .selectFrom("users") + .select(["email"]) + .where("email", "=", "uppercase@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + }); + + test("rejects weak password", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { email: "weak@example.com", password: "password" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); + + test("rejects duplicate email (anti-enumeration)", async () => { + // Create existing user + await createTestUser(getDb(), { email: "existing@example.com" }); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { email: "existing@example.com", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Unable to create account"); + }); + + test("rejects signup without password or passkey", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { email: "noauth@example.com" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); + + test("creates user with passkey", async () => { + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const ctx = createAPIContext(); + + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "passkeyuser@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential with virtual authenticator + const response = authenticator.createCredential(options); + + // Step 3: Signup with passkey + const signupCtx = createAPIContext(); + const result = await call( + router.auth.signup, + { + email: "passkeyuser@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ); + + expect(result.success).toBe(true); + + // Verify user was created + const user = await getDb() + .selectFrom("users") + .selectAll() + .where("email", "=", "passkeyuser@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + expect(user?.password_hash).toBeNull(); // No password for passkey signup + expect(user?.email_verified_at).toBeNull(); + + // Verify passkey was stored + const passkeys = await getDb() + .selectFrom("passkeys") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + + expect(passkeys.length).toBe(1); + expect(passkeys[0]?.name).toBeDefined(); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + signupCtx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify webauthn challenge was deleted + const challenges = await getDb() + .selectFrom("webauthn_challenges") + .selectAll() + .where("id", "=", String(challengeId)) + .execute(); + expect(challenges.length).toBe(0); + }); + + test("rejects passkey signup with expired challenge", async () => { + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const ctx = createAPIContext(); + + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "expiredchallenge@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential + const response = authenticator.createCredential(options); + + // Step 3: Expire the challenge by updating created_at + await getDb() + .updateTable("webauthn_challenges") + .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago + .where("id", "=", String(challengeId)) + .execute(); + + // Step 4: Try to signup with expired challenge + const signupCtx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { + email: "expiredchallenge@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ), + ).rejects.toThrow("Registration timed out"); + }); + + test("rejects passkey signup with invalid response", async () => { + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const ctx = createAPIContext(); + + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "invalidresponse@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential + const response = authenticator.createCredential(options); + + // Step 3: Tamper with the response + response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64 + + // Step 4: Try to signup with invalid response + const signupCtx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { + email: "invalidresponse@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ), + ).rejects.toThrow("Failed to register your device"); + + // Verify challenge was deleted (cleanup on error) + const challenges = await getDb() + .selectFrom("webauthn_challenges") + .selectAll() + .where("id", "=", String(challengeId)) + .execute(); + expect(challenges.length).toBe(0); + }); +}); + +// ============================================================================= +// auth.createLoginRequest tests +// ============================================================================= + +describe("auth.createLoginRequest", () => { + test("returns auth methods for existing user with password", async () => { + await createTestUser(getDb(), { + email: "haspassword@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "haspassword@example.com" }, + { context: ctx }, + ); + + expect(result.hasPassword).toBe(true); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + expect(result.email).toBe("haspassword@example.com"); + + // Verify login request was created + const loginRequests = await getDb() + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(1); + + // Verify login request token cookie was set + const token = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(token).not.toBeNull(); + expect(token).toStartWith("login_"); + }); + + test("detects trusted device", async () => { + const user = await createTestUser(getDb(), { + email: "trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "trusted-device-fp"; + await createTrustedDevice(user.id, fingerprint); + + const ctx = createAPIContext({ deviceFingerprint: fingerprint }); + const result = await call( + router.auth.createLoginRequest, + { email: "trusted@example.com" }, + { context: ctx }, + ); + + expect(result.isTrustedDevice).toBe(true); + }); + + test("returns fake response for non-existent user (anti-enumeration)", async () => { + const ctx = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "nonexistent@example.com" }, + { context: ctx }, + ); + + // Should return all false (same as user without any auth methods) + expect(result.hasPassword).toBe(false); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + + // Should still set a login request token cookie (fake one) + const token = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(token).not.toBeNull(); + + // Should NOT create a login request in DB + const loginRequests = await getDb() + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(0); + }); + + test("normalizes email to lowercase", async () => { + await createTestUser(getDb(), { + email: "lowercase@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "LOWERCASE@EXAMPLE.COM" }, + { context: ctx }, + ); + + expect(result.hasPassword).toBe(true); + }); + + test("generates device fingerprint if not present", async () => { + await createTestUser(getDb(), { + email: "nofingerprint@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(); // No device fingerprint + await call( + router.auth.createLoginRequest, + { email: "nofingerprint@example.com" }, + { context: ctx }, + ); + + // Should set device fingerprint cookie + const fingerprint = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.DEVICE_FINGERPRINT, + ); + expect(fingerprint).not.toBeNull(); + }); +}); + +// ============================================================================= +// auth.loginPassword tests +// ============================================================================= + +describe("auth.loginPassword", () => { + test("completes login immediately for trusted device", async () => { + const user = await createTestUser(getDb(), { + email: "trustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "trusted-login-fp"; + await createTrustedDevice(user.id, fingerprint); + + const { token: loginToken } = await createLoginRequest( + user.id, + "trustedlogin@example.com", + { deviceFingerprint: fingerprint }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + + const result = await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was marked as completed + const loginRequest = await getDb() + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).not.toBeNull(); + }); + + test("sends email for untrusted device (does not complete immediately)", async () => { + const user = await createTestUser(getDb(), { + email: "untrustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "untrusted-login-fp"; + const { token: loginToken } = await createLoginRequest( + user.id, + "untrustedlogin@example.com", + { deviceFingerprint: fingerprint }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + + const result = await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was NOT marked as completed (needs email confirmation) + const loginRequest = await getDb() + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).toBeNull(); + }); + + test("rejects invalid password", async () => { + const user = await createTestUser(getDb(), { + email: "wrongpass@example.com", + passwordHash: await hashPassword("CorrectPassword123!"), + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "wrongpass@example.com", + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "WrongPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + + test("rejects expired login request", async () => { + const user = await createTestUser(getDb(), { + email: "expired@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "expired@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Login request has expired"); + }); + + test("rejects when no login request token cookie", async () => { + const ctx = createAPIContext(); // No login request token + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + + test("rejects fake/invalid login request token", async () => { + const ctx = createAPIContext({ loginRequestToken: "fake-token-12345" }); + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + + test("rejects user without password set", async () => { + const user = await createTestUser(getDb(), { + email: "nopassword@example.com", + // No password hash + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "nopassword@example.com", + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); +}); + +// ============================================================================= +// auth.loginPasswordConfirm tests +// ============================================================================= + +describe("auth.loginPasswordConfirm", () => { + test("marks login request as completed with valid token", async () => { + const user = await createTestUser(getDb(), { + email: "confirm@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "confirm@example.com", + ); + + const ctx = createAPIContext(); + const result = await call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was marked as completed + const loginRequest = await getDb() + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).not.toBeNull(); + }); + + test("is idempotent for already completed requests", async () => { + const user = await createTestUser(getDb(), { + email: "idempotent@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "idempotent@example.com", + { completedAt: new Date() }, // Already completed + ); + + const ctx = createAPIContext(); + const result = await call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + }); + + test("rejects invalid token", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); + + test("rejects expired token", async () => { + const user = await createTestUser(getDb(), { + email: "expiredconfirm@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "expiredconfirm@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); +}); + +// ============================================================================= +// auth.loginIfRequestIsCompleted tests +// ============================================================================= + +describe("auth.loginIfRequestIsCompleted", () => { + test("returns pending for incomplete login request", async () => { + const user = await createTestUser(getDb(), { + email: "pending@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "pending@example.com", + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); + + test("returns expired for expired login request", async () => { + const user = await createTestUser(getDb(), { + email: "expiredpoll@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "expiredpoll@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("expired"); + }); + + test("creates session and returns completed for completed request", async () => { + const user = await createTestUser(getDb(), { + email: "completed@example.com", + }); + + const fingerprint = "completed-fp"; + const { token: loginToken, id: loginRequestId } = await createLoginRequest( + user.id, + "completed@example.com", + { deviceFingerprint: fingerprint, completedAt: new Date() }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/auth/trust-device"); // Not trusted yet + + // Verify session was created + const sessions = await getDb() + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(sessions.length).toBe(1); + expect(sessions[0]?.trusted_mode).toBe(true); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify login request was deleted + const loginRequest = await getDb() + .selectFrom("login_requests") + .selectAll() + .where("id", "=", String(loginRequestId)) + .executeTakeFirst(); + expect(loginRequest).toBeUndefined(); + + // Verify user device was created + const devices = await getDb() + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(devices.length).toBe(1); + }); + + test("redirects to dashboard if device is already trusted", async () => { + const user = await createTestUser(getDb(), { + email: "alreadytrusted@example.com", + }); + + const fingerprint = "already-trusted-fp"; + await createTrustedDevice(user.id, fingerprint); + + const { token: loginToken } = await createLoginRequest( + user.id, + "alreadytrusted@example.com", + { deviceFingerprint: fingerprint, completedAt: new Date() }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/dashboard"); + }); + + test("returns pending for fake/non-existent token", async () => { + const ctx = createAPIContext({ loginRequestToken: "fake-token-xyz" }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); + + test("returns pending when no cookie present", async () => { + const ctx = createAPIContext(); // No login request token + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); + + test("returns pending when device fingerprint is missing", async () => { + const user = await createTestUser(getDb(), { + email: "nofp@example.com", + }); + + // Create login request without device fingerprint + const token = `login_test-${String(Date.now())}`; + await getDb() + .insertInto("login_requests") + .values({ + user_id: user.id, + email: "nofp@example.com", + token, + device_fingerprint: null, // No fingerprint + expires_at: new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS), + completed_at: new Date(), + }) + .execute(); + + const ctx = createAPIContext({ loginRequestToken: token }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); +}); + +// ============================================================================= +// auth.verifyEmail tests +// ============================================================================= + +describe("auth.verifyEmail", () => { + test("verifies email with valid token", async () => { + const user = await createTestUser(getDb(), { + email: "verify@example.com", + }); + + const token = await createEmailVerification(user.id); + + const ctx = createAPIContext(); + const result = await call( + router.auth.verifyEmail, + { token }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify user's email_verified_at was set + const updatedUser = await getDb() + .selectFrom("users") + .select(["email_verified_at"]) + .where("id", "=", user.id) + .executeTakeFirst(); + + expect(updatedUser?.email_verified_at).not.toBeNull(); + + // Verify verification record was deleted + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); + }); + + test("rejects invalid token", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.verifyEmail, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired token"); + }); + + test("rejects expired token and cleans up", async () => { + const user = await createTestUser(getDb(), { + email: "expiredverify@example.com", + }); + + const token = await createEmailVerification(user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const ctx = createAPIContext(); + + await expect( + call(router.auth.verifyEmail, { token }, { context: ctx }), + ).rejects.toThrow("Invalid or expired token"); + + // Verify expired token was cleaned up + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); + }); +}); + +// ============================================================================= +// auth.resendVerificationEmail tests +// ============================================================================= + +describe("auth.resendVerificationEmail", () => { + test("creates new verification token for unverified user", async () => { + const user = await createTestUser(getDb(), { + email: "resend@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + const result = await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + expect(result.success).toBe(true); + + // Verify new verification token was created + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(1); + }); + + test("deletes old verification tokens before creating new one", async () => { + const user = await createTestUser(getDb(), { + email: "resendold@example.com", + }); + + // Create existing verification + await createEmailVerification(user.id); + + const { token: sessionToken } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + // Should still have only 1 verification (old one deleted, new one created) + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(1); + }); + + test("returns success for already verified user (no-op)", async () => { + const user = await createTestUser(getDb(), { + email: "alreadyverified@example.com", + emailVerifiedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + const result = await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + expect(result.success).toBe(true); + + // No verification token should be created + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); + }); + + test("requires authentication", async () => { + const ctx = createAPIContext(); // No session + + await expect( + call(router.auth.resendVerificationEmail, undefined, { context: ctx }), + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// auth.forgotPassword tests +// ============================================================================= + +describe("auth.forgotPassword", () => { + test("creates password reset token for existing user", async () => { + const user = await createTestUser(getDb(), { + email: "forgot@example.com", + }); + + const ctx = createAPIContext(); + const result = await call( + router.auth.forgotPassword, + { email: "forgot@example.com" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify password reset token was created + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); + }); + + test("returns success for non-existent user (anti-enumeration)", async () => { + const ctx = createAPIContext(); + const result = await call( + router.auth.forgotPassword, + { email: "nonexistent@example.com" }, + { context: ctx }, + ); + + // Should still return success (anti-enumeration) + expect(result.success).toBe(true); + + // No password reset should be created + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .execute(); + expect(resets.length).toBe(0); + }); + + test("deletes existing password reset tokens before creating new one", async () => { + const user = await createTestUser(getDb(), { + email: "forgotold@example.com", + }); + + // Create existing reset token + await createPasswordReset(user.id); + + const ctx = createAPIContext(); + await call( + router.auth.forgotPassword, + { email: "forgotold@example.com" }, + { context: ctx }, + ); + + // Should have only 1 reset token (old one deleted) + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); + }); + + test("normalizes email to lowercase", async () => { + const user = await createTestUser(getDb(), { + email: "forgotcase@example.com", + }); + + const ctx = createAPIContext(); + await call( + router.auth.forgotPassword, + { email: "FORGOTCASE@EXAMPLE.COM" }, + { context: ctx }, + ); + + // Should find the user and create reset token + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); + }); +}); + +// ============================================================================= +// auth.resetPassword tests +// ============================================================================= + +describe("auth.resetPassword", () => { + test("resets password with valid token", async () => { + const user = await createTestUser(getDb(), { + email: "reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + const token = await createPasswordReset(user.id); + + const ctx = createAPIContext(); + const result = await call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify password was updated (can't directly verify hash, but check updated_at) + const updatedUser = await getDb() + .selectFrom("users") + .select(["password_hash", "updated_at"]) + .where("id", "=", user.id) + .executeTakeFirst(); + + expect(updatedUser?.password_hash).not.toBeNull(); + + // Verify reset token was marked as used + const reset = await getDb() + .selectFrom("password_resets") + .select(["used_at"]) + .where("token", "=", token) + .executeTakeFirst(); + + expect(reset?.used_at).not.toBeNull(); + }); + + test("revokes all sessions after password reset", async () => { + const user = await createTestUser(getDb(), { + email: "resetrevoke@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + // Create some sessions + await createSession(user.id); + await createSession(user.id); + + const token = await createPasswordReset(user.id); + + const ctx = createAPIContext(); + await call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ); + + // Verify all sessions were revoked + const sessions = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("user_id", "=", user.id) + .execute(); + + for (const session of sessions) { + expect(session.revoked_at).not.toBeNull(); + } + }); + + test("rejects invalid token", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired reset token"); + }); + + test("rejects expired token", async () => { + const user = await createTestUser(getDb(), { + email: "resetexpired@example.com", + }); + + const token = await createPasswordReset(user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has expired"); + }); + + test("rejects already used token", async () => { + const user = await createTestUser(getDb(), { + email: "resetused@example.com", + }); + + const token = await createPasswordReset(user.id, { + usedAt: new Date(), // Already used + }); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has already been used"); + }); + + test("rejects weak password", async () => { + const user = await createTestUser(getDb(), { + email: "resetweak@example.com", + }); + + const token = await createPasswordReset(user.id); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "weak" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// auth.logout tests +// ============================================================================= + +describe("auth.logout", () => { + test("revokes current session", async () => { + const user = await createTestUser(getDb(), { + email: "logout@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + const result = await call(router.auth.logout, undefined, { context: ctx }); + + expect(result.success).toBe(true); + + // Verify session was revoked + const session = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId)) + .executeTakeFirst(); + + expect(session?.revoked_at).not.toBeNull(); + + // Verify session cookie was deleted + const setCookies = ctx.resHeaders.getSetCookie(); + const sessionCookie = setCookies.find((c) => + c.startsWith(`${COOKIE_NAMES.SESSION_TOKEN}=`), + ); + expect(sessionCookie).toContain("Max-Age=0"); + }); + + test("requires authentication", async () => { + const ctx = createAPIContext(); // No session + + await expect( + call(router.auth.logout, undefined, { context: ctx }), + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// End-to-end login scenarios from docs/initial-app.md +// ============================================================================= + +describe("End-to-end login scenarios", () => { + test("Scenario: Password login with trusted device (immediate completion)", async () => { + // Setup: User with password and trusted device + const user = await createTestUser(getDb(), { + email: "e2e-trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "e2e-trusted-device"; + await createTrustedDevice(user.id, fingerprint); + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-trusted@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPassword).toBe(true); + expect(loginRequestResult.isTrustedDevice).toBe(true); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Step 2: Login with password (should complete immediately for trusted device) + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx2 }, + ); + + // Step 3: Poll for completion + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(completedResult.status).toBe("completed"); + expect(completedResult.redirectTo).toBe("/dashboard"); // Already trusted + + // Verify session was created + const sessionToken = getCookieFromResponse( + ctx3.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + }); + + test("Scenario: Password login with untrusted device (requires email confirmation)", async () => { + // Setup: User with password but no trusted device + await createTestUser(getDb(), { + email: "e2e-untrusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "e2e-untrusted-device"; + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-untrusted@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPassword).toBe(true); + expect(loginRequestResult.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Step 2: Login with password (should NOT complete - needs email confirmation) + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx2 }, + ); + + // Step 3: Poll should return pending (email not confirmed yet) + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const pendingResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(pendingResult.status).toBe("pending"); + + // Step 4: User clicks email confirmation link + const ctx4 = createAPIContext(); + await call( + router.auth.loginPasswordConfirm, + { token: assertDefined(loginToken) }, + { context: ctx4 }, + ); + + // Step 5: Poll should now return completed + const ctx5 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx5 }, + ); + + expect(completedResult.status).toBe("completed"); + expect(completedResult.redirectTo).toBe("/auth/trust-device"); // Not yet trusted + }); + + test("Scenario: Login attempt with non-existent email (anti-enumeration)", async () => { + // Step 1: Create login request for non-existent email + const ctx1 = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "doesnotexist@example.com" }, + { context: ctx1 }, + ); + + // Should return all false (indistinguishable from user without auth methods) + expect(result.hasPassword).toBe(false); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); // Still get a token (fake) + + // Step 2: Trying to login with password should fail + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + }); + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx2 }, + ), + ).rejects.toThrow("Invalid email or password"); + + // Step 3: Polling should return pending until expired + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + }); + const pollResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(pollResult.status).toBe("pending"); // Fake token - always pending + }); + + test("Scenario: Complete password reset flow", async () => { + // Setup: User with existing password and sessions + const user = await createTestUser(getDb(), { + email: "e2e-reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + await createSession(user.id); + await createSession(user.id); + + // Step 1: Request password reset + const ctx1 = createAPIContext(); + await call( + router.auth.forgotPassword, + { email: "e2e-reset@example.com" }, + { context: ctx1 }, + ); + + // Get the token from DB (in real flow, this would be from email) + const reset = await getDb() + .selectFrom("password_resets") + .select(["token"]) + .where("user_id", "=", user.id) + .executeTakeFirst(); + + // Step 2: Reset password + const ctx2 = createAPIContext(); + await call( + router.auth.resetPassword, + { token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, + { context: ctx2 }, + ); + + // Verify all old sessions were revoked + const sessions = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("user_id", "=", user.id) + .execute(); + + for (const session of sessions) { + expect(session.revoked_at).not.toBeNull(); + } + + // Step 3: Login with new password should work + const ctx3 = createAPIContext(); + await call( + router.auth.createLoginRequest, + { email: "e2e-reset@example.com" }, + { context: ctx3 }, + ); + + const loginToken = getCookieFromResponse( + ctx3.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Mark login as completed (simulate trusted device or email confirmation) + await getDb() + .updateTable("login_requests") + .set({ completed_at: new Date() }) + .where("token", "=", assertDefined(loginToken)) + .execute(); + + const ctx4 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx4 }, + ); + + expect(result.status).toBe("completed"); + }); + + test("Scenario: Passkey login flow (full e2e)", async () => { + // Setup: User with passkey + const user = await createTestUser(getDb(), { + email: "e2e-passkey-login@example.com", + }); + + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const fingerprint = "e2e-passkey-device"; + + // Create a session for passkey registration (registration requires auth) + const { token: regSessionToken, sessionId: regSessionId } = + await createSession(user.id); + + // Create registration options + const regOptionsCtx = createAPIContext({ + sessionToken: regSessionToken, + deviceFingerprint: fingerprint, + }); + const { options: regOptions, challengeId: regChallengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: regOptionsCtx }, + ); + + // Create credential with virtual authenticator + const regResponse = authenticator.createCredential(regOptions); + + // Verify registration + const verifyRegCtx = createAPIContext({ + sessionToken: regSessionToken, + deviceFingerprint: fingerprint, + }); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: regChallengeId, response: regResponse }, + { context: verifyRegCtx }, + ); + + // Clean up registration session + await getDb() + .deleteFrom("sessions") + .where("id", "=", String(regSessionId)) + .execute(); + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-passkey-login@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPasskey).toBe(true); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); + + // Step 2: Create authentication options + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: ctx2 }, + ); + + expect(authOptions.allowCredentials).toHaveLength(1); + + // Step 3: Authenticate with passkey + const authResponse = authenticator.getAssertion(authOptions); + + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: ctx3 }, + ); + + // Step 4: Poll for completion - should be completed now + const ctx4 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx4 }, + ); + + expect(completedResult.status).toBe("completed"); + // Passkey login creates a trusted session, but device is not yet trusted + // So user is redirected to trust-device screen + expect(completedResult.redirectTo).toBe("/auth/trust-device"); + + // Verify session was created with trusted_mode = true + const sessions = await getDb() + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + + expect(sessions.length).toBe(1); + expect(sessions[0]?.trusted_mode).toBe(true); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx4.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + }); + + test("Scenario: User with no auth methods (no password, no passkey)", async () => { + // Setup: User without any auth methods set up + // This simulates a user who was created but never completed setup + await createTestUser(getDb(), { + email: "e2e-no-auth@example.com", + // No password hash + }); + + const fingerprint = "e2e-no-auth-device"; + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-no-auth@example.com" }, + { context: ctx1 }, + ); + + // Should indicate no auth methods available + expect(loginRequestResult.hasPassword).toBe(false); + expect(loginRequestResult.hasPasskey).toBe(false); + expect(loginRequestResult.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); + + // Step 2: Poll should return pending (no way to complete login) + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const pendingResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx2 }, + ); + + expect(pendingResult.status).toBe("pending"); + + // According to docs: "Shows 'Check your email' but no email sent, polling will expire" + // The login request exists but can never be completed since there's no auth method + + // Verify login request exists but is not completed + const loginRequest = await getDb() + .selectFrom("login_requests") + .selectAll() + .where("token", "=", assertDefined(loginToken)) + .executeTakeFirst(); + + expect(loginRequest).toBeDefined(); + expect(loginRequest?.completed_at).toBeNull(); + }); +}); From 319edf70db06edf363711ea417bd4f623711db5a Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 18:08:21 +0800 Subject: [PATCH 2/2] Fix IP address not being set on sessions from localhost The extractClientIP() function only checked proxy headers (X-Forwarded-For, CF-Connecting-IP, etc.) which don't exist when running locally without a proxy. Changes: - Add clientIP field to APIContext - Use Bun's server.requestIP() to get client IP from direct socket connection - Update getGeoInfo() to accept fallback IP parameter - Pass context.clientIP to getGeoInfo() in auth procedures Now sessions will have IP address set even for local development (::1 or 127.0.0.1). Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/context.ts | 2 ++ apps/api-server/src/index.ts | 7 ++++++- .../src/procedures/auth/create-login-request.ts | 2 +- .../src/procedures/auth/login-if-completed.ts | 2 +- apps/api-server/src/procedures/auth/signup.ts | 2 +- apps/api-server/src/utils/geo.ts | 11 +++++++++-- 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts index 4e4458b..ac030e6 100644 --- a/apps/api-server/src/context.ts +++ b/apps/api-server/src/context.ts @@ -21,6 +21,8 @@ export interface APIContext { reqHeaders: Headers; /** Response headers (for setting cookies) */ resHeaders: Headers; + /** Client IP address from direct connection (fallback when no proxy headers) */ + clientIP?: string | null; } /** diff --git a/apps/api-server/src/index.ts b/apps/api-server/src/index.ts index 15eb2d9..e2a8926 100644 --- a/apps/api-server/src/index.ts +++ b/apps/api-server/src/index.ts @@ -39,7 +39,7 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME; Bun.serve({ port, - async fetch(request) { + async fetch(request, server) { const url = new URL(request.url); if (url.pathname.startsWith("/api/v1/rpc")) { @@ -50,6 +50,10 @@ Bun.serve({ // Create response headers for setting cookies const resHeaders = new Headers(); + // Get client IP from Bun's server (fallback for when no proxy headers) + const socketInfo = server.requestIP(request); + const clientIP = socketInfo?.address ?? null; + const context: APIContext = { db, origin, @@ -57,6 +61,7 @@ Bun.serve({ rpName, reqHeaders: request.headers, resHeaders, + clientIP, }; const { response } = await handler.handle(request, { diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts index 0cc0935..551bdb7 100644 --- a/apps/api-server/src/procedures/auth/create-login-request.ts +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( const hasPassword = user.password_hash !== null; // Get geo info and user agent - const geo = getGeoInfo(context.reqHeaders); + const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); // Create login request with secure token diff --git a/apps/api-server/src/procedures/auth/login-if-completed.ts b/apps/api-server/src/procedures/auth/login-if-completed.ts index 513429b..cf438e1 100644 --- a/apps/api-server/src/procedures/auth/login-if-completed.ts +++ b/apps/api-server/src/procedures/auth/login-if-completed.ts @@ -86,7 +86,7 @@ export const loginIfRequestIsCompleted = } // Get current request info - const geo = getGeoInfo(context.reqHeaders); + const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); // Upsert user device diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index 3015c8f..b2efabf 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -225,7 +225,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { } // Get geo info and user agent for session creation - const geo = getGeoInfo(context.reqHeaders); + const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); let userId: number; diff --git a/apps/api-server/src/utils/geo.ts b/apps/api-server/src/utils/geo.ts index abccdb9..4c24374 100644 --- a/apps/api-server/src/utils/geo.ts +++ b/apps/api-server/src/utils/geo.ts @@ -126,9 +126,16 @@ export const lookupGeoFromIP = ( /** * Extract geolocation info from request headers. * Uses Cloudflare headers when available, falls back to GeoIP database lookup. + * + * @param headers - Request headers to extract proxy IP headers from + * @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP) */ -export const getGeoInfo = (headers: Headers): GeoInfo => { - const ip = extractClientIP(headers); +export const getGeoInfo = ( + headers: Headers, + fallbackIP?: string | null, +): GeoInfo => { + // Try proxy headers first, then fall back to direct connection IP + const ip = extractClientIP(headers) ?? fallbackIP ?? null; // Try Cloudflare geo headers first const cfCountry = headers.get("CF-IPCountry");