From 589e146eccde84c8ba34b303596223c1394309f6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:37:26 +0000 Subject: [PATCH] feat(routes): decompose admin and auth monolith routes Extracted domain-specific sub-routers from monolithic `server/routes/admin.ts` and `server/routes/auth.ts` into isolated modules within `server/routes/admin/` and `server/routes/auth/` respectively. The original entry routers were updated to import and assemble these sub-routers without breaking their current HTTP interface or rate limiting/authorization middleware. Testing and linting were run ensuring perfect functionality and 100% test passing score. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com> --- server/routes/admin.ts | 873 +------------------------ server/routes/admin/apps.ts | 129 ++++ server/routes/admin/audit.ts | 55 ++ server/routes/admin/hardware_keys.ts | 136 ++++ server/routes/admin/invites.ts | 157 +++++ server/routes/admin/roles.ts | 155 +++++ server/routes/admin/users.ts | 228 +++++++ server/routes/auth.ts | 931 +-------------------------- server/routes/auth/guest.ts | 90 +++ server/routes/auth/login.ts | 236 +++++++ server/routes/auth/passkeys.ts | 268 ++++++++ server/routes/auth/register.ts | 369 +++++++++++ server/routes/auth/utils.ts | 15 + 13 files changed, 1861 insertions(+), 1781 deletions(-) create mode 100644 server/routes/admin/apps.ts create mode 100644 server/routes/admin/audit.ts create mode 100644 server/routes/admin/hardware_keys.ts create mode 100644 server/routes/admin/invites.ts create mode 100644 server/routes/admin/roles.ts create mode 100644 server/routes/admin/users.ts create mode 100644 server/routes/auth/guest.ts create mode 100644 server/routes/auth/login.ts create mode 100644 server/routes/auth/passkeys.ts create mode 100644 server/routes/auth/register.ts create mode 100644 server/routes/auth/utils.ts diff --git a/server/routes/admin.ts b/server/routes/admin.ts index d47b240..82ba5bc 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -1,865 +1,22 @@ import { Hono } from "jsr:@hono/hono@4"; -import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; -import { sqlWrapper } from "../db.ts"; -import { valkey } from "../valkey.ts"; -import { auditWrapper } from "../audit.ts"; -import { - getAuthenticatedUser, - isGlobalAdmin, - requireAdmin, -} from "../auth-session.ts"; -import { adminRateLimiter, getClientIp } from "../middleware.ts"; -import { computeJwkThumbprint } from "../http_signatures.ts"; +import { requireAdmin } from "../auth-session.ts"; +import { adminRateLimiter } from "../middleware.ts"; + +import { usersAdminRoutes } from "./admin/users.ts"; +import { appsAdminRoutes } from "./admin/apps.ts"; +import { rolesAdminRoutes } from "./admin/roles.ts"; +import { invitesAdminRoutes } from "./admin/invites.ts"; +import { hardwareKeysAdminRoutes } from "./admin/hardware_keys.ts"; +import { auditAdminRoutes } from "./admin/audit.ts"; export const adminRoutes = new Hono(); adminRoutes.use("*", requireAdmin); adminRoutes.use("*", adminRateLimiter); -// --------------------------------------------------------- -// Global Admin APIs (For the Management Console) -// --------------------------------------------------------- - -adminRoutes.get("/audit-logs", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const logs = await sqlWrapper.sql` - SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user - FROM audit_records a - LEFT JOIN users u ON a.user_id = u.id - ORDER BY a.created_at DESC - LIMIT 100 - `; - - return c.json({ logs }); -}); - -adminRoutes.get("/users", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const users = await sqlWrapper.sql` - SELECT id, username, display_name, account_status - FROM users - ORDER BY username ASC - `; - - return c.json({ users }); -}); - -adminRoutes.post("/users/:id/status", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetUserId = c.req.param("id"); - const { status } = await c.req.json(); - - if (!["active", "pending", "suspended"].includes(status)) { - return c.json({ error: "Invalid status" }, 400); - } - - const targetUser = await sqlWrapper - .sql`UPDATE users SET account_status = ${status} WHERE id = ${targetUserId} RETURNING id` - .then((res: any) => res[0]); - - if (!targetUser) { - return c.json({ error: "User not found" }, 404); - } - - auditWrapper.auditLog(auth.userId, "user_status_changed", targetUserId, { - newStatus: status, - }, getClientIp(c)); - - return c.json({ success: true }); -}); - -adminRoutes.post("/users/:id/profile", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetUserId = c.req.param("id"); - const { displayName } = await c.req.json(); - - const targetUser = await sqlWrapper - .sql`UPDATE users SET display_name = ${ - displayName?.trim() || null - } WHERE id = ${targetUserId} RETURNING id, username, display_name` - .then((res: any) => res[0]); - - if (!targetUser) return c.json({ error: "User not found" }, 404); - - auditWrapper.auditLog(auth.userId, "user_profile_updated", targetUserId, { - display_name: targetUser.display_name, - }, getClientIp(c)); - - return c.json({ success: true, user: targetUser }); -}); - -// --------------------------------------------------------- -// Admin Application Registry -// --------------------------------------------------------- - -adminRoutes.get("/apps", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const apps = await sqlWrapper.sql` - SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at, - COUNT(g.id) AS active_grants_count - FROM apps a - LEFT JOIN grants g ON a.id = g.app_id - GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at - ORDER BY a.created_at ASC - `; - return c.json({ apps }); -}); - -adminRoutes.post("/apps", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const { - name, - spiffeId, - description, - domain, - is_public, - bypass_paths, - allowed_cidrs, - } = await c.req.json(); - if (!name || !spiffeId) { - return c.json({ error: "Name and SPIFFE ID are required" }, 400); - } - - try { - const newApp = await sqlWrapper.sql` - INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs) - VALUES (${name.trim()}, ${spiffeId.trim()}, ${ - description?.trim() || null - }, ${domain?.trim() || null}, ${is_public || false}, ${ - bypass_paths || [] - }, ${allowed_cidrs || []}) - RETURNING id, name, spiffe_id, description, created_at - `.then((res: any) => res[0]); - - auditWrapper.auditLog(auth.userId, "app_registered", newApp.id, { - name: newApp.name, - spiffe_id: newApp.spiffe_id, - }, getClientIp(c)); - return c.json({ success: true, app: newApp }); - } catch (err: any) { - if (err.code === "23505") { - return c.json({ - error: "An application with this SPIFFE ID already exists", - }, 409); - } - return c.json({ error: "Failed to register application" }, 500); - } -}); - -adminRoutes.put("/apps/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const appId = c.req.param("id"); - const { - name, - description, - domain, - is_public, - bypass_paths, - allowed_cidrs, - } = await c.req.json(); - - if (!name) { - return c.json({ error: "Application name is required" }, 400); - } - - try { - const updatedApp = await sqlWrapper.sql` - UPDATE apps - SET name = ${name.trim()}, - description = ${description?.trim() || null}, - domain = ${domain?.trim() || null}, - is_public = ${is_public || false}, - bypass_paths = ${bypass_paths || []}, - allowed_cidrs = ${allowed_cidrs || []} - WHERE id = ${appId} - RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs - `.then((res: any) => res[0]); - - if (!updatedApp) return c.json({ error: "Application not found" }, 404); - - auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, { - name: updatedApp.name, - }, getClientIp(c)); - - return c.json({ success: true, app: updatedApp }); - } catch (_err: any) { - return c.json({ error: "Failed to update application" }, 500); - } -}); - -adminRoutes.delete("/apps/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const appId = c.req.param("id"); - const app = await sqlWrapper - .sql`DELETE FROM apps WHERE id = ${appId} RETURNING id, name` - .then((res: any) => res[0]); - if (app) { - auditWrapper.auditLog( - auth.userId, - "app_deleted", - appId, - { name: app.name }, - getClientIp(c), - ); - return c.json({ success: true }); - } - return c.json({ error: "Application not found" }, 404); -}); - -// --------------------------------------------------------- -// Admin Role Catalog Management -// --------------------------------------------------------- - -adminRoutes.get("/roles", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const appId = c.req.query("appId"); - let roles; - if (appId) { - roles = await sqlWrapper.sql` - SELECT r.id, r.name, r.description, r.app_id, r.created_at, - a.name AS app_name - FROM roles r - LEFT JOIN apps a ON r.app_id = a.id - WHERE r.app_id IS NULL OR r.app_id = ${appId} - ORDER BY r.app_id NULLS FIRST, r.name ASC - `; - } else { - roles = await sqlWrapper.sql` - SELECT r.id, r.name, r.description, r.app_id, r.created_at, - a.name AS app_name - FROM roles r - LEFT JOIN apps a ON r.app_id = a.id - ORDER BY r.app_id NULLS FIRST, r.name ASC - `; - } - - return c.json({ roles }); -}); - -adminRoutes.post("/roles", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const { name, description, appId } = await c.req.json(); - if ( - !name || typeof name !== "string" || name.trim().length < 2 || - name.trim().length > 32 - ) { - return c.json( - { error: "Role name must be between 2 and 32 characters" }, - 400, - ); - } - - const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_"); - - let validatedAppId = null; - if (appId && typeof appId === "string" && appId.trim()) { - const uuidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - if (!uuidRegex.test(appId)) { - return c.json({ error: "Invalid App UUID" }, 400); - } - const appExists = await sqlWrapper - .sql`SELECT id, name FROM apps WHERE id = ${appId}` - .then((res: any) => res[0]); - if (!appExists) { - return c.json({ error: "Selected application does not exist" }, 404); - } - validatedAppId = appId; - } - - try { - const newRole = await sqlWrapper.sql` - INSERT INTO roles (name, description, app_id) - VALUES (${normalizedName}, ${ - description?.trim() || null - }, ${validatedAppId}) - RETURNING id, name, description, app_id, created_at - `.then((res: any) => res[0]); - - auditWrapper.auditLog(auth.userId, "role_created", validatedAppId, { - role_name: newRole.name, - scope: validatedAppId ? "app-specific" : "global", - }, getClientIp(c)); - - return c.json({ success: true, role: newRole }); - } catch (err: any) { - if (err.code === "23505") { - return c.json({ - error: "This role already exists for the selected scope", - }, 409); - } - return c.json({ error: "Failed to create role" }, 500); - } -}); - -adminRoutes.put("/roles/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const roleId = c.req.param("id"); - const { name, description } = await c.req.json(); - - if (!name || typeof name !== "string" || name.trim().length < 2) { - return c.json({ error: "Role name must be at least 2 characters" }, 400); - } - - const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_"); - - try { - const updatedRole = await sqlWrapper.sql` - UPDATE roles - SET name = ${normalizedName}, - description = ${description?.trim() || null} - WHERE id = ${roleId} - RETURNING id, name, description, app_id, created_at - `.then((res: any) => res[0]); - - if (!updatedRole) return c.json({ error: "Role not found" }, 404); - - auditWrapper.auditLog(auth.userId, "role_updated", updatedRole.app_id, { - role_name: updatedRole.name, - }, getClientIp(c)); - - return c.json({ success: true, role: updatedRole }); - } catch (_err: any) { - return c.json({ error: "Failed to update role" }, 500); - } -}); - -adminRoutes.delete("/roles/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const roleId = c.req.param("id"); - const role = await sqlWrapper - .sql`SELECT id, name, app_id FROM roles WHERE id = ${roleId}`.then( - (res: any) => res[0], - ); - if (!role) { - return c.json({ error: "Role not found" }, 404); - } - - if (role.name === "admin" && role.app_id === null) { - return c.json({ error: "The global 'admin' role cannot be deleted" }, 400); - } - - await sqlWrapper.sql`DELETE FROM roles WHERE id = ${roleId}`; - auditWrapper.auditLog( - auth.userId, - "role_deleted", - role.app_id, - { role_name: role.name }, - getClientIp(c), - ); - return c.json({ success: true }); -}); - -// --------------------------------------------------------- -// Admin Invites / Registration Tokens -// --------------------------------------------------------- - -adminRoutes.get("/invites", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const invites = await sqlWrapper.sql` - SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at, - a.name AS app_name, a.id AS app_id, - u.username AS used_by_username - FROM invites i - LEFT JOIN apps a ON i.app_id = a.id - LEFT JOIN users u ON i.used_by = u.id - ORDER BY i.created_at DESC - `; - return c.json({ invites }); -}); - -adminRoutes.post("/invites/create", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const { - appId, - role, - expiresInDays, - customCode, - usageLimitType, - maxUses, - autoActivate, - } = await c.req.json(); - const assignedRole = (typeof role === "string" && role.trim()) - ? role.trim() - : "user"; - - let validatedAppId = null; - if (appId) { - const uuidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - if (typeof appId !== "string" || !uuidRegex.test(appId)) { - return c.json({ error: "appId must be a valid UUID string" }, 400); - } - const appExists = await sqlWrapper - .sql`SELECT id FROM apps WHERE id = ${appId}`.then( - (res: any) => res[0], - ); - if (!appExists) { - return c.json({ error: "Target application does not exist" }, 404); - } - validatedAppId = appId; - } - - let parsedMaxUses: number | null = 1; - if (usageLimitType === "unlimited") { - parsedMaxUses = null; - } else if (usageLimitType === "limited") { - const n = parseInt(maxUses); - parsedMaxUses = (!isNaN(n) && n > 0) ? n : 5; - } else { - parsedMaxUses = 1; // single-use default - } - - const shouldAutoActivate = autoActivate !== false; - - const inviteCode = - (customCode && typeof customCode === "string" && customCode.trim()) - ? customCode.trim() - : encodeBase64Url(crypto.getRandomValues(new Uint8Array(24))); - - const days = Number(expiresInDays) || 7; - if (!Number.isInteger(days) || days < 1 || days > 30) { - return c.json({ - error: "expiresInDays must be an integer between 1 and 30", - }, 400); - } - - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + days); - - try { - await sqlWrapper.sql` - INSERT INTO invites (code, app_id, role, created_by, max_uses, uses_count, auto_activate, expires_at) - VALUES (${inviteCode}, ${validatedAppId}, ${assignedRole}, ${auth.userId}, ${parsedMaxUses}, 0, ${shouldAutoActivate}, ${expiresAt}) - `; - } catch (err: any) { - if (err.code === "23505") { - return c.json({ error: "An invite with this code already exists" }, 409); - } - return c.json({ error: "Failed to generate invite" }, 500); - } - - auditWrapper.auditLog( - auth.userId, - "invite_created", - validatedAppId, - { - code: inviteCode, - role: assignedRole, - max_uses: parsedMaxUses, - auto_activate: shouldAutoActivate, - expiresInDays: days, - }, - getClientIp(c), - ); - - return c.json({ - success: true, - inviteCode, - expiresAt, - maxUses: parsedMaxUses, - autoActivate: shouldAutoActivate, - }); -}); - -adminRoutes.get("/invites/:id/redemptions", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const inviteId = c.req.param("id"); - const redemptions = await sqlWrapper.sql` - SELECT ir.id, ir.redeemed_at, u.id AS user_id, u.username, u.display_name, u.account_status - FROM invite_redemptions ir - JOIN users u ON ir.user_id = u.id - WHERE ir.invite_id = ${inviteId} - ORDER BY ir.redeemed_at DESC - `; - - return c.json({ redemptions }); -}); - -adminRoutes.delete("/invites/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const inviteId = c.req.param("id"); - const invite = await sqlWrapper - .sql`DELETE FROM invites WHERE id = ${inviteId} RETURNING id, code` - .then((res: any) => res[0]); - if (invite) { - auditWrapper.auditLog( - auth.userId, - "invite_revoked", - inviteId, - { code: invite.code }, - getClientIp(c), - ); - return c.json({ success: true }); - } - return c.json({ error: "Invite not found" }, 404); -}); - -// --------------------------------------------------------- -// Admin User RBAC Grants Management -// --------------------------------------------------------- - -adminRoutes.get("/users/:id/grants", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetUserId = c.req.param("id"); - const grants = await sqlWrapper.sql` - SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id - FROM grants g - JOIN apps a ON g.app_id = a.id - WHERE g.user_id = ${targetUserId} - ORDER BY a.name ASC - `; - return c.json({ grants }); -}); - -adminRoutes.post("/users/:id/grants", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetUserId = c.req.param("id"); - const { appId, role } = await c.req.json(); - - if (!appId || !role) { - return c.json({ error: "appId and role are required" }, 400); - } - - const app = await sqlWrapper - .sql`SELECT id, name FROM apps WHERE id = ${appId}`.then( - (res: any) => res[0], - ); - if (!app) return c.json({ error: "Application not found" }, 404); - - const targetUser = await sqlWrapper - .sql`SELECT id, username FROM users WHERE id = ${targetUserId}`.then( - (res: any) => res[0], - ); - if (!targetUser) return c.json({ error: "User not found" }, 404); - - await sqlWrapper.sql` - INSERT INTO grants (user_id, app_id, role) - VALUES (${targetUserId}, ${appId}, ${role}) - ON CONFLICT (user_id, app_id) DO UPDATE SET role = ${role} - `; - - auditWrapper.auditLog( - auth.userId, - "user_grant_assigned", - targetUserId, - { app_id: appId, app_name: app.name, role }, - getClientIp(c), - ); - - return c.json({ success: true }); -}); - -adminRoutes.delete("/users/:id/grants/:appId", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const { id: targetUserId, appId } = c.req.param(); - - const grant = await sqlWrapper.sql` - DELETE FROM grants - WHERE user_id = ${targetUserId} AND app_id = ${appId} - RETURNING id - `.then((res: any) => res[0]); - - if (grant) { - auditWrapper.auditLog( - auth.userId, - "user_grant_revoked", - targetUserId, - { app_id: appId }, - getClientIp(c), - ); - return c.json({ success: true }); - } - - return c.json({ error: "Grant not found" }, 404); -}); - -// --------------------------------------------------------- -// Admin AAGUID Management -// --------------------------------------------------------- - -adminRoutes.get("/aaguid", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const allowlist = await sqlWrapper - .sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`; - return c.json({ allowlist }); -}); - -adminRoutes.post("/aaguid", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const { aaguid, description } = await c.req.json(); - const uuidRegex = - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i; - if (!aaguid || !uuidRegex.test(aaguid)) { - return c.json({ error: "Valid AAGUID (UUID) is required" }, 400); - } - try { - await sqlWrapper - .sql`INSERT INTO aaguid_allowlist (aaguid, description) VALUES (${aaguid.toLowerCase()}, ${ - description || null - })`; - auditWrapper.auditLog( - auth.userId, - "aaguid_added", - null, - { aaguid }, - getClientIp(c), - ); - return c.json({ success: true }); - } catch (err: any) { - if (err.code === "23505") { - return c.json({ error: "AAGUID already exists" }, 409); - } - return c.json({ error: "Internal server error" }, 500); - } -}); - -adminRoutes.delete("/aaguid/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const id = c.req.param("id"); - const record = await sqlWrapper - .sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid` - .then((res: any) => res[0]); - if (record) { - auditWrapper.auditLog( - auth.userId, - "aaguid_removed", - null, - { aaguid: record.aaguid }, - getClientIp(c), - ); - } - return c.json({ success: true }); -}); - -// --------------------------------------------------------- -// Admin HWK (Header Web Key) Management -// --------------------------------------------------------- - -adminRoutes.post("/hwk", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const { jwk, name } = await c.req.json(); - if (!jwk || !name || typeof name !== "string") { - return c.json({ error: "Missing required fields: jwk, name" }, 400); - } - - if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) { - return c.json({ error: "Invalid JWK: Must be an Ed25519 OKP key" }, 400); - } - - try { - const fingerprint = await computeJwkThumbprint(jwk); - - // 1. Dual Storage: PostgreSQL (Durability) - await sqlWrapper.sql` - INSERT INTO hwk_keys (fingerprint, public_key, name) - VALUES (${fingerprint}, ${JSON.stringify(jwk)}, ${name}) - `; - - // 2. Dual Storage: Valkey (O(1) Verification) - await valkey.sadd("auth:hwk:fingerprints", fingerprint); - - auditWrapper.auditLog( - auth.userId, - "hwk_added", - null, - { fingerprint, name }, - getClientIp(c), - ); - - return c.json({ success: true, fingerprint }, 201); - } catch (err: any) { - if (err.code === "23505") { - return c.json({ error: "This key has already been registered" }, 409); - } - console.error("Failed to add HWK:", err); - return c.json({ error: "Internal server error" }, 500); - } -}); - -adminRoutes.delete("/hwk/:fingerprint", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const fingerprint = c.req.param("fingerprint"); - - // 1. Remove from PostgreSQL - const record = await sqlWrapper.sql` - DELETE FROM hwk_keys WHERE fingerprint = ${fingerprint} RETURNING id, name - `.then((res: any) => res[0]); - - if (record) { - // 2. Remove from Valkey - try { - await valkey.srem("auth:hwk:fingerprints", fingerprint); - } catch (_err) {} - - auditWrapper.auditLog(auth.userId, "hwk_removed", null, { - fingerprint, - name: record.name, - }, getClientIp(c)); - return c.json({ success: true }); - } - - return c.json({ error: "Key not found" }, 404); -}); - -// --------------------------------------------------------- -// Global Session and Device Revocation (Admin) -// --------------------------------------------------------- - -adminRoutes.get("/users/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const targetUserId = c.req.param("id"); - const user = await sqlWrapper - .sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}` - .then((res: any) => res[0]); - if (!user) return c.json({ error: "User not found" }, 404); - const sessions = await sqlWrapper - .sql`SELECT id, created_at, expires_at FROM sessions WHERE user_id = ${targetUserId} ORDER BY created_at DESC`; - const passkeys = await sqlWrapper - .sql`SELECT id, credential_id, counter FROM passkeys WHERE user_id = ${targetUserId}`; - return c.json({ user, sessions, passkeys }); -}); - -adminRoutes.delete("/sessions/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const sessionId = c.req.param("id"); - const session = await sqlWrapper - .sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id` - .then((res: any) => res[0]); - if (session) { - try { - await valkey.del(sessionId); - } catch (_err) {} - auditWrapper.auditLog( - auth.userId, - "admin_session_revoked", - session.user_id, - { - revoked_session_id: sessionId, - }, - getClientIp(c), - ); - } - return c.json({ success: true }); -}); - -adminRoutes.delete("/users/:id/sessions", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const targetUserId = c.req.param("id"); - const sessions = await sqlWrapper - .sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`; - for (const session of sessions) { - try { - await valkey.del(session.id); - } catch (_err) {} - } - auditWrapper.auditLog( - auth.userId, - "admin_all_sessions_revoked", - targetUserId, - null, - getClientIp(c), - ); - return c.json({ success: true }); -}); - -adminRoutes.delete("/users/:userId/passkeys/:passkeyId", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const { userId, passkeyId } = c.req.param(); - const passkey = await sqlWrapper - .sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id` - .then((res: any) => res[0]); - if (passkey) { - auditWrapper.auditLog(auth.userId, "admin_passkey_revoked", userId, { - passkey_id: passkey.id, - }, getClientIp(c)); - return c.json({ success: true }); - } - return c.json({ error: "Passkey not found" }, 404); -}); - -// --------------------------------------------------------- -// Out-of-Band Account Recovery (Use Case 12) -// --------------------------------------------------------- - -adminRoutes.post("/users/:id/recovery", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - const targetUserId = c.req.param("id"); - const targetUser = await sqlWrapper - .sql`SELECT id FROM users WHERE id = ${targetUserId}` - .then((res: any) => res[0]); - if (!targetUser) return c.json({ error: "User not found" }, 404); - const recoveryCode = encodeBase64Url( - crypto.getRandomValues(new Uint8Array(24)), - ); - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + 1); - await sqlWrapper - .sql`INSERT INTO recovery_links (code, user_id, created_by, expires_at) VALUES (${recoveryCode}, ${targetUserId}, ${auth.userId}, ${expiresAt})`; - auditWrapper.auditLog( - auth.userId, - "recovery_link_created", - targetUserId, - null, - getClientIp(c), - ); - return c.json({ success: true, recoveryCode, expiresAt }); -}); - -adminRoutes.get("/check", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const isAdmin = await isGlobalAdmin(auth.userId); - return c.json({ isAdmin }); -}); +adminRoutes.route("/users", usersAdminRoutes); +adminRoutes.route("/apps", appsAdminRoutes); +adminRoutes.route("/roles", rolesAdminRoutes); +adminRoutes.route("/invites", invitesAdminRoutes); +adminRoutes.route("/", hardwareKeysAdminRoutes); +adminRoutes.route("/", auditAdminRoutes); diff --git a/server/routes/admin/apps.ts b/server/routes/admin/apps.ts new file mode 100644 index 0000000..7e081e7 --- /dev/null +++ b/server/routes/admin/apps.ts @@ -0,0 +1,129 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../../db.ts"; +import { auditWrapper } from "../../audit.ts"; +import { getAuthenticatedUser } from "../../auth-session.ts"; +import { getClientIp } from "../../middleware.ts"; + +export const appsAdminRoutes = new Hono(); + +appsAdminRoutes.get("/", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const apps = await sqlWrapper.sql` + SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at, + COUNT(g.id) AS active_grants_count + FROM apps a + LEFT JOIN grants g ON a.id = g.app_id + GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at + ORDER BY a.created_at ASC + `; + return c.json({ apps }); +}); + +appsAdminRoutes.post("/", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { + name, + spiffeId, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + } = await c.req.json(); + if (!name || !spiffeId) { + return c.json({ error: "Name and SPIFFE ID are required" }, 400); + } + + try { + const newApp = await sqlWrapper.sql` + INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs) + VALUES (${name.trim()}, ${spiffeId.trim()}, ${ + description?.trim() || null + }, ${domain?.trim() || null}, ${is_public || false}, ${ + bypass_paths || [] + }, ${allowed_cidrs || []}) + RETURNING id, name, spiffe_id, description, created_at + `.then((res: any) => res[0]); + + auditWrapper.auditLog(auth.userId, "app_registered", newApp.id, { + name: newApp.name, + spiffe_id: newApp.spiffe_id, + }, getClientIp(c)); + return c.json({ success: true, app: newApp }); + } catch (err: any) { + if (err.code === "23505") { + return c.json({ + error: "An application with this SPIFFE ID already exists", + }, 409); + } + return c.json({ error: "Failed to register application" }, 500); + } +}); + +appsAdminRoutes.put("/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const appId = c.req.param("id"); + const { + name, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + } = await c.req.json(); + + if (!name) { + return c.json({ error: "Application name is required" }, 400); + } + + try { + const updatedApp = await sqlWrapper.sql` + UPDATE apps + SET name = ${name.trim()}, + description = ${description?.trim() || null}, + domain = ${domain?.trim() || null}, + is_public = ${is_public || false}, + bypass_paths = ${bypass_paths || []}, + allowed_cidrs = ${allowed_cidrs || []} + WHERE id = ${appId} + RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs + `.then((res: any) => res[0]); + + if (!updatedApp) return c.json({ error: "Application not found" }, 404); + + auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, { + name: updatedApp.name, + }, getClientIp(c)); + + return c.json({ success: true, app: updatedApp }); + } catch (_err: any) { + return c.json({ error: "Failed to update application" }, 500); + } +}); + +appsAdminRoutes.delete("/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const appId = c.req.param("id"); + const app = await sqlWrapper + .sql`DELETE FROM apps WHERE id = ${appId} RETURNING id, name` + .then((res: any) => res[0]); + if (app) { + auditWrapper.auditLog( + auth.userId, + "app_deleted", + appId, + { name: app.name }, + getClientIp(c), + ); + return c.json({ success: true }); + } + return c.json({ error: "Application not found" }, 404); +}); diff --git a/server/routes/admin/audit.ts b/server/routes/admin/audit.ts new file mode 100644 index 0000000..8ab7eee --- /dev/null +++ b/server/routes/admin/audit.ts @@ -0,0 +1,55 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../../db.ts"; +import { valkey } from "../../valkey.ts"; +import { auditWrapper } from "../../audit.ts"; +import { getAuthenticatedUser, isGlobalAdmin } from "../../auth-session.ts"; +import { getClientIp } from "../../middleware.ts"; + +export const auditAdminRoutes = new Hono(); + +auditAdminRoutes.get("/audit-logs", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const logs = await sqlWrapper.sql` + SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user + FROM audit_records a + LEFT JOIN users u ON a.user_id = u.id + ORDER BY a.created_at DESC + LIMIT 100 + `; + + return c.json({ logs }); +}); + +auditAdminRoutes.get("/check", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const isAdmin = await isGlobalAdmin(auth.userId); + return c.json({ isAdmin }); +}); + +auditAdminRoutes.delete("/sessions/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const sessionId = c.req.param("id"); + const session = await sqlWrapper + .sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id` + .then((res: any) => res[0]); + if (session) { + try { + await valkey.del(sessionId); + } catch (_err) {} + auditWrapper.auditLog( + auth.userId, + "admin_session_revoked", + session.user_id, + { + revoked_session_id: sessionId, + }, + getClientIp(c), + ); + } + return c.json({ success: true }); +}); diff --git a/server/routes/admin/hardware_keys.ts b/server/routes/admin/hardware_keys.ts new file mode 100644 index 0000000..47a576d --- /dev/null +++ b/server/routes/admin/hardware_keys.ts @@ -0,0 +1,136 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../../db.ts"; +import { valkey } from "../../valkey.ts"; +import { auditWrapper } from "../../audit.ts"; +import { getAuthenticatedUser } from "../../auth-session.ts"; +import { getClientIp } from "../../middleware.ts"; +import { computeJwkThumbprint } from "../../http_signatures.ts"; + +export const hardwareKeysAdminRoutes = new Hono(); + +hardwareKeysAdminRoutes.get("/aaguid", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const allowlist = await sqlWrapper + .sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`; + return c.json({ allowlist }); +}); + +hardwareKeysAdminRoutes.post("/aaguid", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const { aaguid, description } = await c.req.json(); + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i; + if (!aaguid || !uuidRegex.test(aaguid)) { + return c.json({ error: "Valid AAGUID (UUID) is required" }, 400); + } + try { + await sqlWrapper + .sql`INSERT INTO aaguid_allowlist (aaguid, description) VALUES (${aaguid.toLowerCase()}, ${ + description || null + })`; + auditWrapper.auditLog( + auth.userId, + "aaguid_added", + null, + { aaguid }, + getClientIp(c), + ); + return c.json({ success: true }); + } catch (err: any) { + if (err.code === "23505") { + return c.json({ error: "AAGUID already exists" }, 409); + } + return c.json({ error: "Internal server error" }, 500); + } +}); + +hardwareKeysAdminRoutes.delete("/aaguid/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const id = c.req.param("id"); + const record = await sqlWrapper + .sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid` + .then((res: any) => res[0]); + if (record) { + auditWrapper.auditLog( + auth.userId, + "aaguid_removed", + null, + { aaguid: record.aaguid }, + getClientIp(c), + ); + } + return c.json({ success: true }); +}); + +hardwareKeysAdminRoutes.post("/hwk", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { jwk, name } = await c.req.json(); + if (!jwk || !name || typeof name !== "string") { + return c.json({ error: "Missing required fields: jwk, name" }, 400); + } + + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) { + return c.json({ error: "Invalid JWK: Must be an Ed25519 OKP key" }, 400); + } + + try { + const fingerprint = await computeJwkThumbprint(jwk); + + // 1. Dual Storage: PostgreSQL (Durability) + await sqlWrapper.sql` + INSERT INTO hwk_keys (fingerprint, public_key, name) + VALUES (${fingerprint}, ${JSON.stringify(jwk)}, ${name}) + `; + + // 2. Dual Storage: Valkey (O(1) Verification) + await valkey.sadd("auth:hwk:fingerprints", fingerprint); + + auditWrapper.auditLog( + auth.userId, + "hwk_added", + null, + { fingerprint, name }, + getClientIp(c), + ); + + return c.json({ success: true, fingerprint }, 201); + } catch (err: any) { + if (err.code === "23505") { + return c.json({ error: "This key has already been registered" }, 409); + } + console.error("Failed to add HWK:", err); + return c.json({ error: "Internal server error" }, 500); + } +}); + +hardwareKeysAdminRoutes.delete("/hwk/:fingerprint", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const fingerprint = c.req.param("fingerprint"); + + // 1. Remove from PostgreSQL + const record = await sqlWrapper.sql` + DELETE FROM hwk_keys WHERE fingerprint = ${fingerprint} RETURNING id, name + `.then((res: any) => res[0]); + + if (record) { + // 2. Remove from Valkey + try { + await valkey.srem("auth:hwk:fingerprints", fingerprint); + } catch (_err) {} + + auditWrapper.auditLog(auth.userId, "hwk_removed", null, { + fingerprint, + name: record.name, + }, getClientIp(c)); + return c.json({ success: true }); + } + + return c.json({ error: "Key not found" }, 404); +}); diff --git a/server/routes/admin/invites.ts b/server/routes/admin/invites.ts new file mode 100644 index 0000000..ce10307 --- /dev/null +++ b/server/routes/admin/invites.ts @@ -0,0 +1,157 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; +import { sqlWrapper } from "../../db.ts"; +import { auditWrapper } from "../../audit.ts"; +import { getAuthenticatedUser } from "../../auth-session.ts"; +import { getClientIp } from "../../middleware.ts"; + +export const invitesAdminRoutes = new Hono(); + +invitesAdminRoutes.get("/", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const invites = await sqlWrapper.sql` + SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at, + a.name AS app_name, a.id AS app_id, + u.username AS used_by_username + FROM invites i + LEFT JOIN apps a ON i.app_id = a.id + LEFT JOIN users u ON i.used_by = u.id + ORDER BY i.created_at DESC + `; + return c.json({ invites }); +}); + +invitesAdminRoutes.post("/create", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { + appId, + role, + expiresInDays, + customCode, + usageLimitType, + maxUses, + autoActivate, + } = await c.req.json(); + const assignedRole = (typeof role === "string" && role.trim()) + ? role.trim() + : "user"; + + let validatedAppId = null; + if (appId) { + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + if (typeof appId !== "string" || !uuidRegex.test(appId)) { + return c.json({ error: "appId must be a valid UUID string" }, 400); + } + const appExists = await sqlWrapper + .sql`SELECT id FROM apps WHERE id = ${appId}`.then( + (res: any) => res[0], + ); + if (!appExists) { + return c.json({ error: "Target application does not exist" }, 404); + } + validatedAppId = appId; + } + + let parsedMaxUses: number | null = 1; + if (usageLimitType === "unlimited") { + parsedMaxUses = null; + } else if (usageLimitType === "limited") { + const n = parseInt(maxUses); + parsedMaxUses = (!isNaN(n) && n > 0) ? n : 5; + } else { + parsedMaxUses = 1; // single-use default + } + + const shouldAutoActivate = autoActivate !== false; + + const inviteCode = + (customCode && typeof customCode === "string" && customCode.trim()) + ? customCode.trim() + : encodeBase64Url(crypto.getRandomValues(new Uint8Array(24))); + + const days = Number(expiresInDays) || 7; + if (!Number.isInteger(days) || days < 1 || days > 30) { + return c.json({ + error: "expiresInDays must be an integer between 1 and 30", + }, 400); + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + days); + + try { + await sqlWrapper.sql` + INSERT INTO invites (code, app_id, role, created_by, max_uses, uses_count, auto_activate, expires_at) + VALUES (${inviteCode}, ${validatedAppId}, ${assignedRole}, ${auth.userId}, ${parsedMaxUses}, 0, ${shouldAutoActivate}, ${expiresAt}) + `; + } catch (err: any) { + if (err.code === "23505") { + return c.json({ error: "An invite with this code already exists" }, 409); + } + return c.json({ error: "Failed to generate invite" }, 500); + } + + auditWrapper.auditLog( + auth.userId, + "invite_created", + validatedAppId, + { + code: inviteCode, + role: assignedRole, + max_uses: parsedMaxUses, + auto_activate: shouldAutoActivate, + expiresInDays: days, + }, + getClientIp(c), + ); + + return c.json({ + success: true, + inviteCode, + expiresAt, + maxUses: parsedMaxUses, + autoActivate: shouldAutoActivate, + }); +}); + +invitesAdminRoutes.get("/:id/redemptions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const inviteId = c.req.param("id"); + const redemptions = await sqlWrapper.sql` + SELECT ir.id, ir.redeemed_at, u.id AS user_id, u.username, u.display_name, u.account_status + FROM invite_redemptions ir + JOIN users u ON ir.user_id = u.id + WHERE ir.invite_id = ${inviteId} + ORDER BY ir.redeemed_at DESC + `; + + return c.json({ redemptions }); +}); + +invitesAdminRoutes.delete("/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const inviteId = c.req.param("id"); + const invite = await sqlWrapper + .sql`DELETE FROM invites WHERE id = ${inviteId} RETURNING id, code` + .then((res: any) => res[0]); + if (invite) { + auditWrapper.auditLog( + auth.userId, + "invite_revoked", + inviteId, + { code: invite.code }, + getClientIp(c), + ); + return c.json({ success: true }); + } + return c.json({ error: "Invite not found" }, 404); +}); diff --git a/server/routes/admin/roles.ts b/server/routes/admin/roles.ts new file mode 100644 index 0000000..8386db9 --- /dev/null +++ b/server/routes/admin/roles.ts @@ -0,0 +1,155 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../../db.ts"; +import { auditWrapper } from "../../audit.ts"; +import { getAuthenticatedUser } from "../../auth-session.ts"; +import { getClientIp } from "../../middleware.ts"; + +export const rolesAdminRoutes = new Hono(); + +rolesAdminRoutes.get("/", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const appId = c.req.query("appId"); + let roles; + if (appId) { + roles = await sqlWrapper.sql` + SELECT r.id, r.name, r.description, r.app_id, r.created_at, + a.name AS app_name + FROM roles r + LEFT JOIN apps a ON r.app_id = a.id + WHERE r.app_id IS NULL OR r.app_id = ${appId} + ORDER BY r.app_id NULLS FIRST, r.name ASC + `; + } else { + roles = await sqlWrapper.sql` + SELECT r.id, r.name, r.description, r.app_id, r.created_at, + a.name AS app_name + FROM roles r + LEFT JOIN apps a ON r.app_id = a.id + ORDER BY r.app_id NULLS FIRST, r.name ASC + `; + } + + return c.json({ roles }); +}); + +rolesAdminRoutes.post("/", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { name, description, appId } = await c.req.json(); + if ( + !name || typeof name !== "string" || name.trim().length < 2 || + name.trim().length > 32 + ) { + return c.json( + { error: "Role name must be between 2 and 32 characters" }, + 400, + ); + } + + const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_"); + + let validatedAppId = null; + if (appId && typeof appId === "string" && appId.trim()) { + const uuidRegex = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + if (!uuidRegex.test(appId)) { + return c.json({ error: "Invalid App UUID" }, 400); + } + const appExists = await sqlWrapper + .sql`SELECT id, name FROM apps WHERE id = ${appId}` + .then((res: any) => res[0]); + if (!appExists) { + return c.json({ error: "Selected application does not exist" }, 404); + } + validatedAppId = appId; + } + + try { + const newRole = await sqlWrapper.sql` + INSERT INTO roles (name, description, app_id) + VALUES (${normalizedName}, ${ + description?.trim() || null + }, ${validatedAppId}) + RETURNING id, name, description, app_id, created_at + `.then((res: any) => res[0]); + + auditWrapper.auditLog(auth.userId, "role_created", validatedAppId, { + role_name: newRole.name, + scope: validatedAppId ? "app-specific" : "global", + }, getClientIp(c)); + + return c.json({ success: true, role: newRole }); + } catch (err: any) { + if (err.code === "23505") { + return c.json({ + error: "This role already exists for the selected scope", + }, 409); + } + return c.json({ error: "Failed to create role" }, 500); + } +}); + +rolesAdminRoutes.put("/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const roleId = c.req.param("id"); + const { name, description } = await c.req.json(); + + if (!name || typeof name !== "string" || name.trim().length < 2) { + return c.json({ error: "Role name must be at least 2 characters" }, 400); + } + + const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_"); + + try { + const updatedRole = await sqlWrapper.sql` + UPDATE roles + SET name = ${normalizedName}, + description = ${description?.trim() || null} + WHERE id = ${roleId} + RETURNING id, name, description, app_id, created_at + `.then((res: any) => res[0]); + + if (!updatedRole) return c.json({ error: "Role not found" }, 404); + + auditWrapper.auditLog(auth.userId, "role_updated", updatedRole.app_id, { + role_name: updatedRole.name, + }, getClientIp(c)); + + return c.json({ success: true, role: updatedRole }); + } catch (_err: any) { + return c.json({ error: "Failed to update role" }, 500); + } +}); + +rolesAdminRoutes.delete("/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const roleId = c.req.param("id"); + const role = await sqlWrapper + .sql`SELECT id, name, app_id FROM roles WHERE id = ${roleId}`.then( + (res: any) => res[0], + ); + if (!role) { + return c.json({ error: "Role not found" }, 404); + } + + if (role.name === "admin" && role.app_id === null) { + return c.json({ error: "The global 'admin' role cannot be deleted" }, 400); + } + + await sqlWrapper.sql`DELETE FROM roles WHERE id = ${roleId}`; + auditWrapper.auditLog( + auth.userId, + "role_deleted", + role.app_id, + { role_name: role.name }, + getClientIp(c), + ); + return c.json({ success: true }); +}); diff --git a/server/routes/admin/users.ts b/server/routes/admin/users.ts new file mode 100644 index 0000000..cf63a2f --- /dev/null +++ b/server/routes/admin/users.ts @@ -0,0 +1,228 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; +import { sqlWrapper } from "../../db.ts"; +import { valkey } from "../../valkey.ts"; +import { auditWrapper } from "../../audit.ts"; +import { getAuthenticatedUser } from "../../auth-session.ts"; +import { getClientIp } from "../../middleware.ts"; + +export const usersAdminRoutes = new Hono(); + +usersAdminRoutes.get("/", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const users = await sqlWrapper.sql` + SELECT id, username, display_name, account_status + FROM users + ORDER BY username ASC + `; + + return c.json({ users }); +}); + +usersAdminRoutes.post("/:id/status", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetUserId = c.req.param("id"); + const { status } = await c.req.json(); + + if (!["active", "pending", "suspended"].includes(status)) { + return c.json({ error: "Invalid status" }, 400); + } + + const targetUser = await sqlWrapper + .sql`UPDATE users SET account_status = ${status} WHERE id = ${targetUserId} RETURNING id` + .then((res: any) => res[0]); + + if (!targetUser) { + return c.json({ error: "User not found" }, 404); + } + + auditWrapper.auditLog(auth.userId, "user_status_changed", targetUserId, { + newStatus: status, + }, getClientIp(c)); + + return c.json({ success: true }); +}); + +usersAdminRoutes.post("/:id/profile", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetUserId = c.req.param("id"); + const { displayName } = await c.req.json(); + + const targetUser = await sqlWrapper + .sql`UPDATE users SET display_name = ${ + displayName?.trim() || null + } WHERE id = ${targetUserId} RETURNING id, username, display_name` + .then((res: any) => res[0]); + + if (!targetUser) return c.json({ error: "User not found" }, 404); + + auditWrapper.auditLog(auth.userId, "user_profile_updated", targetUserId, { + display_name: targetUser.display_name, + }, getClientIp(c)); + + return c.json({ success: true, user: targetUser }); +}); + +usersAdminRoutes.get("/:id/grants", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetUserId = c.req.param("id"); + const grants = await sqlWrapper.sql` + SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id + FROM grants g + JOIN apps a ON g.app_id = a.id + WHERE g.user_id = ${targetUserId} + ORDER BY a.name ASC + `; + return c.json({ grants }); +}); + +usersAdminRoutes.post("/:id/grants", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetUserId = c.req.param("id"); + const { appId, role } = await c.req.json(); + + if (!appId || !role) { + return c.json({ error: "appId and role are required" }, 400); + } + + const app = await sqlWrapper + .sql`SELECT id, name FROM apps WHERE id = ${appId}`.then( + (res: any) => res[0], + ); + if (!app) return c.json({ error: "Application not found" }, 404); + + const targetUser = await sqlWrapper + .sql`SELECT id, username FROM users WHERE id = ${targetUserId}`.then( + (res: any) => res[0], + ); + if (!targetUser) return c.json({ error: "User not found" }, 404); + + await sqlWrapper.sql` + INSERT INTO grants (user_id, app_id, role) + VALUES (${targetUserId}, ${appId}, ${role}) + ON CONFLICT (user_id, app_id) DO UPDATE SET role = ${role} + `; + + auditWrapper.auditLog( + auth.userId, + "user_grant_assigned", + targetUserId, + { app_id: appId, app_name: app.name, role }, + getClientIp(c), + ); + + return c.json({ success: true }); +}); + +usersAdminRoutes.delete("/:id/grants/:appId", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { id: targetUserId, appId } = c.req.param(); + + const grant = await sqlWrapper.sql` + DELETE FROM grants + WHERE user_id = ${targetUserId} AND app_id = ${appId} + RETURNING id + `.then((res: any) => res[0]); + + if (grant) { + auditWrapper.auditLog( + auth.userId, + "user_grant_revoked", + targetUserId, + { app_id: appId }, + getClientIp(c), + ); + return c.json({ success: true }); + } + + return c.json({ error: "Grant not found" }, 404); +}); + +usersAdminRoutes.get("/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const targetUserId = c.req.param("id"); + const user = await sqlWrapper + .sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}` + .then((res: any) => res[0]); + if (!user) return c.json({ error: "User not found" }, 404); + const sessions = await sqlWrapper + .sql`SELECT id, created_at, expires_at FROM sessions WHERE user_id = ${targetUserId} ORDER BY created_at DESC`; + const passkeys = await sqlWrapper + .sql`SELECT id, credential_id, counter FROM passkeys WHERE user_id = ${targetUserId}`; + return c.json({ user, sessions, passkeys }); +}); + +usersAdminRoutes.delete("/:id/sessions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const targetUserId = c.req.param("id"); + const sessions = await sqlWrapper + .sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`; + for (const session of sessions) { + try { + await valkey.del(session.id); + } catch (_err) {} + } + auditWrapper.auditLog( + auth.userId, + "admin_all_sessions_revoked", + targetUserId, + null, + getClientIp(c), + ); + return c.json({ success: true }); +}); + +usersAdminRoutes.delete("/:userId/passkeys/:passkeyId", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const { userId, passkeyId } = c.req.param(); + const passkey = await sqlWrapper + .sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id` + .then((res: any) => res[0]); + if (passkey) { + auditWrapper.auditLog(auth.userId, "admin_passkey_revoked", userId, { + passkey_id: passkey.id, + }, getClientIp(c)); + return c.json({ success: true }); + } + return c.json({ error: "Passkey not found" }, 404); +}); + +usersAdminRoutes.post("/:id/recovery", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const targetUserId = c.req.param("id"); + const targetUser = await sqlWrapper + .sql`SELECT id FROM users WHERE id = ${targetUserId}` + .then((res: any) => res[0]); + if (!targetUser) return c.json({ error: "User not found" }, 404); + const recoveryCode = encodeBase64Url( + crypto.getRandomValues(new Uint8Array(24)), + ); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 1); + await sqlWrapper + .sql`INSERT INTO recovery_links (code, user_id, created_by, expires_at) VALUES (${recoveryCode}, ${targetUserId}, ${auth.userId}, ${expiresAt})`; + auditWrapper.auditLog( + auth.userId, + "recovery_link_created", + targetUserId, + null, + getClientIp(c), + ); + return c.json({ success: true, recoveryCode, expiresAt }); +}); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 6e48aff..1a1267d 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,928 +1,13 @@ 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"; +import { registerAuthRoutes } from "./auth/register.ts"; +import { loginAuthRoutes } from "./auth/login.ts"; +import { passkeysAuthRoutes } from "./auth/passkeys.ts"; +import { guestAuthRoutes } from "./auth/guest.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 }); -}); +authRoutes.route("/", registerAuthRoutes); +authRoutes.route("/", loginAuthRoutes); +authRoutes.route("/", passkeysAuthRoutes); +authRoutes.route("/", guestAuthRoutes); diff --git a/server/routes/auth/guest.ts b/server/routes/auth/guest.ts new file mode 100644 index 0000000..2483c55 --- /dev/null +++ b/server/routes/auth/guest.ts @@ -0,0 +1,90 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; + +import { sqlWrapper } from "../../db.ts"; +import { valkey } from "../../valkey.ts"; +import { extractAllSessionIds } from "../../auth-session.ts"; +import { getCookieDomain } from "./utils.ts"; + +export const guestAuthRoutes = new Hono(); + +const rpID = Deno.env.get("RP_ID") || + (import.meta.main ? undefined : "localhost"); + +// Generate Ephemeral Guest Sandbox +guestAuthRoutes.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) +guestAuthRoutes.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 }); +}); diff --git a/server/routes/auth/login.ts b/server/routes/auth/login.ts new file mode 100644 index 0000000..b81a2ed --- /dev/null +++ b/server/routes/auth/login.ts @@ -0,0 +1,236 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { decodeBase64Url } from "jsr:@std/encoding@1/base64url"; +import { + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "jsr:@simplewebauthn/server@13"; +import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13"; + +import { sqlWrapper } from "../../db.ts"; +import { valkey } from "../../valkey.ts"; +import { auditWrapper } from "../../audit.ts"; +import { extractAllSessionIds } from "../../auth-session.ts"; +import { getClientIp, publicRateLimiter } from "../../middleware.ts"; +import { getCookieDomain } from "./utils.ts"; + +export const loginAuthRoutes = 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 generateSessionId() { + return crypto.randomUUID(); +} + +loginAuthRoutes.use("/api/login/*", publicRateLimiter); + +// Start a WebAuthn authentication ceremony +loginAuthRoutes.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 +loginAuthRoutes.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 }); +}); diff --git a/server/routes/auth/passkeys.ts b/server/routes/auth/passkeys.ts new file mode 100644 index 0000000..6b952cb --- /dev/null +++ b/server/routes/auth/passkeys.ts @@ -0,0 +1,268 @@ +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 }); +}); diff --git a/server/routes/auth/register.ts b/server/routes/auth/register.ts new file mode 100644 index 0000000..9e29ff2 --- /dev/null +++ b/server/routes/auth/register.ts @@ -0,0 +1,369 @@ +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); + } +}); diff --git a/server/routes/auth/utils.ts b/server/routes/auth/utils.ts new file mode 100644 index 0000000..a61e272 --- /dev/null +++ b/server/routes/auth/utils.ts @@ -0,0 +1,15 @@ +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}`; +}