import { Hono } from "jsr:@hono/hono@4"; import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; import { generateRegistrationOptions, MetadataService, verifyRegistrationResponse, } from "jsr:@simplewebauthn/server@13"; import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13"; import { sqlWrapper } from "../../db.ts"; import { valkey } from "../../valkey.ts"; import { auditWrapper } from "../../audit.ts"; import { getClientIp, publicRateLimiter } from "../../middleware.ts"; import { getCookieDomain } from "./utils.ts"; export const registerAuthRoutes = new Hono(); const rpName = "Auth-Yes Identity Provider"; const rpID = Deno.env.get("RP_ID") || (import.meta.main ? undefined : "localhost"); const origin = Deno.env.get("ORIGIN") || (import.meta.main ? undefined : "http://localhost"); const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true"; registerAuthRoutes.use("/api/register/*", publicRateLimiter); registerAuthRoutes.get("/.well-known/webauthn", (c) => { if (!origin) { return c.json({ origins: [] }); } return c.json({ origins: [origin] }); }); // Start a WebAuthn registration ceremony registerAuthRoutes.post("/api/register/challenge", async (c) => { const { username, inviteCode } = await c.req.json(); if (!username || !inviteCode) { return c.json({ error: "Username and inviteCode required" }, 400); } // Validate invite code early (checks expiration and max_uses bounds) const invite = await sqlWrapper .sql`SELECT id, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` .then((res: any) => res[0]); if (!invite) { return c.json( { error: "Invalid, expired, or fully claimed invite code" }, 400, ); } // Prevent hijacking an existing user's account if they already exist const existingUser = await sqlWrapper .sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => res[0] ); if (existingUser) { return c.json({ error: "Username already exists" }, 409); } const newUserId = crypto.randomUUID(); const userIdBytes = new TextEncoder().encode(newUserId); if (!rpID) throw new Error("rpID is missing"); const options = await generateRegistrationOptions({ rpName, 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 registerAuthRoutes.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); } // Prevent race condition account hijacking let user = await sqlWrapper .sql`SELECT id FROM users WHERE username = ${username}` .then( (res: any) => res[0], ); 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); } // Enterprise Allow-List Verification const allowlistCount = await sqlWrapper .sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) => Number(res[0].count) ); if (allowlistCount > 0 && registrationInfo.aaguid) { const isAllowed = await sqlWrapper .sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}` .then((res: any) => res[0]); if (!isAllowed) { auditWrapper.auditLog(null, "failed_attestation_allowlist", null, { aaguid: registrationInfo.aaguid, }, getClientIp(c)); return c.json({ error: "Authenticator AAGUID is not in the enterprise allow-list.", }, 403); } } // Optional Strict Hardware Attestation (e.g. YubiKey-only) if (requireHardwareToken) { if ( !registrationInfo.aaguid || registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000" ) { auditWrapper.auditLog(null, "registration_failed_attestation", null, { username, reason: "No AAGUID provided", }, getClientIp(c)); return c.json({ error: "Hardware attestation failed: No AAGUID provided. Only certified hardware security keys are permitted.", }, 403); } let mdsStatement; try { mdsStatement = await MetadataService.getStatement( registrationInfo.aaguid, ); } catch (mdsError) { console.warn("[Auth API] MetadataService lookup error:", mdsError); } if (!mdsStatement) { auditWrapper.auditLog(null, "registration_failed_attestation", null, { username, aaguid: registrationInfo.aaguid, reason: "AAGUID not found in MDS3", }, getClientIp(c)); return c.json({ error: `Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob. Only certified hardware security keys are permitted.`, }, 403); } // @ts-ignore: TypeScript definition might be out of date for FIDO MDS3 (1) if (mdsStatement.keyProtection?.includes(0x0001)) { auditWrapper.auditLog(null, "registration_failed_attestation", null, { username, aaguid: registrationInfo.aaguid, reason: "Software passkey detected", }, getClientIp(c)); return c.json({ error: "Hardware attestation failed: Authenticator is flagged as a software-based passkey. Only certified hardware security keys are permitted.", }, 403); } } 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) { // Ephemeral Guest Sandbox in-flight promotion 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; const insertRes = await sqlWrapper .sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`; user = insertRes[0]; await sqlWrapper.sql` INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) `; // Promote Valkey session await valkey.setex( upgrade_session, 28800, // Upgrade TTL to 8 hours JSON.stringify({ uuid: guestUuid, username, account_status: "active" }), ); // Register session in PostgreSQL const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000); await sqlWrapper.sql` INSERT INTO sessions (id, user_id, expires_at) VALUES (${upgrade_session}, ${user.id}, ${expiresAt}) `; } else { // Standard Registration Flow const invite = await sqlWrapper .sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` .then((res: any) => res[0]); if (!invite) { return c.json( { error: "Invalid, expired, or fully claimed invite code" }, 400, ); } const initialStatus = invite.auto_activate === false ? "pending" : "active"; const insertRes = await sqlWrapper .sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`; user = insertRes[0]; await sqlWrapper.sql` INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) `; await sqlWrapper.sql` UPDATE invites SET uses_count = uses_count + 1, used_at = NOW(), used_by = ${user.id} WHERE id = ${invite.id} `; await sqlWrapper.sql` INSERT INTO invite_redemptions (invite_id, user_id) VALUES (${invite.id}, ${user.id}) `; if (invite.app_id) { await sqlWrapper.sql` INSERT INTO grants (user_id, app_id, role) VALUES (${user.id}, ${invite.app_id}, ${invite.role}) `; } else if (invite.role === "admin") { const adminApp = await sqlWrapper .sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'` .then((res: any) => res[0]); if (adminApp) { await sqlWrapper.sql` INSERT INTO grants (user_id, app_id, role) VALUES (${user.id}, ${adminApp.id}, 'admin') ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin' `; } } } auditWrapper.auditLog( user.id, "user_registered", null, { username, inviteCode }, getClientIp(c), ); // Set response cookie to clear out the challenge 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) { deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" }); } deleteCookie(c, "session_id", { path: "/" }); 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); } });