import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server" import { isoBase64URL } from "@simplewebauthn/server/helpers" import type { RegistrationResponseJSON, AuthenticationResponseJSON, } from "@simplewebauthn/server" import { createUser, getUserByUsername, getUserById, storeCredential, getCredentialById, updateCredentialCounter, createSession, getSession, deleteSession, } from "./db" const RP_NAME = "Leo's Typing Tutor" // Temporary challenge store (in-memory, keyed by random ID → challenge + metadata) const challenges = new Map() // Cleanup expired challenges periodically setInterval(() => { const now = Date.now() for (const [key, val] of challenges) { if (val.expires < now) challenges.delete(key) } }, 60_000) function getRpId(req: Request): string { const url = new URL(req.url) return url.hostname } function getOrigin(req: Request): string { const url = new URL(req.url) return url.origin } function getCookie(req: Request, name: string): string | undefined { const cookies = req.headers.get("cookie") if (!cookies) return undefined const match = cookies.split(";").find(c => c.trim().startsWith(`${name}=`)) return match?.split("=").slice(1).join("=").trim() } function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string { return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}` } function challengeCookie(key: string): string { return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120` } function clearChallengeCookie(): string { return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0` } // ---- Route handlers ---- export async function registerOptions(req: Request): Promise { const { username } = await req.json() as { username: string } if (!username || username.length < 1 || username.length > 32) { return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 }) } // Check if username already taken const existing = await getUserByUsername(username) if (existing) { return Response.json({ error: "Username already taken" }, { status: 409 }) } // Create user const user = await createUser(username) const rpID = getRpId(req) const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID, userName: username, userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())), attestationType: "none", authenticatorSelection: { residentKey: "required", userVerification: "preferred", }, }) // Store challenge const challengeKey = crypto.randomUUID() challenges.set(challengeKey, { challenge: options.challenge, userId: user.id, expires: Date.now() + 120_000, }) return new Response(JSON.stringify(options), { headers: { "Content-Type": "application/json", "Set-Cookie": challengeCookie(challengeKey), }, }) } export async function registerVerify(req: Request): Promise { const body = (await req.json()) as RegistrationResponseJSON const challengeKey = getCookie(req, "challenge_key") if (!challengeKey) { return Response.json({ error: "No challenge found" }, { status: 400 }) } const stored = challenges.get(challengeKey) if (!stored || stored.expires < Date.now()) { challenges.delete(challengeKey!) return Response.json({ error: "Challenge expired" }, { status: 400 }) } challenges.delete(challengeKey) const rpID = getRpId(req) const origin = getOrigin(req) let verification try { verification = await verifyRegistrationResponse({ response: body, expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, }) } catch (err) { return Response.json({ error: `Verification failed: ${err}` }, { status: 400 }) } if (!verification.verified || !verification.registrationInfo) { return Response.json({ error: "Verification failed" }, { status: 400 }) } const { credential } = verification.registrationInfo await storeCredential( credential.id, stored.userId!, isoBase64URL.fromBuffer(credential.publicKey), credential.counter, credential.transports, ) // Create session const token = await createSession(stored.userId!) const user = await getUserById(stored.userId!) return new Response(JSON.stringify({ verified: true, username: user?.username }), { headers: { "Content-Type": "application/json", "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), }, }) } export async function loginOptions(req: Request): Promise { const rpID = getRpId(req) const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", // Empty allowCredentials = discoverable credentials (passkey prompt) }) const challengeKey = crypto.randomUUID() challenges.set(challengeKey, { challenge: options.challenge, expires: Date.now() + 120_000, }) return new Response(JSON.stringify(options), { headers: { "Content-Type": "application/json", "Set-Cookie": challengeCookie(challengeKey), }, }) } export async function loginVerify(req: Request): Promise { const body = (await req.json()) as AuthenticationResponseJSON const challengeKey = getCookie(req, "challenge_key") if (!challengeKey) { return Response.json({ error: "No challenge found" }, { status: 400 }) } const stored = challenges.get(challengeKey) if (!stored || stored.expires < Date.now()) { challenges.delete(challengeKey!) return Response.json({ error: "Challenge expired" }, { status: 400 }) } challenges.delete(challengeKey) // Look up credential const credentialId = body.id const credential = await getCredentialById(credentialId) if (!credential) { return Response.json({ error: "Unknown credential" }, { status: 400 }) } const rpID = getRpId(req) const origin = getOrigin(req) let verification try { verification = await verifyAuthenticationResponse({ response: body, expectedChallenge: stored.challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: credential.id, publicKey: isoBase64URL.toBuffer(credential.public_key), counter: credential.counter, transports: credential.transports ? JSON.parse(credential.transports) : undefined, }, }) } catch (err) { return Response.json({ error: `Verification failed: ${err}` }, { status: 400 }) } if (!verification.verified) { return Response.json({ error: "Verification failed" }, { status: 400 }) } // Update counter await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter) // Create session const token = await createSession(credential.user_id) const user = await getUserById(credential.user_id) return new Response(JSON.stringify({ verified: true, username: user?.username }), { headers: { "Content-Type": "application/json", "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), }, }) } export async function me(req: Request): Promise { const token = getCookie(req, "session") if (!token) { return Response.json({ user: null }) } const session = await getSession(token) if (!session) { return new Response(JSON.stringify({ user: null }), { headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie("", 0), // clear expired cookie }, }) } return Response.json({ user: { id: session.user_id, username: session.username } }) } export async function logout(req: Request): Promise { const token = getCookie(req, "session") if (token) { await deleteSession(token) } return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie("", 0), }, }) }