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 = "shukudai"; // Temporary challenge store (in-memory, keyed by random ID → challenge + metadata) const challenges = new Map< string, { challenge: string; userId?: number; expires: number } >(); // 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), }, }); }