From 88821b80afdd4d11d82fb4d1828cf7b0c4bdbfb8 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 03:23:20 +0000 Subject: [PATCH] feat: Phase 3 Monolith decomposition of server/main.ts - Extracts Auth, Registration, and Passkey routes into `server/routes/auth.ts`. - Extracts all Admin API endpoints into `server/routes/admin.ts`. - Extracts RPC Connect setup and mTLS listener into `server/rpc.ts`. - Extracts global rate limiters and IP helpers into `server/middleware.ts`. - Reduces `server/main.ts` purely to an entrypoint mounting orchestrator. - Ensures all existing tests and quality gates pass with zero regressions. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com> --- server/main.ts | 2067 +---------------- server/middleware.ts | 62 + server/routes/admin.ts | 865 +++++++ server/routes/auth.ts | 928 ++++++++ server/rpc.ts | 173 ++ ...monolith-decomposition-roadmap-1845.ph3.md | 0 6 files changed, 2057 insertions(+), 2038 deletions(-) create mode 100644 server/middleware.ts create mode 100644 server/routes/admin.ts create mode 100644 server/routes/auth.ts create mode 100644 server/rpc.ts rename tasks/{new => complete}/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph3.md (100%) diff --git a/server/main.ts b/server/main.ts index cb264ef..7b623b4 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,47 +1,20 @@ import { recoveryApp } from "./recovery.ts"; import { Hono } from "jsr:@hono/hono@4"; -import type { Context } from "jsr:@hono/hono@4"; -import { - generateAuthenticationOptions, - generateRegistrationOptions, - verifyAuthenticationResponse, - verifyRegistrationResponse, -} from "jsr:@simplewebauthn/server@13"; -import type { - AuthenticationResponseJSON, - RegistrationResponseJSON, -} from "jsr:@simplewebauthn/server@13"; -import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; -import { - extractAllSessionIds, - getAuthenticatedUser, - isGlobalAdmin, - requireAdmin, - requirePrimarySession, -} from "./auth-session.ts"; -import { - decodeBase64Url, - encodeBase64Url, -} from "jsr:@std/encoding@1/base64url"; + import { MetadataService } from "jsr:@simplewebauthn/server@13"; -import { initDb, sqlWrapper } from "./db.ts"; -import { pingValkey, valkey } from "./valkey.ts"; -import { rateLimitWrapper } from "./ratelimit.ts"; -import { auditWrapper } from "./audit.ts"; -import { spireWrapper } from "./spire_ffi.ts"; -import { AuthService } from "../sdk/gen/auth_connect.ts"; -import { - universalServerRequestFromFetch, - universalServerResponseToFetch, -} from "npm:@connectrpc/connect@^1.4.0/protocol"; -import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; -import { computeJwkThumbprint } from "./http_signatures.ts"; +import { initDb } from "./db.ts"; +import { pingValkey } from "./valkey.ts"; + import { uiApp } from "../ui/mod.ts"; import { passRoutes } from "./routes/passes_magic.ts"; import { eventRoutes } from "./routes/events.ts"; import { sessionRoutes } from "./routes/sessions.ts"; import { forwardAuthRoutes } from "./routes/auth_forward.ts"; +import { authRoutes } from "./routes/auth.ts"; +import { adminRoutes } from "./routes/admin.ts"; +import { startConnectRpcServer } from "./rpc.ts"; + type Variables = { userId: string; }; @@ -50,48 +23,37 @@ export const app: Hono<{ Variables: Variables }> = new Hono< { Variables: Variables } >(); +// Middleware +app.use("*", async (_c, next) => { + // TODO: Add mTLS verification or internal network constraints + await next(); +}); + // Mount Sub-routers app.route("/pass", passRoutes); app.route("/", eventRoutes); app.route("/", sessionRoutes); app.route("/", forwardAuthRoutes); +app.route("/api/admin", adminRoutes); +app.route("/", authRoutes); +app.route("/api/recovery", recoveryApp); + +// Initialize RPC Services +startConnectRpcServer(app as any); // Type cast due to Hono version mismatch internally, though here it's compatible // Mount UI Routes app.route("/", uiApp); -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}`; -} - -if (!rpID || !origin) { - throw new Error( - "Missing critical environment variables: RP_ID and ORIGIN must be set.", - ); -} - -// Auth API Gateway for Zero-Trust Identity Provider -// Exposes REST and gRPC endpoints exclusively to the internal Docker network. - if (import.meta.main) { + const rpID = Deno.env.get("RP_ID"); + const origin = Deno.env.get("ORIGIN"); + + if (!rpID || !origin) { + throw new Error( + "Missing critical environment variables: RP_ID and ORIGIN must be set.", + ); + } + console.log("[Auth API] Initializing FIDO MDS3 Metadata Blob..."); try { // SIDE EFFECT: Fetch MDS3 metadata dynamically on startup @@ -116,1978 +78,7 @@ if (import.meta.main) { console.error(error); Deno.exit(1); } -} -function generateSessionId() { - return crypto.randomUUID(); -} - -// --------------------------------------------------------- -// Middleware -// --------------------------------------------------------- -app.use("*", async (_c, next) => { - // TODO: Add mTLS verification or internal network constraints - await next(); -}); - -// Middleware helper to get IP address -function getClientIp(c: Context): string { - // Check for X-Real-IP first - const realIp = c.req.header("x-real-ip"); - if (realIp) { - return realIp.trim(); - } - - // Fallback to X-Forwarded-For, bounded to prevent memory exhaustion - let forwardedFor = c.req.header("x-forwarded-for"); - if (forwardedFor) { - // Truncate to a max of 256 characters - if (forwardedFor.length > 256) { - forwardedFor = forwardedFor.substring(0, 256); - } - const parts = forwardedFor.split(","); - // Extract the last untrusted hop (right-most IP) - return parts[parts.length - 1].trim(); - } - // Fallback (might not be accurate behind proxy without X-Forwarded-For) - return "unknown-ip"; -} - -// Rate Limiting Middlewares - -// Public Endpoints (/api/login/*, /api/register/*): 10 requests per minute by IP -app.use("/api/login/*", async (c, next) => { - const ip = getClientIp(c); - const key = `ratelimit:public:${ip}`; - const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000); - if (!allowed) { - return c.json({ error: "Too Many Requests" }, 429); - } - await next(); -}); - -app.use("/api/register/*", async (c, next) => { - const ip = getClientIp(c); - const key = `ratelimit:public:${ip}`; - const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000); - if (!allowed) { - return c.json({ error: "Too Many Requests" }, 429); - } - await next(); -}); - -// Admin Endpoints (/api/admin/*): 60 requests per minute by user_id -app.use("/api/admin/*", requireAdmin); -app.use("/api/admin/*", async (c, next) => { - const auth = await getAuthenticatedUser(c); - if (!auth) { - return c.json({ error: "Missing or invalid session" }, 401); - } - - const key = `ratelimit:admin:${auth.userId}`; - const allowed = await rateLimitWrapper.checkRateLimit(key, 60, 60000); - if (!allowed) { - return c.json({ error: "Too Many Requests" }, 429); - } - - // Stash userId in context so downstream routes can use it - c.set("userId", auth.userId); - await next(); -}); - -// --------------------------------------------------------- -// Primary session requirement for passkeys management to prevent agent mutation -app.use("/api/passkeys/*", requirePrimarySession); - -// Provisioning & Registration (Use Cases 1, 2, 3) -// --------------------------------------------------------- - -app.post("/api/admin/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, - }); -}); - -app.get("/.well-known/webauthn", (c) => { - if (!origin) { - return c.json({ origins: [] }); - } - return c.json({ origins: [origin] }); -}); - -// Start a WebAuthn registration ceremony -app.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); - - 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 }); -}); - -// Generate Ephemeral Guest Sandbox -app.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 }); -}); - -// Verify registration and create UUID/session -app.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); - } - - 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); - } -}); - -// --------------------------------------------------------- -// Authentication (Login) -// --------------------------------------------------------- - -// Start a WebAuthn authentication ceremony -app.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, - }; - } - } - } - } - } - - 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 -app.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); - - 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 }); -}); - -// --------------------------------------------------------- -// gRPC Services (Zero-Trust mTLS internal communication) -// --------------------------------------------------------- - -const connectRoutes = (router: ConnectRouter) => { - router.service(AuthService, { - async validateSession(req, context) { - try { - const spiffeId = spireWrapper.extractSpiffeIdFromCert( - context.requestHeader.get("x-peer-cert") || "", - ); - - if (!spiffeId) { - return { - valid: false, - uuid: "", - scopes: [], - error: "Validation failed", // Sanitized - }; - } - - // Check if the SPIFFE ID is a recognized application - const appRecord = await sqlWrapper - .sql`SELECT id FROM apps WHERE spiffe_id = ${spiffeId}`.then(( - res: any, - ) => res[0]); - - if (!appRecord) { - auditWrapper.auditLog(null, "session_validation_failed", null, { - reason: "Unauthorized SPIFFE ID", - }, "internal-grpc"); - return { - valid: false, - uuid: "", - scopes: [], - error: "Validation failed", // Sanitized - }; - } - - const token = req.token; - if (!token) { - return { - valid: false, - uuid: "", - scopes: [], - error: "Validation failed", - }; // Sanitized - } - - let sessionDataStr; - try { - sessionDataStr = await valkey.get(token); - } catch (err: unknown) { - console.error("Valkey error during validateSession:", err); - return { - valid: false, - uuid: "", - scopes: [], - error: "Internal server error", - }; - } - - if (!sessionDataStr) { - auditWrapper.auditLog( - null, - "session_validation_failed", - appRecord.id, - { - reason: "Session invalid or expired", - }, - "internal-grpc", - ); - return { - valid: false, - uuid: "", - scopes: [], - error: "Validation failed", // Sanitized - }; - } - - let sessionData; - try { - sessionData = JSON.parse(sessionDataStr); - } catch (err: unknown) { - console.error("JSON parse error during validateSession:", err); - return { - valid: false, - uuid: "", - scopes: [], - error: "Internal server error", // Sanitized - }; - } - - if (!sessionData || !sessionData.uuid) { - return { - valid: false, - uuid: "", - scopes: [], - error: "Internal server error", // Sanitized - }; - } - - const userId = sessionData.uuid; - - // Check RBAC grant for the user and app - const grantRecord = await sqlWrapper - .sql`SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appRecord.id}` - .then((res: any) => res[0]); - - if (!grantRecord) { - auditWrapper.auditLog( - userId, - "session_validation_failed", - appRecord.id, - { - reason: "Access denied (RBAC)", - }, - "internal-grpc", - ); - return { - valid: false, - uuid: "", - scopes: [], - error: "Validation failed", - }; // Sanitized - } - - return { - valid: true, - uuid: userId, - scopes: [grantRecord.role], - error: "", - }; - } catch (err: unknown) { - console.error("Unexpected error in validateSession:", err); - return { - valid: false, - uuid: "", - scopes: [], - error: "Internal server error", - }; - } - }, - }); -}; - -// Deno/Hono adapter using Universal Handlers -import { createConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; -const router = createConnectRouter(); -connectRoutes(router); - -// `createUniversalHandler` in ConnectRPC is actually exposed via `createConnectRouter().handlers` -// which returns an array of `UniversalHandler`. We can map those to standard web fetch. -const handlers = router.handlers; - -app.all("/auth.v1.AuthService/*", async (c) => { - const url = new URL(c.req.url); - const handler = handlers.find((h) => h.requestPath === url.pathname); - if (!handler) { - return new Response("Not Found", { status: 404 }); - } - - const uReq = universalServerRequestFromFetch(c.req.raw, {}); - const uRes = await handler(uReq); - return universalServerResponseToFetch(uRes); -}); - -// --------------------------------------------------------- -// Session & Credential Management (Authenticated APIs) -// --------------------------------------------------------- - -// --------------------------------------------------------- -// Global Admin APIs (For the Management Console) -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.get("/api/admin/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 }); -}); - -app.post("/api/admin/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 }); -}); - -app.post("/api/admin/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 -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.post("/api/admin/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); - } -}); - -app.put("/api/admin/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); - } -}); - -app.delete("/api/admin/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 -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.post("/api/admin/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); - } -}); - -app.put("/api/admin/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); - } -}); - -app.delete("/api/admin/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 -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.get("/api/admin/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 }); -}); - -app.delete("/api/admin/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 -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.post("/api/admin/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 }); -}); - -app.delete("/api/admin/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 -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.post("/api/admin/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); - } -}); - -app.delete("/api/admin/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 -// --------------------------------------------------------- - -app.post("/api/admin/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); - } -}); - -app.delete("/api/admin/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) -// --------------------------------------------------------- - -app.get("/api/admin/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 }); -}); - -app.delete("/api/admin/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 }); -}); - -app.delete("/api/admin/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 }); -}); - -app.delete("/api/admin/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) -// --------------------------------------------------------- - -app.post("/api/admin/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 }); -}); - -app.route("/api/recovery", recoveryApp); - -app.get("/api/admin/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 }); -}); - -// --------------------------------------------------------- -// Authenticated Passkey Registration (Adding a new device) -// --------------------------------------------------------- - -app.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); - - 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 }); -}); - -app.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); - } - - 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 -app.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 -app.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 }); -}); - -// --------------------------------------------------------- -// Lifecycle & Revocation -// --------------------------------------------------------- - -// Revoke a session manually (used by layout logout) -app.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 }); -}); - -if (import.meta.main) { const PORT = parseInt(Deno.env.get("PORT") || "8000"); // Fail fast if connection to Valkey fails during startup diff --git a/server/middleware.ts b/server/middleware.ts new file mode 100644 index 0000000..cd24b71 --- /dev/null +++ b/server/middleware.ts @@ -0,0 +1,62 @@ +import type { Context } from "jsr:@hono/hono@4"; +import { rateLimitWrapper } from "./ratelimit.ts"; +import { getAuthenticatedUser } from "./auth-session.ts"; + +// Middleware helper to get IP address +export function getClientIp(c: Context): string { + // Check for X-Real-IP first + const realIp = c.req.header("x-real-ip"); + if (realIp) { + return realIp.trim(); + } + + // Fallback to X-Forwarded-For, bounded to prevent memory exhaustion + let forwardedFor = c.req.header("x-forwarded-for"); + if (forwardedFor) { + // Truncate to a max of 256 characters + if (forwardedFor.length > 256) { + forwardedFor = forwardedFor.substring(0, 256); + } + const parts = forwardedFor.split(","); + // Extract the last untrusted hop (right-most IP) + return parts[parts.length - 1].trim(); + } + // Fallback (might not be accurate behind proxy without X-Forwarded-For) + return "unknown-ip"; +} + +export const publicRateLimiter = async ( + c: Context, + next: () => Promise, +) => { + const ip = getClientIp(c); + const key = `ratelimit:public:${ip}`; + const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000); + if (!allowed) { + return c.json({ error: "Too Many Requests" }, 429); + } + await next(); +}; + +export const adminRateLimiter = async ( + c: Context, + next: () => Promise, +) => { + const auth = await getAuthenticatedUser(c); + if (!auth) { + return c.json({ error: "Missing or invalid session" }, 401); + } + + const key = `ratelimit:admin:${auth.userId}`; + const allowed = await rateLimitWrapper.checkRateLimit(key, 60, 60000); + if (!allowed) { + return c.json({ error: "Too Many Requests" }, 429); + } + + // Stash userId in context so downstream routes can use it + c.set("userId", auth.userId); + await next(); +}; + +// Also apply requireAdmin logic on the admin route by chaining it or exporting it. +// We'll export requireAdmin here or use it directly in the admin routes. diff --git a/server/routes/admin.ts b/server/routes/admin.ts new file mode 100644 index 0000000..d47b240 --- /dev/null +++ b/server/routes/admin.ts @@ -0,0 +1,865 @@ +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"; + +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 }); +}); diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 0000000..6e48aff --- /dev/null +++ b/server/routes/auth.ts @@ -0,0 +1,928 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { + decodeBase64Url, + encodeBase64Url, +} from "jsr:@std/encoding@1/base64url"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + MetadataService, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "jsr:@simplewebauthn/server@13"; +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, +} from "jsr:@simplewebauthn/server@13"; + +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { auditWrapper } from "../audit.ts"; +import { + extractAllSessionIds, + getAuthenticatedUser, + requirePrimarySession, +} from "../auth-session.ts"; +import { getClientIp, publicRateLimiter } from "../middleware.ts"; + +export const authRoutes = new Hono(); + +const rpName = "Auth-Yes Identity Provider"; +const rpID = Deno.env.get("RP_ID") || + (import.meta.main ? undefined : "localhost"); +const origin = Deno.env.get("ORIGIN") || + (import.meta.main ? undefined : "http://localhost"); +const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true"; + +export function getCookieDomain(customRpId?: string): string | undefined { + const envDomain = Deno.env.get("COOKIE_DOMAIN"); + if (envDomain) { + return envDomain.startsWith(".") ? envDomain : `.${envDomain}`; + } + const targetId = customRpId || Deno.env.get("RP_ID") || ""; + if (!targetId || !targetId.includes(".") || targetId === "localhost") { + return undefined; + } + const parts = targetId.split(".").filter(Boolean); + if (parts.length >= 2) { + return `.${parts.slice(-2).join(".")}`; + } + return `.${targetId}`; +} + +function generateSessionId() { + return crypto.randomUUID(); +} + +authRoutes.use("/api/register/*", publicRateLimiter); +authRoutes.use("/api/login/*", publicRateLimiter); +authRoutes.use("/api/passkeys/*", requirePrimarySession); + +authRoutes.get("/.well-known/webauthn", (c) => { + if (!origin) { + return c.json({ origins: [] }); + } + return c.json({ origins: [origin] }); +}); + +// Start a WebAuthn registration ceremony +authRoutes.post("/api/register/challenge", async (c) => { + const { username, inviteCode } = await c.req.json(); + + if (!username || !inviteCode) { + return c.json({ error: "Username and inviteCode required" }, 400); + } + + // Validate invite code early (checks expiration and max_uses bounds) + const invite = await sqlWrapper + .sql`SELECT id, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` + .then((res: any) => res[0]); + if (!invite) { + return c.json( + { error: "Invalid, expired, or fully claimed invite code" }, + 400, + ); + } + + // Prevent hijacking an existing user's account if they already exist + const existingUser = await sqlWrapper + .sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => + res[0] + ); + if (existingUser) { + return c.json({ error: "Username already exists" }, 409); + } + + const newUserId = crypto.randomUUID(); + const userIdBytes = new TextEncoder().encode(newUserId); + + if (!rpID) throw new Error("rpID is missing"); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: username, + userID: userIdBytes, + attestationType: "direct", + authenticatorSelection: { + residentKey: "required", + requireResidentKey: true, + userVerification: "preferred", + }, + timeout: 60000, + extensions: { + ["prf" as string]: {}, + } as any, + }); + + setCookie(c, "expected_registration_challenge", options.challenge, { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 300, + }); + + setCookie(c, "registration_user_id", newUserId, { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 300, + }); + + return c.json({ options, username }); +}); + +// Verify registration and create UUID/session +authRoutes.post("/api/register/verify", async (c) => { + try { + const { response, username, inviteCode, upgrade_session } = await c.req + .json(); + + if (!inviteCode && !upgrade_session) { + return c.json({ error: "inviteCode or upgrade_session required" }, 400); + } + + const expectedChallenge = getCookie(c, "expected_registration_challenge"); + const registrationUserId = getCookie(c, "registration_user_id"); + if (!expectedChallenge || !registrationUserId) { + return c.json({ + error: "Missing or expired registration challenge/user ID", + }, 400); + } + + // Prevent race condition account hijacking + let user = await sqlWrapper + .sql`SELECT id FROM users WHERE username = ${username}` + .then( + (res: any) => res[0], + ); + if (user) { + return c.json({ error: "Username already exists" }, 409); + } + + if (!origin || !rpID) throw new Error("Missing origin or rpID"); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: response as RegistrationResponseJSON, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + } catch (error: any) { + return c.json({ error: error.message }, 400); + } + + const { verified, registrationInfo } = verification; + if (!verified || !registrationInfo) { + return c.json({ error: "Verification failed" }, 400); + } + + // Enterprise Allow-List Verification + const allowlistCount = await sqlWrapper + .sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) => + Number(res[0].count) + ); + if (allowlistCount > 0 && registrationInfo.aaguid) { + const isAllowed = await sqlWrapper + .sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}` + .then((res: any) => res[0]); + if (!isAllowed) { + auditWrapper.auditLog(null, "failed_attestation_allowlist", null, { + aaguid: registrationInfo.aaguid, + }, getClientIp(c)); + return c.json({ + error: "Authenticator AAGUID is not in the enterprise allow-list.", + }, 403); + } + } + + // Optional Strict Hardware Attestation (e.g. YubiKey-only) + if (requireHardwareToken) { + if ( + !registrationInfo.aaguid || + registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000" + ) { + auditWrapper.auditLog(null, "registration_failed_attestation", null, { + username, + reason: "No AAGUID provided", + }, getClientIp(c)); + return c.json({ + error: + "Hardware attestation failed: No AAGUID provided. Only certified hardware security keys are permitted.", + }, 403); + } + + let mdsStatement; + try { + mdsStatement = await MetadataService.getStatement( + registrationInfo.aaguid, + ); + } catch (mdsError) { + console.warn("[Auth API] MetadataService lookup error:", mdsError); + } + + if (!mdsStatement) { + auditWrapper.auditLog(null, "registration_failed_attestation", null, { + username, + aaguid: registrationInfo.aaguid, + reason: "AAGUID not found in MDS3", + }, getClientIp(c)); + return c.json({ + error: + `Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob. Only certified hardware security keys are permitted.`, + }, 403); + } + + // @ts-ignore: TypeScript definition might be out of date for FIDO MDS3 (1) + if (mdsStatement.keyProtection?.includes(0x0001)) { + auditWrapper.auditLog(null, "registration_failed_attestation", null, { + username, + aaguid: registrationInfo.aaguid, + reason: "Software passkey detected", + }, getClientIp(c)); + return c.json({ + error: + "Hardware attestation failed: Authenticator is flagged as a software-based passkey. Only certified hardware security keys are permitted.", + }, 403); + } + } + const credentialID = registrationInfo.credential.id; + const credentialPublicKey = registrationInfo.credential.publicKey; + const counter = registrationInfo.credential.counter; + + const base64CredentialID = typeof credentialID === "string" + ? credentialID + : encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer)); + const base64PublicKey = encodeBase64Url( + new Uint8Array(credentialPublicKey as unknown as ArrayBuffer), + ); + + const prfEnabled = + (response.clientExtensionResults as any)?.prf?.enabled === true; + let prfSalt = null; + if (prfEnabled) { + const saltBytes = crypto.getRandomValues(new Uint8Array(32)); + prfSalt = encodeBase64Url(saltBytes); + } + + if (upgrade_session) { + // Ephemeral Guest Sandbox in-flight promotion + const sessionDataStr = await valkey.get(upgrade_session); + if (!sessionDataStr) { + return c.json({ error: "Invalid or expired guest session" }, 400); + } + const sessionData = JSON.parse(sessionDataStr); + if ( + !sessionData || !sessionData.uuid || + sessionData.account_status !== "guest" + ) { + return c.json({ error: "Invalid guest session state" }, 400); + } + + const guestUuid = sessionData.uuid; + + const insertRes = await sqlWrapper + .sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`; + user = insertRes[0]; + + await sqlWrapper.sql` + INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) + VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) + `; + + // Promote Valkey session + await valkey.setex( + upgrade_session, + 28800, // Upgrade TTL to 8 hours + JSON.stringify({ uuid: guestUuid, username, account_status: "active" }), + ); + + // Register session in PostgreSQL + const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000); + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, expires_at) + VALUES (${upgrade_session}, ${user.id}, ${expiresAt}) + `; + } else { + // Standard Registration Flow + const invite = await sqlWrapper + .sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()` + .then((res: any) => res[0]); + if (!invite) { + return c.json( + { error: "Invalid, expired, or fully claimed invite code" }, + 400, + ); + } + + const initialStatus = invite.auto_activate === false + ? "pending" + : "active"; + const insertRes = await sqlWrapper + .sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`; + user = insertRes[0]; + + await sqlWrapper.sql` + INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt) + VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt}) + `; + + await sqlWrapper.sql` + UPDATE invites + SET uses_count = uses_count + 1, + used_at = NOW(), + used_by = ${user.id} + WHERE id = ${invite.id} + `; + + await sqlWrapper.sql` + INSERT INTO invite_redemptions (invite_id, user_id) + VALUES (${invite.id}, ${user.id}) + `; + + if (invite.app_id) { + await sqlWrapper.sql` + INSERT INTO grants (user_id, app_id, role) + VALUES (${user.id}, ${invite.app_id}, ${invite.role}) + `; + } else if (invite.role === "admin") { + const adminApp = await sqlWrapper + .sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'` + .then((res: any) => res[0]); + if (adminApp) { + await sqlWrapper.sql` + INSERT INTO grants (user_id, app_id, role) + VALUES (${user.id}, ${adminApp.id}, 'admin') + ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin' + `; + } + } + } + + auditWrapper.auditLog( + user.id, + "user_registered", + null, + { username, inviteCode }, + getClientIp(c), + ); + + // Set response cookie to clear out the challenge + setCookie(c, "expected_registration_challenge", "", { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 0, + }); + + setCookie(c, "registration_user_id", "", { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 0, + }); + + const cookieDomain = getCookieDomain(rpID); + if (cookieDomain) { + deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" }); + } + deleteCookie(c, "session_id", { path: "/" }); + + return c.json({ success: true }); + } catch (error: any) { + console.error( + "[Auth API] Uncaught Exception in /api/register/verify:", + error, + ); + return c.json({ error: error.message || "Internal server error" }, 500); + } +}); + +// Start a WebAuthn authentication ceremony +authRoutes.post("/api/login/challenge", async (c) => { + let body; + try { + body = await c.req.json(); + } catch (_err) { + body = {}; + } + const username = body.username; + let extensions: any = undefined; + let allowCredentials: any[] | undefined = undefined; + + if (username) { + const user = await sqlWrapper + .sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) => + res[0] + ); + if (user) { + const passkeys = await sqlWrapper + .sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`; + + if (passkeys.length > 0) { + allowCredentials = passkeys.map((pk: any) => ({ + id: pk.credential_id, + type: "public-key", + })); + + const prfPasskeys = passkeys.filter((pk: any) => + pk.prf_enabled && pk.prf_salt + ); + if (prfPasskeys.length > 0) { + extensions = { + ["prf" as string]: { evalByCredential: {} }, + }; + for (const pk of prfPasskeys) { + const saltBytes = decodeBase64Url(pk.prf_salt); + extensions["prf"]["evalByCredential"][pk.credential_id] = { + first: saltBytes, + }; + } + } + } + } + } + + if (!rpID) throw new Error("rpID is missing"); + + const options = await generateAuthenticationOptions({ + rpID, + userVerification: "preferred", + timeout: 60000, + allowCredentials, + extensions, + }); + + setCookie(c, "expected_authentication_challenge", options.challenge, { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 300, + }); + + return c.json({ options }); +}); + +// Verify login and issue session +authRoutes.post("/api/login/verify", async (c) => { + const { response } = await c.req.json(); + + const expectedChallenge = getCookie(c, "expected_authentication_challenge"); + if (!expectedChallenge) { + return c.json( + { error: "Missing or expired authentication challenge" }, + 400, + ); + } + + const base64CredentialID = response.id; + + const passkey = await sqlWrapper + .sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}` + .then((res: any) => res[0]); + if (!passkey) { + return c.json({ + error: "Passkey not found. Please register your passkey first.", + }, 404); + } + + const user = await sqlWrapper + .sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}` + .then((res: any) => res[0]); + if (!user) { + return c.json({ error: "User not found" }, 404); + } + + const userId = user.id; + + if (user.account_status !== "active") { + auditWrapper.auditLog( + userId, + "login_failed", + null, + { reason: `Account status is ${user.account_status}` }, + getClientIp(c), + ); + return c.json({ + error: "Account is not active. Please contact an administrator.", + }, 403); + } + + const publicKeyBytes = decodeBase64Url(passkey.public_key); + + if (!origin || !rpID) throw new Error("Missing origin or rpID"); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: response as AuthenticationResponseJSON, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + credential: { + id: passkey.credential_id, + publicKey: publicKeyBytes, + counter: Number(passkey.counter), + }, + }); + } catch (error: any) { + return c.json({ error: error.message }, 400); + } + + const { verified, authenticationInfo } = verification; + if (!verified || !authenticationInfo) { + auditWrapper.auditLog( + userId, + "login_failed", + null, + { reason: "verification failed" }, + getClientIp(c), + ); + return c.json({ error: "Verification failed" }, 400); + } + + await sqlWrapper + .sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`; + + const sessionId = generateSessionId(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + // Persistence in PostgreSQL + await sqlWrapper + .sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`; + + // Write session to Valkey with TTL matching expiresAt + const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000); + try { + const sessionData = JSON.stringify({ + uuid: user.id, + username: user.username, + }); + await valkey.setex(sessionId, ttlSeconds, sessionData); + } catch (_err: unknown) { + // If Valkey fails, log and fail closed for security + auditWrapper.auditLog( + user.id, + "login_failed", + null, + { reason: "Cache write failure" }, + getClientIp(c), + ); + return c.json({ error: "Internal server error" }, 500); + } + + const oldSessionIds = extractAllSessionIds(c); + if (oldSessionIds.length > 0) { + for (const old of oldSessionIds) { + try { + await valkey.del(old); + await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`; + } catch (_e) {} + } + } + + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + expires: expiresAt, + }); + + setCookie(c, "expected_authentication_challenge", "", { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 0, + }); + + auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c)); + + return c.json({ success: true }); +}); + +// Generate Ephemeral Guest Sandbox +authRoutes.post("/api/guests/sandbox", async (c) => { + const guestUuid = crypto.randomUUID(); + const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))); + const username = `guest-${guestUuid.substring(0, 8)}`; + + await valkey.setex( + sessionId, + 7200, // 2-hour TTL + JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }), + ); + + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 7200, + }); + + return c.json({ success: true, sessionId, guestUuid }); +}); + +// Revoke a session manually (used by layout logout) +authRoutes.post("/api/revoke", async (c) => { + // Try Authorization header first (SDK) + let token = ""; + const authHeader = c.req.header("Authorization"); + if (authHeader && authHeader.startsWith("Bearer ")) { + token = authHeader.split(" ")[1]; + } + + if (!token) { + // Fallback to session cookie (Web UI) + const tokens = extractAllSessionIds(c); + if (tokens.length === 0) { + return c.json({ error: "Missing or invalid token" }, 401); + } + + for (const t of tokens) { + try { + await valkey.del(t); + await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`; + } catch (_e) {} + } + } else { + // SDK Token Path + try { + await valkey.del(token); + await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`; + } catch (_e) {} + } + + const cookieDomain = getCookieDomain(rpID); + + if (cookieDomain) { + deleteCookie(c, "session_id", { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + } + deleteCookie(c, "session_id", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + + return c.json({ success: true }); +}); + +// --------------------------------------------------------- +// Authenticated Passkey Registration (Adding a new device) +// --------------------------------------------------------- + +authRoutes.post("/api/passkeys/register/challenge", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const user = await sqlWrapper + .sql`SELECT username FROM users WHERE id = ${auth.userId}` + .then((res: any) => res[0]); + if (!user) return c.json({ error: "User not found" }, 404); + + const userIdBytes = new TextEncoder().encode(auth.userId); + + if (!rpID) throw new Error("rpID is missing"); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: user.username, + userID: userIdBytes, + attestationType: "direct", + authenticatorSelection: { + residentKey: "required", + requireResidentKey: true, + userVerification: "preferred", + }, + timeout: 60000, + }); + + setCookie(c, "expected_add_passkey_challenge", options.challenge, { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 300, + }); + + return c.json({ options }); +}); + +authRoutes.post("/api/passkeys/register/verify", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { response } = await c.req.json(); + const expectedChallenge = getCookie(c, "expected_add_passkey_challenge"); + + if (!expectedChallenge) { + return c.json({ error: "Missing or expired registration challenge" }, 400); + } + + if (!origin || !rpID) throw new Error("Missing origin or rpID"); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: response as RegistrationResponseJSON, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + } catch (error: any) { + return c.json({ error: error.message }, 400); + } + + const { verified, registrationInfo } = verification; + if (!verified || !registrationInfo) { + return c.json({ error: "Verification failed" }, 400); + } + + // Enterprise Allow-List Verification + const allowlistCount = await sqlWrapper + .sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) => + Number(res[0].count) + ); + if (allowlistCount > 0 && registrationInfo.aaguid) { + const isAllowed = await sqlWrapper + .sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}` + .then((res: any) => res[0]); + if (!isAllowed) { + auditWrapper.auditLog(auth.userId, "failed_attestation_allowlist", null, { + aaguid: registrationInfo.aaguid, + }, getClientIp(c)); + return c.json({ + error: "Authenticator AAGUID is not in the enterprise allow-list.", + }, 403); + } + } + + // Optional Strict Hardware Attestation + if (requireHardwareToken) { + if ( + !registrationInfo.aaguid || + registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000" + ) { + auditWrapper.auditLog( + auth.userId, + "add_passkey_failed_attestation", + null, + { + reason: "No AAGUID provided", + }, + getClientIp(c), + ); + return c.json( + { error: "Hardware attestation failed: No AAGUID provided." }, + 403, + ); + } + + let mdsStatement; + try { + mdsStatement = await MetadataService.getStatement( + registrationInfo.aaguid, + ); + } catch (mdsError) { + console.warn("[Auth API] MetadataService lookup error:", mdsError); + } + + if (!mdsStatement) { + auditWrapper.auditLog( + auth.userId, + "add_passkey_failed_attestation", + null, + { + aaguid: registrationInfo.aaguid, + reason: "AAGUID not found in MDS3", + }, + getClientIp(c), + ); + return c.json({ + error: + `Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob.`, + }, 403); + } + + // @ts-ignore: FIDO MDS3 missing type + if (mdsStatement.keyProtection?.includes(0x0001)) { + auditWrapper.auditLog( + auth.userId, + "add_passkey_failed_attestation", + null, + { + aaguid: registrationInfo.aaguid, + reason: "Software passkey detected", + }, + getClientIp(c), + ); + return c.json({ + error: + "Hardware attestation failed: Authenticator is flagged as a software-based passkey.", + }, 403); + } + } + const credentialID = registrationInfo.credential.id; + const credentialPublicKey = registrationInfo.credential.publicKey; + const counter = registrationInfo.credential.counter; + + const base64CredentialID = typeof credentialID === "string" + ? credentialID + : encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer)); + const base64PublicKey = encodeBase64Url( + new Uint8Array(credentialPublicKey as unknown as ArrayBuffer), + ); + + await sqlWrapper.sql` + INSERT INTO passkeys (user_id, credential_id, public_key, counter) + VALUES (${auth.userId}, ${base64CredentialID}, ${base64PublicKey}, ${counter}) + `; + + auditWrapper.auditLog( + auth.userId, + "passkey_added", + null, + null, + getClientIp(c), + ); + + setCookie(c, "expected_add_passkey_challenge", "", { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 0, + }); + + return c.json({ success: true }); +}); + +// Get user's registered passkeys +authRoutes.get("/api/passkeys", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const passkeys = await sqlWrapper.sql` + SELECT id, counter + FROM passkeys + WHERE user_id = ${auth.userId} + `; + + return c.json({ passkeys }); +}); + +// Revoke a specific passkey +authRoutes.delete("/api/passkeys/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetPasskeyId = c.req.param("id"); + + // Verify the passkey belongs to the user + const passkey = await sqlWrapper.sql` + SELECT id FROM passkeys WHERE id = ${targetPasskeyId} AND user_id = ${auth.userId} + `.then((res: any) => res[0]); + + if (!passkey) { + return c.json({ error: "Passkey not found or access denied" }, 404); + } + + // Prevent deleting the very last passkey to avoid locking out the user + const passkeyCount = await sqlWrapper.sql` + SELECT count(*) as count FROM passkeys WHERE user_id = ${auth.userId} + `.then((res: any) => Number(res[0].count)); + + if (passkeyCount <= 1) { + return c.json({ + error: "Cannot delete your last passkey. Register another one first.", + }, 400); + } + + await sqlWrapper.sql`DELETE FROM passkeys WHERE id = ${targetPasskeyId}`; + + auditWrapper.auditLog(auth.userId, "passkey_revoked", null, { + revoked_passkey_id: targetPasskeyId, + }, getClientIp(c)); + + return c.json({ success: true }); +}); diff --git a/server/rpc.ts b/server/rpc.ts new file mode 100644 index 0000000..7127eb5 --- /dev/null +++ b/server/rpc.ts @@ -0,0 +1,173 @@ +import { AuthService } from "../sdk/gen/auth_connect.ts"; +import { + universalServerRequestFromFetch, + universalServerResponseToFetch, +} from "npm:@connectrpc/connect@^1.4.0/protocol"; +import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; +import { createConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; +import type { Hono } from "jsr:@hono/hono@4"; + +import { spireWrapper } from "./spire_ffi.ts"; +import { sqlWrapper } from "./db.ts"; +import { valkey } from "./valkey.ts"; +import { auditWrapper } from "./audit.ts"; + +export const connectRoutes = (router: ConnectRouter) => { + router.service(AuthService, { + async validateSession(req, context) { + try { + const spiffeId = spireWrapper.extractSpiffeIdFromCert( + context.requestHeader.get("x-peer-cert") || "", + ); + + if (!spiffeId) { + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", // Sanitized + }; + } + + // Check if the SPIFFE ID is a recognized application + const appRecord = await sqlWrapper + .sql`SELECT id FROM apps WHERE spiffe_id = ${spiffeId}`.then(( + res: any, + ) => res[0]); + + if (!appRecord) { + auditWrapper.auditLog(null, "session_validation_failed", null, { + reason: "Unauthorized SPIFFE ID", + }, "internal-grpc"); + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", // Sanitized + }; + } + + const token = req.token; + if (!token) { + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; // Sanitized + } + + let sessionDataStr; + try { + sessionDataStr = await valkey.get(token); + } catch (err: unknown) { + console.error("Valkey error during validateSession:", err); + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", + }; + } + + if (!sessionDataStr) { + auditWrapper.auditLog( + null, + "session_validation_failed", + appRecord.id, + { + reason: "Session invalid or expired", + }, + "internal-grpc", + ); + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", // Sanitized + }; + } + + let sessionData; + try { + sessionData = JSON.parse(sessionDataStr); + } catch (err: unknown) { + console.error("JSON parse error during validateSession:", err); + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", // Sanitized + }; + } + + if (!sessionData || !sessionData.uuid) { + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", // Sanitized + }; + } + + const userId = sessionData.uuid; + + // Check RBAC grant for the user and app + const grantRecord = await sqlWrapper + .sql`SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appRecord.id}` + .then((res: any) => res[0]); + + if (!grantRecord) { + auditWrapper.auditLog( + userId, + "session_validation_failed", + appRecord.id, + { + reason: "Access denied (RBAC)", + }, + "internal-grpc", + ); + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; // Sanitized + } + + return { + valid: true, + uuid: userId, + scopes: [grantRecord.role], + error: "", + }; + } catch (err: unknown) { + console.error("Unexpected error in validateSession:", err); + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", + }; + } + }, + }); +}; + +export const startConnectRpcServer = (app: Hono) => { + const router = createConnectRouter(); + connectRoutes(router); + const handlers = router.handlers; + + app.all("/auth.v1.AuthService/*", async (c) => { + const url = new URL(c.req.url); + const handler = handlers.find((h) => h.requestPath === url.pathname); + if (!handler) { + return new Response("Not Found", { status: 404 }); + } + + const uReq = universalServerRequestFromFetch(c.req.raw, {}); + const uRes = await handler(uReq); + return universalServerResponseToFetch(uRes); + }); +}; diff --git a/tasks/new/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph3.md b/tasks/complete/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph3.md similarity index 100% rename from tasks/new/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph3.md rename to tasks/complete/2026-0825.01.jul.story.arch.monolith-decomposition-roadmap-1845.ph3.md