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, MetadataService, verifyRegistrationResponse, } from "jsr:@simplewebauthn/server@13"; import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13"; import { sqlWrapper } from "../../db.ts"; import { auditWrapper } from "../../audit.ts"; import { getAuthenticatedUser, requirePrimarySession, } from "../../auth-session.ts"; import { getClientIp } from "../../middleware.ts"; export const passkeysAuthRoutes = 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"; passkeysAuthRoutes.use("/api/passkeys/*", requirePrimarySession); // --------------------------------------------------------- // Authenticated Passkey Registration (Adding a new device) // --------------------------------------------------------- passkeysAuthRoutes.post("/api/passkeys/register/challenge", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); const user = await sqlWrapper .sql`SELECT username FROM users WHERE id = ${auth.userId}` .then((res: any) => res[0]); if (!user) return c.json({ error: "User not found" }, 404); const userIdBytes = new TextEncoder().encode(auth.userId); if (!rpID) throw new Error("rpID is missing"); const options = await generateRegistrationOptions({ rpName, rpID, userName: user.username, userID: userIdBytes, attestationType: "direct", authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "preferred", }, timeout: 60000, }); setCookie(c, "expected_add_passkey_challenge", options.challenge, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); return c.json({ options }); }); passkeysAuthRoutes.post("/api/passkeys/register/verify", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); const { response } = await c.req.json(); const expectedChallenge = getCookie(c, "expected_add_passkey_challenge"); if (!expectedChallenge) { return c.json({ error: "Missing or expired registration challenge" }, 400); } 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(auth.userId, "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 if (requireHardwareToken) { if ( !registrationInfo.aaguid || registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000" ) { auditWrapper.auditLog( auth.userId, "add_passkey_failed_attestation", null, { reason: "No AAGUID provided", }, getClientIp(c), ); return c.json( { error: "Hardware attestation failed: No AAGUID provided." }, 403, ); } let mdsStatement; try { mdsStatement = await MetadataService.getStatement( registrationInfo.aaguid, ); } catch (mdsError) { console.warn("[Auth API] MetadataService lookup error:", mdsError); } if (!mdsStatement) { auditWrapper.auditLog( auth.userId, "add_passkey_failed_attestation", null, { 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.`, }, 403); } // @ts-ignore: FIDO MDS3 missing type if (mdsStatement.keyProtection?.includes(0x0001)) { auditWrapper.auditLog( auth.userId, "add_passkey_failed_attestation", null, { aaguid: registrationInfo.aaguid, reason: "Software passkey detected", }, getClientIp(c), ); return c.json({ error: "Hardware attestation failed: Authenticator is flagged as a software-based passkey.", }, 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), ); await sqlWrapper.sql` INSERT INTO passkeys (user_id, credential_id, public_key, counter) VALUES (${auth.userId}, ${base64CredentialID}, ${base64PublicKey}, ${counter}) `; auditWrapper.auditLog( auth.userId, "passkey_added", null, null, getClientIp(c), ); setCookie(c, "expected_add_passkey_challenge", "", { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 0, }); return c.json({ success: true }); }); // Get user's registered passkeys passkeysAuthRoutes.get("/api/passkeys", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); const passkeys = await sqlWrapper.sql` SELECT id, counter FROM passkeys WHERE user_id = ${auth.userId} `; return c.json({ passkeys }); }); // Revoke a specific passkey passkeysAuthRoutes.delete("/api/passkeys/:id", async (c) => { const auth = await getAuthenticatedUser(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); const targetPasskeyId = c.req.param("id"); // Verify the passkey belongs to the user const passkey = await sqlWrapper.sql` SELECT id FROM passkeys WHERE id = ${targetPasskeyId} AND user_id = ${auth.userId} `.then((res: any) => res[0]); if (!passkey) { return c.json({ error: "Passkey not found or access denied" }, 404); } // Prevent deleting the very last passkey to avoid locking out the user const passkeyCount = await sqlWrapper.sql` SELECT count(*) as count FROM passkeys WHERE user_id = ${auth.userId} `.then((res: any) => Number(res[0].count)); if (passkeyCount <= 1) { return c.json({ error: "Cannot delete your last passkey. Register another one first.", }, 400); } await sqlWrapper.sql`DELETE FROM passkeys WHERE id = ${targetPasskeyId}`; auditWrapper.auditLog(auth.userId, "passkey_revoked", null, { revoked_passkey_id: targetPasskeyId, }, getClientIp(c)); return c.json({ success: true }); });