import { Hono } from "jsr:@hono/hono@4"; import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; import { generateRegistrationOptions, verifyRegistrationResponse, } from "jsr:@simplewebauthn/server@13"; import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13"; import { valkey } from "../../core/valkey.ts"; import { getClientIp, publicRateLimiter } from "../../core/middleware.ts"; import { auditWrapper } from "../../core/audit.ts"; import { createPasskey, createSession, createUser, getInviteToken, getUserByUsername, markInviteTokenUsed, } from "./queries.ts"; import { RegisterPageFragment } from "./register_fragments.tsx"; export const registerRoutes = 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 getCookieDomain(customRpId?: string): string | undefined { const envDomain = Deno.env.get("COOKIE_DOMAIN"); if (envDomain) { return envDomain.startsWith(".") ? envDomain : `.${envDomain}`; } const targetId = customRpId || Deno.env.get("RP_ID") || ""; if (!targetId || !targetId.includes(".") || targetId === "localhost") { return undefined; } const parts = targetId.split(".").filter(Boolean); if (parts.length >= 2) { return `.${parts.slice(-2).join(".")}`; } return `.${targetId}`; } // UI Route registerRoutes.get("/register", (c) => { const code = c.req.query("code") || ""; return c.html(RegisterPageFragment({ initialCode: code })); }); // API Routes registerRoutes.use("/api/register/*", publicRateLimiter); // Register Challenge registerRoutes.post("/api/register/challenge", async (c) => { let body; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid payload" }, 400); } const { username, inviteCode } = body; if (!username || !inviteCode) { return c.json({ error: "Username and invite code are required" }, 400); } if (!rpID) throw new Error("rpID missing"); const userIdBytes = new Uint8Array(16); crypto.getRandomValues(userIdBytes); const newUserId = crypto.randomUUID(); const options = await generateRegistrationOptions({ rpName: "Auth-Yes Identity", rpID, userName: username, userID: userIdBytes, attestationType: "direct", authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "preferred", }, timeout: 60000, extensions: { ["prf" as string]: {}, } as any, }); setCookie(c, "expected_registration_challenge", options.challenge, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); setCookie(c, "registration_user_id", newUserId, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); return c.json({ options, username }); }); // Verify registration and create UUID/session registerRoutes.post("/api/register/verify", async (c) => { try { const { response, username, inviteCode, upgrade_session } = await c.req .json(); if (!inviteCode && !upgrade_session) { return c.json({ error: "inviteCode or upgrade_session required" }, 400); } const expectedChallenge = getCookie(c, "expected_registration_challenge"); const registrationUserId = getCookie(c, "registration_user_id"); if (!expectedChallenge || !registrationUserId) { return c.json({ error: "Missing or expired registration challenge/user ID", }, 400); } let user = await getUserByUsername(username); if (user) { return c.json({ error: "Username already exists" }, 409); } if (!origin || !rpID) throw new Error("Missing origin or rpID"); let verification; try { verification = await verifyRegistrationResponse({ response: response as RegistrationResponseJSON, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, }); } catch (error: any) { return c.json({ error: error.message }, 400); } const { verified, registrationInfo } = verification; if (!verified || !registrationInfo) { return c.json({ error: "Verification failed" }, 400); } const credentialID = registrationInfo.credential.id; const credentialPublicKey = registrationInfo.credential.publicKey; const counter = registrationInfo.credential.counter; const base64CredentialID = typeof credentialID === "string" ? credentialID : encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer)); const base64PublicKey = encodeBase64Url( new Uint8Array(credentialPublicKey as unknown as ArrayBuffer), ); const prfEnabled = (response.clientExtensionResults as any)?.prf?.enabled === true; let prfSalt = null; if (prfEnabled) { const saltBytes = crypto.getRandomValues(new Uint8Array(32)); prfSalt = encodeBase64Url(saltBytes); } if (upgrade_session) { const sessionDataStr = await valkey.get(upgrade_session); if (!sessionDataStr) { return c.json({ error: "Invalid or expired guest session" }, 400); } const sessionData = JSON.parse(sessionDataStr); if ( !sessionData || !sessionData.uuid || sessionData.account_status !== "guest" ) { return c.json({ error: "Invalid guest session state" }, 400); } const guestUuid = sessionData.uuid; user = await createUser(guestUuid, username); await createPasskey( user.id, base64CredentialID, base64PublicKey, counter, registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000", "Unknown Device", prfEnabled, prfSalt, ); await valkey.setex( upgrade_session, 28800, JSON.stringify({ uuid: guestUuid, username, account_status: "active" }), ); const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000); await createSession(upgrade_session, user.id, expiresAt); } else { const invite = await getInviteToken(inviteCode); if (!invite) { return c.json( { error: "Invalid, expired, or fully claimed invite code" }, 400, ); } user = await createUser(registrationUserId, username); await createPasskey( user.id, base64CredentialID, base64PublicKey, counter, registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000", "Unknown Device", prfEnabled, prfSalt, ); await markInviteTokenUsed(invite.id, user.id); } auditWrapper.auditLog( user.id, "user_registered", null, { username, inviteCode }, getClientIp(c), ); setCookie(c, "expected_registration_challenge", "", { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 0, }); setCookie(c, "registration_user_id", "", { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 0, }); const cookieDomain = getCookieDomain(rpID); if (cookieDomain) { setCookie(c, "session_id", "", { domain: cookieDomain, path: "/", maxAge: 0, }); } setCookie(c, "session_id", "", { path: "/", maxAge: 0 }); return c.json({ success: true }); } catch (error: any) { console.error( "[Auth API] Uncaught Exception in /api/register/verify:", error, ); return c.json({ error: error.message || "Internal server error" }, 500); } });