import { Hono } from "jsr:@hono/hono@4"; import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { decodeBase64Url } from "jsr:@std/encoding@1/base64url"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "jsr:@simplewebauthn/server@13"; import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13"; import { sqlWrapper } from "../../db.ts"; import { valkey } from "../../valkey.ts"; import { auditWrapper } from "../../audit.ts"; import { extractAllSessionIds } from "../../auth-session.ts"; import { getClientIp, publicRateLimiter } from "../../middleware.ts"; import { getCookieDomain } from "./utils.ts"; export const loginAuthRoutes = new Hono(); const rpID = Deno.env.get("RP_ID") || (import.meta.main ? undefined : "localhost"); const origin = Deno.env.get("ORIGIN") || (import.meta.main ? undefined : "http://localhost"); function generateSessionId() { return crypto.randomUUID(); } loginAuthRoutes.use("/api/login/*", publicRateLimiter); // Start a WebAuthn authentication ceremony loginAuthRoutes.post("/api/login/challenge", async (c) => { let body; try { body = await c.req.json(); } catch (_err) { body = {}; } const username = body.username; let extensions: any = undefined; let allowCredentials: any[] | undefined = undefined; if (username) { const user = await sqlWrapper .sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => res[0] ); if (user) { const passkeys = await sqlWrapper .sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`; if (passkeys.length > 0) { allowCredentials = passkeys.map((pk: any) => ({ id: pk.credential_id, type: "public-key", })); const prfPasskeys = passkeys.filter((pk: any) => pk.prf_enabled && pk.prf_salt ); if (prfPasskeys.length > 0) { extensions = { ["prf" as string]: { evalByCredential: {} }, }; for (const pk of prfPasskeys) { const saltBytes = decodeBase64Url(pk.prf_salt); extensions["prf"]["evalByCredential"][pk.credential_id] = { first: saltBytes, }; } } } } } if (!rpID) throw new Error("rpID is missing"); const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", timeout: 60000, allowCredentials, extensions, }); setCookie(c, "expected_authentication_challenge", options.challenge, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); return c.json({ options }); }); // Verify login and issue session loginAuthRoutes.post("/api/login/verify", async (c) => { const { response } = await c.req.json(); const expectedChallenge = getCookie(c, "expected_authentication_challenge"); if (!expectedChallenge) { return c.json( { error: "Missing or expired authentication challenge" }, 400, ); } const base64CredentialID = response.id; const passkey = await sqlWrapper .sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}` .then((res: any) => res[0]); if (!passkey) { return c.json({ error: "Passkey not found. Please register your passkey first.", }, 404); } const user = await sqlWrapper .sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}` .then((res: any) => res[0]); if (!user) { return c.json({ error: "User not found" }, 404); } const userId = user.id; if (user.account_status !== "active") { auditWrapper.auditLog( userId, "login_failed", null, { reason: `Account status is ${user.account_status}` }, getClientIp(c), ); return c.json({ error: "Account is not active. Please contact an administrator.", }, 403); } const publicKeyBytes = decodeBase64Url(passkey.public_key); if (!origin || !rpID) throw new Error("Missing origin or rpID"); let verification; try { verification = await verifyAuthenticationResponse({ response: response as AuthenticationResponseJSON, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, credential: { id: passkey.credential_id, publicKey: publicKeyBytes, counter: Number(passkey.counter), }, }); } catch (error: any) { return c.json({ error: error.message }, 400); } const { verified, authenticationInfo } = verification; if (!verified || !authenticationInfo) { auditWrapper.auditLog( userId, "login_failed", null, { reason: "verification failed" }, getClientIp(c), ); return c.json({ error: "Verification failed" }, 400); } await sqlWrapper .sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`; const sessionId = generateSessionId(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); // Persistence in PostgreSQL await sqlWrapper .sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`; // Write session to Valkey with TTL matching expiresAt const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000); try { const sessionData = JSON.stringify({ uuid: user.id, username: user.username, }); await valkey.setex(sessionId, ttlSeconds, sessionData); } catch (_err: unknown) { // If Valkey fails, log and fail closed for security auditWrapper.auditLog( user.id, "login_failed", null, { reason: "Cache write failure" }, getClientIp(c), ); return c.json({ error: "Internal server error" }, 500); } const oldSessionIds = extractAllSessionIds(c); if (oldSessionIds.length > 0) { for (const old of oldSessionIds) { try { await valkey.del(old); await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`; } catch (_e) {} } } const cookieDomain = getCookieDomain(rpID); setCookie(c, "session_id", sessionId, { domain: cookieDomain, path: "/", httpOnly: true, secure: true, sameSite: "Lax", expires: expiresAt, }); setCookie(c, "expected_authentication_challenge", "", { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 0, }); auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c)); return c.json({ success: true }); });