import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server"; import * as argon2 from "argon2"; import { Elysia, t } from "elysia"; import config from "../config.ts"; import { ADMIN_USERNAME, CHALLENGE_TTL_MS, LOGIN_MAX_ATTEMPTS, LOGIN_RATE_WINDOW_MS, MIN_PASSWORD_LENGTH, REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS, SESSION_DURATION_MS, SESSION_DURATION_SECONDS, SESSION_ID_BYTES, VALID_USERNAME_RE, WEBAUTHN_RP_NAME, } from "../constants.ts"; import { db } from "../db/index.ts"; import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts"; import { redirect } from "../lib/redirect.ts"; import { resolveSession } from "../middleware/session.ts"; import { Login } from "../views/auth/Login.tsx"; import { Register } from "../views/auth/Register.tsx"; import { html } from "../views/render.tsx"; // WebAuthn's expected origin and RP ID are pinned to the configured public // origin (BASE_URL), never derived from the client's Origin header — otherwise // the server-side origin check in verification validates the value against // itself and becomes a no-op. The browser must be on this origin for passkeys // to work, which is the intended production posture (set BASE_URL). const PUBLIC_RP_ID = new URL(config.PUBLIC_ORIGIN).hostname; function rpFromRequest(_request: Request): { origin: string; rpId: string } { return { origin: config.PUBLIC_ORIGIN, rpId: PUBLIC_RP_ID }; } function randomHex(bytes: number): string { const arr = new Uint8Array(bytes); crypto.getRandomValues(arr); return Array.from(arr) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } async function createSession(userId: number): Promise { const id = randomHex(SESSION_ID_BYTES); const now = new Date(); const expires = new Date(now.getTime() + SESSION_DURATION_MS); await db .insertInto("sessions") .values({ id, user_id: userId, expires_at: expires.toISOString(), created_at: now.toISOString(), }) .execute(); return id; } function sessionCookie(id: string): string { const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; return `session=${id}; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=${SESSION_DURATION_SECONDS}`; } function clearCookie(): string { const secure = config.PUBLIC_HTTPS ? "; Secure" : ""; return `session=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`; } // In-memory challenge store (fine for single-process) type ChallengeEntry = { challenge: string; timeoutId: ReturnType; }; const pendingChallenges = new Map(); function setChallenge(key: string, challenge: string): void { const existing = pendingChallenges.get(key); if (existing) clearTimeout(existing.timeoutId); const timeoutId = setTimeout( () => pendingChallenges.delete(key), CHALLENGE_TTL_MS, ); pendingChallenges.set(key, { challenge, timeoutId }); } function deleteChallenge(key: string): void { const entry = pendingChallenges.get(key); if (entry) clearTimeout(entry.timeoutId); pendingChallenges.delete(key); } export const authRoutes = new Elysia() .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }), }) .get("/login", () => { return html(); }) .post( "/login", async ({ body, request, server }) => { const ip = getClientIp(request, server); if ( !checkRateLimit( ip, "login", LOGIN_MAX_ATTEMPTS, LOGIN_RATE_WINDOW_MS, ) ) { return html( , ); } const { username, password } = body; const user = await db .selectFrom("users") .selectAll() .where("username", "=", username) .executeTakeFirst(); if (!user || !user.password_hash) { return html(); } const valid = await argon2.verify(user.password_hash, password); if (!valid) { return html(); } if (user.is_pending) { return html( , ); } const sessionId = await createSession(user.id); return redirect("/", sessionCookie(sessionId)); }, { body: t.Object({ username: t.String(), password: t.String() }), }, ) .get("/register", () => { if (config.REGISTRATION_TYPE === "disabled") return new Response("Registration is disabled", { status: 403 }); return html(); }) .post( "/register", async ({ body, request, server }) => { if (config.REGISTRATION_TYPE === "disabled") return new Response("Registration is disabled", { status: 403, }); const ip = getClientIp(request, server); if ( !checkRateLimit( ip, "register", REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS, ) ) { return html( , ); } const { username, password, password2, application } = body; if (!VALID_USERNAME_RE.test(username)) { return html( , ); } if (username === ADMIN_USERNAME) { return html( , ); } if (!password?.trim()) { return html( , ); } if (password !== password2) { return html( , ); } if (password.length < MIN_PASSWORD_LENGTH) { return html( , ); } const hash = await argon2.hash(password); const now = new Date().toISOString(); const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0; let result: { id: number }; try { result = await db .insertInto("users") .values({ username, password_hash: hash, created_at: now, is_pending: isPending, register_application: application ?? null, }) .returning("id") .executeTakeFirstOrThrow(); } catch (err) { if ( err instanceof Error && err.message.includes( "UNIQUE constraint failed: users.username", ) ) { return html( , ); } throw err; } if (config.REGISTRATION_TYPE === "queue") { return html( , ); } const sessionId = await createSession(result.id); return redirect("/", sessionCookie(sessionId)); }, { body: t.Object({ username: t.String({ maxLength: config.MAX_USERNAME_BYTES }), password: t.Optional( t.String({ maxLength: config.MAX_PASSWORD_BYTES }), ), password2: t.Optional( t.String({ maxLength: config.MAX_PASSWORD_BYTES }), ), application: t.Optional( t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), ), }), }, ) .post("/logout", async ({ cookie }) => { const sessionId = cookie.session.value; if (sessionId) { await db .deleteFrom("sessions") .where("id", "=", sessionId) .execute(); } return redirect("/", clearCookie()); }) // Create account (passkey-only path, called before passkey registration) .post( "/auth/passkey/create-user", async ({ body, request, server }) => { if (config.REGISTRATION_TYPE === "disabled") { return new Response( JSON.stringify({ error: "Registration is disabled" }), { status: 400 }, ); } // Shares the "register" bucket with the password path so this // endpoint can't be used to sidestep that limiter (both create a // real users row). const ip = getClientIp(request, server); if ( !checkRateLimit( ip, "register", REGISTRATION_MAX_ATTEMPTS, REGISTRATION_RATE_WINDOW_MS, ) ) { return new Response( JSON.stringify({ error: "Too many registration attempts. Please try again later.", }), { status: 429, headers: { "Content-Type": "application/json" }, }, ); } const { username, application } = body; if (!username || !VALID_USERNAME_RE.test(username)) { return new Response( JSON.stringify({ error: "Invalid username" }), { status: 400, }, ); } if (username === ADMIN_USERNAME) { return new Response( JSON.stringify({ error: "That username is reserved" }), { status: 400 }, ); } const now = new Date().toISOString(); const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0; let result: { id: number }; try { result = await db .insertInto("users") .values({ username, password_hash: null, created_at: now, is_pending: isPending, register_application: application ?? null, }) .returning("id") .executeTakeFirstOrThrow(); } catch (err) { if ( err instanceof Error && err.message.includes( "UNIQUE constraint failed: users.username", ) ) { return new Response( JSON.stringify({ error: "Username already taken" }), { status: 400 }, ); } throw err; } if (config.REGISTRATION_TYPE === "queue") { return new Response( JSON.stringify({ ok: true, pending: true }), { headers: { "Content-Type": "application/json" } }, ); } const sessionId = await createSession(result.id); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie(sessionId), }, }); }, { body: t.Object({ username: t.String({ maxLength: config.MAX_USERNAME_BYTES }), application: t.Optional( t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), ), }), }, ) // Passkey registration .post("/auth/passkey/register/options", async ({ cookie, request }) => { const user = await resolveSession(cookie.session.value); if (!user) return new Response( JSON.stringify({ error: "Not authenticated" }), { status: 401, }, ); const { rpId } = rpFromRequest(request); const passkeys = await db .selectFrom("passkeys") .select(["credential_id"]) .where("user_id", "=", user.id) .execute(); const options = await generateRegistrationOptions({ rpName: WEBAUTHN_RP_NAME, rpID: rpId, userID: Buffer.from(String(user.id)), userName: user.username, attestationType: "none", excludeCredentials: passkeys.map((p) => ({ id: p.credential_id, type: "public-key" as const, })), }); setChallenge(`${user.username}:reg`, options.challenge); return new Response(JSON.stringify(options), { headers: { "Content-Type": "application/json" }, }); }) .post( "/auth/passkey/register/verify", async ({ body, cookie, request }) => { const user = await resolveSession(cookie.session.value); if (!user) return new Response( JSON.stringify({ error: "Not authenticated" }), { status: 401, }, ); const challenge = pendingChallenges.get( `${user.username}:reg`, )?.challenge; if (!challenge) return new Response(JSON.stringify({ error: "No challenge" }), { status: 400, }); const { origin, rpId } = rpFromRequest(request); try { const verification = await verifyRegistrationResponse({ response: body as Parameters< typeof verifyRegistrationResponse >[0]["response"], expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpId, }); if (!verification.verified || !verification.registrationInfo) { return new Response( JSON.stringify({ error: "Verification failed" }), { status: 400 }, ); } const { credential } = verification.registrationInfo; const now = new Date().toISOString(); await db .insertInto("passkeys") .values({ user_id: user.id, credential_id: credential.id, public_key: Buffer.from( new Uint8Array(credential.publicKey), ).toString("base64"), counter: credential.counter, created_at: now, }) .execute(); deleteChallenge(`${user.username}:reg`); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, }); } catch (e) { return new Response( JSON.stringify({ error: e instanceof Error ? e.message : "Verification failed", }), { status: 400 }, ); } }, { body: t.Any(), }, ) // Passkey login .post("/auth/passkey/login/options", async ({ request }) => { const { rpId } = rpFromRequest(request); const options = await generateAuthenticationOptions({ rpID: rpId, }); setChallenge(`login:${options.challenge}`, options.challenge); return new Response(JSON.stringify(options), { headers: { "Content-Type": "application/json" }, }); }) .post( "/auth/passkey/login/verify", async ({ body, request }) => { const reqBody = body as { id?: string }; const credentialId = reqBody?.id; if (!credentialId) { return new Response( JSON.stringify({ error: "Missing credential" }), { status: 400, }, ); } const passkey = await db .selectFrom("passkeys") .innerJoin("users", "users.id", "passkeys.user_id") .selectAll("passkeys") .select("users.username") .where("passkeys.credential_id", "=", credentialId) .executeTakeFirst(); if (!passkey) { return new Response( JSON.stringify({ error: "Unknown credential" }), { status: 400, }, ); } // Look up the challenge by decoding it from the signed clientDataJSON. // This ensures each verify request finds its own challenge rather than // an arbitrary "first login:" entry, which would fail under concurrency. const reqBodyTyped = body as { response?: { clientDataJSON?: string }; }; const clientDataJSON = reqBodyTyped?.response?.clientDataJSON; let challengeKey: string | undefined; let challenge: string | undefined; if (clientDataJSON) { try { const cd = JSON.parse( Buffer.from(clientDataJSON, "base64url").toString(), ) as { challenge?: string }; if (cd.challenge) { challengeKey = `login:${cd.challenge}`; challenge = pendingChallenges.get(challengeKey)?.challenge; } } catch { // malformed clientDataJSON — handled by the check below } } if (!challenge) { return new Response(JSON.stringify({ error: "No challenge" }), { status: 400, }); } const { origin, rpId } = rpFromRequest(request); try { const verification = await verifyAuthenticationResponse({ response: body as Parameters< typeof verifyAuthenticationResponse >[0]["response"], expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpId, credential: { id: passkey.credential_id, publicKey: Buffer.from(passkey.public_key, "base64"), counter: passkey.counter, }, }); if (!verification.verified) { return new Response( JSON.stringify({ error: "Verification failed" }), { status: 401 }, ); } // Atomically advance the counter using the expected old value as // a guard. If another concurrent request already updated it, 0 // rows are affected and we reject — this preserves WebAuthn's // monotonic-counter replay-attack protection. const updated = await db .updateTable("passkeys") .set({ counter: verification.authenticationInfo.newCounter, }) .where("id", "=", passkey.id) .where("counter", "=", passkey.counter) .executeTakeFirst(); if (!updated || updated.numUpdatedRows === 0n) { return new Response( JSON.stringify({ error: "Credential replay detected" }), { status: 401 }, ); } deleteChallenge(challengeKey!); const sessionId = await createSession(passkey.user_id); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie(sessionId), }, }); } catch (e) { return new Response( JSON.stringify({ error: e instanceof Error ? e.message : "Verification failed", }), { status: 400 }, ); } }, { body: t.Any(), }, );