import { Hono } from "jsr:@hono/hono@4"; import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { decodeBase64Url, encodeBase64Url, } from "jsr:@std/encoding@1/base64url"; import { generateAuthenticationOptions, generateRegistrationOptions, MetadataService, verifyAuthenticationResponse, verifyRegistrationResponse, } from "jsr:@simplewebauthn/server@13"; import type { AuthenticationResponseJSON, RegistrationResponseJSON, } from "jsr:@simplewebauthn/server@13"; import { sqlWrapper } from "../db.ts"; import { valkey } from "../valkey.ts"; import { auditWrapper } from "../audit.ts"; import { extractAllSessionIds, getAuthenticatedUser, requirePrimarySession, } from "../auth-session.ts"; import { getClientIp, publicRateLimiter } from "../middleware.ts"; export const authRoutes = 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"; export 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}`; } function generateSessionId() { return crypto.randomUUID(); } authRoutes.use("/api/register/*", publicRateLimiter); authRoutes.use("/api/login/*", publicRateLimiter); authRoutes.use("/api/passkeys/*", requirePrimarySession); authRoutes.get("/.well-known/webauthn", (c) => { if (!origin) { return c.json({ origins: [] }); } return c.json({ origins: [origin] }); }); // Start a WebAuthn registration ceremony authRoutes.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 authRoutes.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); } }); // Start a WebAuthn authentication ceremony authRoutes.post("/api/login/challenge", async (c) => { let body; try { body = await c.req.json(); } catch (_err) { body = {}; } const username = body.username; let extensions: any = undefined; let allowCredentials: any[] | undefined = undefined; if (username) { const user = await sqlWrapper .sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => res[0] ); if (user) { const passkeys = await sqlWrapper .sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`; if (passkeys.length > 0) { allowCredentials = passkeys.map((pk: any) => ({ id: pk.credential_id, type: "public-key", })); const prfPasskeys = passkeys.filter((pk: any) => pk.prf_enabled && pk.prf_salt ); if (prfPasskeys.length > 0) { extensions = { ["prf" as string]: { evalByCredential: {} }, }; for (const pk of prfPasskeys) { const saltBytes = decodeBase64Url(pk.prf_salt); extensions["prf"]["evalByCredential"][pk.credential_id] = { first: saltBytes, }; } } } } } if (!rpID) throw new Error("rpID is missing"); const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred", timeout: 60000, allowCredentials, extensions, }); setCookie(c, "expected_authentication_challenge", options.challenge, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); return c.json({ options }); }); // Verify login and issue session authRoutes.post("/api/login/verify", async (c) => { const { response } = await c.req.json(); const expectedChallenge = getCookie(c, "expected_authentication_challenge"); if (!expectedChallenge) { return c.json( { error: "Missing or expired authentication challenge" }, 400, ); } const base64CredentialID = response.id; const passkey = await sqlWrapper .sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}` .then((res: any) => res[0]); if (!passkey) { return c.json({ error: "Passkey not found. Please register your passkey first.", }, 404); } const user = await sqlWrapper .sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}` .then((res: any) => res[0]); if (!user) { return c.json({ error: "User not found" }, 404); } const userId = user.id; if (user.account_status !== "active") { auditWrapper.auditLog( userId, "login_failed", null, { reason: `Account status is ${user.account_status}` }, getClientIp(c), ); return c.json({ error: "Account is not active. Please contact an administrator.", }, 403); } const publicKeyBytes = decodeBase64Url(passkey.public_key); if (!origin || !rpID) throw new Error("Missing origin or rpID"); let verification; try { verification = await verifyAuthenticationResponse({ response: response as AuthenticationResponseJSON, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, credential: { id: passkey.credential_id, publicKey: publicKeyBytes, counter: Number(passkey.counter), }, }); } catch (error: any) { return c.json({ error: error.message }, 400); } const { verified, authenticationInfo } = verification; if (!verified || !authenticationInfo) { auditWrapper.auditLog( userId, "login_failed", null, { reason: "verification failed" }, getClientIp(c), ); return c.json({ error: "Verification failed" }, 400); } await sqlWrapper .sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`; const sessionId = generateSessionId(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); // Persistence in PostgreSQL await sqlWrapper .sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`; // Write session to Valkey with TTL matching expiresAt const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000); try { const sessionData = JSON.stringify({ uuid: user.id, username: user.username, }); await valkey.setex(sessionId, ttlSeconds, sessionData); } catch (_err: unknown) { // If Valkey fails, log and fail closed for security auditWrapper.auditLog( user.id, "login_failed", null, { reason: "Cache write failure" }, getClientIp(c), ); return c.json({ error: "Internal server error" }, 500); } const oldSessionIds = extractAllSessionIds(c); if (oldSessionIds.length > 0) { for (const old of oldSessionIds) { try { await valkey.del(old); await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`; } catch (_e) {} } } const cookieDomain = getCookieDomain(rpID); setCookie(c, "session_id", sessionId, { domain: cookieDomain, path: "/", httpOnly: true, secure: true, sameSite: "Lax", expires: expiresAt, }); setCookie(c, "expected_authentication_challenge", "", { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 0, }); auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c)); return c.json({ success: true }); }); // Generate Ephemeral Guest Sandbox authRoutes.post("/api/guests/sandbox", async (c) => { const guestUuid = crypto.randomUUID(); const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))); const username = `guest-${guestUuid.substring(0, 8)}`; await valkey.setex( sessionId, 7200, // 2-hour TTL JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }), ); const cookieDomain = getCookieDomain(rpID); setCookie(c, "session_id", sessionId, { domain: cookieDomain, path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: 7200, }); return c.json({ success: true, sessionId, guestUuid }); }); // Revoke a session manually (used by layout logout) authRoutes.post("/api/revoke", async (c) => { // Try Authorization header first (SDK) let token = ""; const authHeader = c.req.header("Authorization"); if (authHeader && authHeader.startsWith("Bearer ")) { token = authHeader.split(" ")[1]; } if (!token) { // Fallback to session cookie (Web UI) const tokens = extractAllSessionIds(c); if (tokens.length === 0) { return c.json({ error: "Missing or invalid token" }, 401); } for (const t of tokens) { try { await valkey.del(t); await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`; } catch (_e) {} } } else { // SDK Token Path try { await valkey.del(token); await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`; } catch (_e) {} } const cookieDomain = getCookieDomain(rpID); if (cookieDomain) { deleteCookie(c, "session_id", { domain: cookieDomain, path: "/", httpOnly: true, secure: true, sameSite: "Lax", }); } deleteCookie(c, "session_id", { path: "/", httpOnly: true, secure: true, sameSite: "Lax", }); return c.json({ success: true }); }); // --------------------------------------------------------- // Authenticated Passkey Registration (Adding a new device) // --------------------------------------------------------- authRoutes.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 }); }); authRoutes.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 authRoutes.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 authRoutes.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 }); });