diff --git a/server/auth-session.ts b/server/auth-session.ts index 918a551..f0814be 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -401,3 +401,24 @@ export function isSafeRedirectUrl( } return false; } + +/** + * Extracts the real client IP from X-Real-IP or X-Forwarded-For headers. + */ +export function getClientIp(c: Context): string { + const realIp = c.req.header("x-real-ip"); + if (realIp) { + return realIp.trim(); + } + + let forwardedFor = c.req.header("x-forwarded-for"); + if (forwardedFor) { + if (forwardedFor.length > 256) { + forwardedFor = forwardedFor.substring(0, 256); + } + const parts = forwardedFor.split(","); + return parts[parts.length - 1].trim(); + } + + return "127.0.0.1"; +} diff --git a/server/main.ts b/server/main.ts index 26ded03..169f6e7 100644 --- a/server/main.ts +++ b/server/main.ts @@ -21,7 +21,6 @@ import { decodeBase64Url, encodeBase64Url, } from "jsr:@std/encoding@1/base64url"; -import { encodeHex } from "jsr:@std/encoding@1/hex"; import { MetadataService } from "jsr:@simplewebauthn/server@13"; import { initDb, sqlWrapper } from "./db.ts"; import { pingValkey, valkey } from "./valkey.ts"; @@ -34,9 +33,12 @@ import { 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 { uiApp } from "../ui/mod.ts"; -import { EventJoinPage } from "../ui/components/EventJoinPage.tsx"; -import { EventSplashPage } from "../ui/components/EventSplashPage.tsx"; +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"; type Variables = { userId: string; @@ -46,6 +48,12 @@ export const app: Hono<{ Variables: Variables }> = new Hono< { Variables: Variables } >(); +// Mount Sub-routers +app.route("/pass", passRoutes); +app.route("/", eventRoutes); +app.route("/", sessionRoutes); +app.route("/", forwardAuthRoutes); + // Mount UI Routes app.route("/", uiApp); @@ -185,399 +193,6 @@ app.use("/api/admin/*", async (c, next) => { }); // --------------------------------------------------------- -// Ephemeral 1-Click Magic Link Redemption (/pass) -// --------------------------------------------------------- - -app.get("/pass", async (c) => { - const token = c.req.query("token"); - if (!token) { - return c.redirect("/login?error=invalid_or_expired_pass", 302); - } - - // 1. Validate against Valkey, fallback to PostgreSQL - let sessionDataStr = null; - try { - sessionDataStr = await valkey.get(token); - } catch (_err) {} - - let sessionInfo: any = null; - if (sessionDataStr) { - try { - sessionInfo = JSON.parse(sessionDataStr); - } catch (_err) {} - } - - let expiresAtDate: Date | null = null; - let customScopes: string[] = []; - - if (!sessionInfo || !sessionInfo.uuid) { - try { - const nowIso = new Date().toISOString(); - const session = await sqlWrapper.sql` - SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.id = ${token} AND s.expires_at > ${nowIso} - `.then((res: any) => res[0]); - - if (!session) { - return c.redirect("/login?error=invalid_or_expired_pass", 302); - } - - sessionInfo = { - uuid: session.user_id, - username: session.username, - label: session.label, - isAgent: session.is_agent, - customScopes: session.custom_scopes, - }; - expiresAtDate = new Date(session.expires_at); - customScopes = session.custom_scopes || []; - - try { - const ttlSeconds = Math.max( - 1, - Math.floor((expiresAtDate.getTime() - Date.now()) / 1000), - ); - await valkey.setex(token, ttlSeconds, JSON.stringify(sessionInfo)); - } catch (_e) {} - } catch (_err) { - return c.redirect("/login?error=invalid_or_expired_pass", 302); - } - } else { - try { - const ttl = await valkey.ttl(token); - if (ttl <= 0) { - return c.redirect("/login?error=invalid_or_expired_pass", 302); - } - expiresAtDate = new Date(Date.now() + ttl * 1000); - customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes || []; - } catch (_err) { - return c.redirect("/login?error=invalid_or_expired_pass", 302); - } - } - - if (!sessionInfo || !expiresAtDate) { - return c.redirect("/login?error=invalid_or_expired_pass", 302); - } - - // 2. Cookie Scoping - deleteCookie(c, "session_id", { path: "/" }); - - const cookieDomain = getCookieDomain(rpID); - const ttlSeconds = Math.max( - 1, - Math.floor((expiresAtDate.getTime() - Date.now()) / 1000), - ); - setCookie(c, "session_id", token, { - path: "/", - domain: cookieDomain, - httpOnly: true, - secure: true, - sameSite: "Lax", - maxAge: ttlSeconds, - }); - - // 3. Redirect URL Resolution - let targetDomain = null; - if (Array.isArray(customScopes)) { - const appScope = customScopes.find((s: string) => - typeof s === "string" && s.startsWith("app:") - ); - if (appScope) { - const appName = appScope.substring(4); - try { - const appRecord = await sqlWrapper.sql` - SELECT domain FROM apps WHERE name = ${appName} - `.then((res: any) => res[0]); - if (appRecord && appRecord.domain) { - targetDomain = appRecord.domain; - } - } catch (_err) {} - } - } - - if (targetDomain) { - return c.redirect(`https://${targetDomain}`, 302); - } else { - return c.redirect("/dashboard", 302); - } -}); - -// --------------------------------------------------------- -// Multi-Claim Event Passes & Short Code Join Portal -// --------------------------------------------------------- - -app.post("/api/events", async (c) => { - const user = await getAuthenticatedUser(c); - if (!user) return c.json({ error: "Unauthorized" }, 401); - - const body = await c.req.json().catch(() => ({})); - const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } = - body; - let { slug, pinCode } = body; - - if (!name || typeof name !== "string") { - return c.json({ error: "Event name is required" }, 400); - } - - if (!slug) { - slug = - name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + - "-" + Math.random().toString(36).substring(2, 6); - } - if (!pinCode) { - const randPin = Math.floor(100000 + Math.random() * 900000).toString(); - pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); - } - - try { - const expiresAt = new Date( - Date.now() + Number(lifespanHours) * 3600 * 1000, - ); - const result = await sqlWrapper.sql` - INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at) - VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${ - Number(maxSeats) - }, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt}) - RETURNING * - `; - return c.json({ success: true, event: result[0] }); - } catch (e: any) { - console.error("[Events] Failed to create event pass:", e); - return c.json({ error: "Failed to create event pass" }, 500); - } -}); - -app.post("/api/join", async (c) => { - let code = ""; - if ( - c.req.header("content-type")?.includes("application/x-www-form-urlencoded") - ) { - const fd = await c.req.formData(); - code = (fd.get("code") as string) || ""; - } else { - const body = await c.req.json().catch(() => ({})); - code = body.code || ""; - } - - if (!code || typeof code !== "string") { - return c.json({ error: "Event code or PIN is required" }, 400); - } - code = code.trim(); - - try { - const result = await sqlWrapper.sql` - UPDATE event_passes - SET seats_claimed = seats_claimed + 1 - WHERE (slug = ${code} OR pin_code = ${code}) - AND is_active = TRUE - AND (expires_at IS NULL OR expires_at > NOW()) - AND (max_seats = 0 OR seats_claimed < max_seats) - RETURNING * - `; - - if (!result || result.length === 0) { - return c.json({ - error: "Invalid event code or workshop capacity reached", - }, 404); - } - - const event = result[0]; - const guestUuid = crypto.randomUUID(); - const username = `guest_${event.slug}_${event.seats_claimed}`; - - await sqlWrapper.sql` - INSERT INTO users (id, username, display_name, account_status) - VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') - ON CONFLICT DO NOTHING - `; - - const randomBytes = crypto.getRandomValues(new Uint8Array(32)); - const sessionId = `ay_sess_${encodeHex(randomBytes)}`; - const label = `${event.name} Seat #${event.seats_claimed}`; - const ttl = (Number(event.lifespan_hours) || 3) * 3600; - - let customScopes = ["guest", "trial"]; - let appDomain = ""; - - if (event.app_id) { - const apps = await sqlWrapper - .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; - if (apps.length > 0) { - customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; - appDomain = apps[0].domain || ""; - } - } - - const expiresAt = new Date(Date.now() + ttl * 1000); - - await sqlWrapper.sql` - INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) - VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) - `; - - await valkey.setex( - sessionId, - ttl, - JSON.stringify({ - uuid: guestUuid, - username, - account_status: "guest", - customScopes, - }), - ); - - deleteCookie(c, "session_id", { path: "/" }); - const cookieDomain = getCookieDomain(rpID); - - setCookie(c, "session_id", sessionId, { - domain: cookieDomain, - path: "/", - httpOnly: true, - secure: true, - sameSite: "Lax", - maxAge: ttl, - }); - - const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; - - if ( - c.req.header("accept")?.includes("text/html") && - !c.req.header("accept")?.includes("application/json") - ) { - return c.redirect(redirectUrl, 302); - } - - return c.json({ - success: true, - sessionId, - token: sessionId, - guestUuid, - username, - redirectUrl, - }); - } catch (e: any) { - console.error("[Events] Failed to join event:", e); - return c.json({ error: "Failed to join event" }, 500); - } -}); - -app.get("/join/:slug", async (c) => { - const slug = c.req.param("slug"); - const format = c.req.query("format") || "html"; - - try { - const result = await sqlWrapper.sql` - UPDATE event_passes - SET seats_claimed = seats_claimed + 1 - WHERE slug = ${slug} - AND is_active = TRUE - AND (expires_at IS NULL OR expires_at > NOW()) - AND (max_seats = 0 OR seats_claimed < max_seats) - RETURNING * - `; - - if (!result || result.length === 0) { - if (format === "env" || format === "json") { - return c.text("Invalid slug or workshop capacity reached", 404); - } - return c.redirect("/join?error=not_found", 302); - } - - const event = result[0]; - const guestUuid = crypto.randomUUID(); - const username = `guest_${event.slug}_${event.seats_claimed}`; - - await sqlWrapper.sql` - INSERT INTO users (id, username, display_name, account_status) - VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') - ON CONFLICT DO NOTHING - `; - - const randomBytes = crypto.getRandomValues(new Uint8Array(32)); - const sessionId = `ay_sess_${encodeHex(randomBytes)}`; - const label = `${event.name} Seat #${event.seats_claimed}`; - const ttl = (Number(event.lifespan_hours) || 3) * 3600; - - let customScopes = ["guest", "trial"]; - if (event.app_id) { - const apps = await sqlWrapper - .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; - if (apps.length > 0) { - customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; - } - } - - const expiresAt = new Date(Date.now() + ttl * 1000); - - await sqlWrapper.sql` - INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) - VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) - `; - - await valkey.setex( - sessionId, - ttl, - JSON.stringify({ - uuid: guestUuid, - username, - account_status: "guest", - customScopes, - }), - ); - - if (format === "env") { - return c.text( - `export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`, - ); - } else if (format === "json") { - return c.json({ - success: true, - token: sessionId, - username, - expiresAt: expiresAt.toISOString(), - }); - } - - deleteCookie(c, "session_id", { path: "/" }); - setCookie(c, "session_id", sessionId, { - domain: getCookieDomain(rpID), - path: "/", - httpOnly: true, - secure: true, - sameSite: "Lax", - maxAge: ttl, - }); - - return c.redirect("/dashboard", 302); - } catch (e: any) { - console.error("[Events] Failed to execute CLI join:", e); - return c.text("Internal Server Error", 500); - } -}); - -app.get("/join", (c) => { - return c.html(EventJoinPage()); -}); - -app.get("/e/:slug", async (c) => { - const slug = c.req.param("slug"); - try { - const result = await sqlWrapper.sql` - SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE - `; - if (!result || result.length === 0) { - return c.redirect("/join?error=event_not_found", 302); - } - return c.html(EventSplashPage({ event: result[0] })); - } catch (_e) { - return c.redirect("/join?error=db_error", 302); - } -}); - -// --------------------------------------------------------- ->>>>>>> 0f4b72e (feat(passes): implement ephemeral 1-click magic links, event passes, PIN join portal, and CLI 1-liner) // Provisioning & Registration (Use Cases 1, 2, 3) // --------------------------------------------------------- @@ -1521,150 +1136,6 @@ app.post("/api/admin/users/:id/profile", async (c) => { return c.json({ success: true, user: targetUser }); }); -// --------------------------------------------------------- -// Traefik ForwardAuth Edge Proxy Route (Tier 2) -// --------------------------------------------------------- - -import { - getAppByHost, - getUserGrant, - isIpAllowed, - isPathBypassed, -} from "./auth-session.ts"; -import { - computeJwkThumbprint, - verifyHttpSignature, -} from "./http_signatures.ts"; - -app.get("/api/forward-auth", async (c) => { - const host = c.req.header("X-Forwarded-Host"); - if (!host) { - return c.text("Bad Request: Missing X-Forwarded-Host header", 400); - } - - // 1. Resolve Target App (Valkey -> DB) - const appRecord = await getAppByHost(host); - if (!appRecord) { - const accept = c.req.header("Accept") || ""; - // If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view - if (accept.includes("text/html")) { - const loginDomain = rpID || "auth.atyg.org"; - c.header( - "Cache-Control", - "no-store, no-cache, must-revalidate, max-age=0", - ); - return c.redirect( - `https://${loginDomain}/errors/unregistered?host=${ - encodeURIComponent(host) - }`, - 302, - ); - } - // Default-Deny if app is not registered (API requests) - return c.json({ error: "Application not registered" }, 403); - } - - // 1.5 Dynamic Bypass Check - const uri = c.req.header("X-Forwarded-Uri") || "/"; - const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1"; - const requestPath = new URL(uri, `http://${host}`).pathname; - - if ( - appRecord.is_public === true || - isPathBypassed(requestPath, appRecord.bypass_paths) || - isIpAllowed(clientIp, appRecord.allowed_cidrs) - ) { - // Append standard headers even on bypass for downstream context if needed - c.header("X-Forwarded-App-Id", appRecord.id); - return c.text("OK", 200); - } - - // 2. Validate Session OR HTTP Signature - const signatureInput = c.req.header("Signature-Input"); - const signature = c.req.header("Signature"); - - if (signatureInput && signature) { - // Headless Edge Node Path (RFC 9421) - try { - const fingerprint = await verifyHttpSignature(c.req.raw); - - // Look up key name from postgres if needed, but fingerprint string manipulation is fast enough - const serviceName = `service-node:${fingerprint.substring(0, 8)}`; - const serviceId = fingerprint; - const scopes = "edge-node,daemon"; - - c.header("X-Forwarded-User", serviceName); - c.header("X-Forwarded-User-Id", serviceId); - c.header("X-Forwarded-Scopes", scopes); - c.header("X-Forwarded-App-Id", appRecord.id); - - return c.text("OK", 200); - } catch (err: any) { - return c.text(`Unauthorized: ${err.message}`, 401); - } - } - - // Standard User Session Path - const auth = await getAuthenticatedUser(c); - if (!auth) { - const accept = c.req.header("Accept") || ""; - const proto = c.req.header("X-Forwarded-Proto") || "https"; - const uri = c.req.header("X-Forwarded-Uri") || "/"; - const originalUrl = `${proto}://${host}${uri}`; - - // If a browser is requesting a webpage, seamlessly redirect to login - if (accept.includes("text/html")) { - const loginDomain = rpID || "auth.atyg.org"; - c.header( - "Cache-Control", - "no-store, no-cache, must-revalidate, max-age=0", - ); - return c.redirect( - `https://${loginDomain}/login?redirect=${ - encodeURIComponent(originalUrl) - }`, - 302, - ); - } - - return c.text("Unauthorized", 401); - } - - // Cache lookup for user status can be added later; hitting DB to be safe for now, - // but let's just make sure account is active. - const user = await sqlWrapper.sql` - SELECT id, username, account_status - FROM users - WHERE id = ${auth.userId} - `.then((res: any) => res[0]); - - if (!user || user.account_status !== "active") { - return c.text("Forbidden: Account inactive", 403); - } - - // 3. Resolve Grants and Roles - const globalAdmin = await isGlobalAdmin(auth.userId); - const grantRole = await getUserGrant(auth.userId, appRecord.id); - - if (!globalAdmin && !grantRole) { - // Enforce Default-Deny if no app-specific grants and not global admin - return c.text("Forbidden: Access denied to this application", 403); - } - - // Combine scopes, ensuring no duplicates and formatting as comma-separated string - const scopes = [ - ...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)), - ].join(","); - - // 4. Inject Headers - c.header("X-Forwarded-User", user.username); - c.header("X-Forwarded-User-Id", user.id); - c.header("X-Forwarded-Scopes", scopes); - c.header("X-Forwarded-App-Id", appRecord.id); - - return c.text("OK", 200); -}); - // --------------------------------------------------------- // Admin Application Registry // --------------------------------------------------------- @@ -2411,226 +1882,6 @@ app.get("/api/admin/check", async (c) => { return c.json({ isAdmin }); }); -// Get user's active sessions -app.get("/api/sessions", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const sessions = await sqlWrapper.sql` - SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at - FROM sessions - WHERE user_id = ${auth.userId} AND expires_at > NOW() - ORDER BY created_at DESC - `; - - return c.json({ sessions, currentSessionId: auth.sessionId }); -}); - -// Delegate a child agent session with custom lifespan and scopes -app.post("/api/sessions/delegate", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const { - label, - lifespanHours = 1, - mode = "read_only", - customScopes = [], - } = await c.req.json(); - - const cleanLabel = (label && typeof label === "string" && label.trim()) - ? label.trim() - : "AI Agent Session"; - const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days - const expiresAt = new Date(Date.now() + hours * 3600 * 1000); - - let effectiveScopes: string[] = []; - if (mode === "read_only") { - effectiveScopes = [ - "read:audit", - "read:users", - "read:apps", - "read:roles", - "read:sessions", - ]; - } else if (mode === "operator") { - effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"]; - } else if (mode === "admin") { - effectiveScopes = ["*"]; - } else if (mode === "custom" && Array.isArray(customScopes)) { - effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter( - Boolean, - ); - } - - const rawBytes = new Uint8Array(32); - crypto.getRandomValues(rawBytes); - const tokenHex = Array.from(rawBytes).map((b) => - b.toString(16).padStart(2, "0") - ).join(""); - const sessionId = `ay_sess_${tokenHex}`; - - await sqlWrapper.sql` - INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at) - VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW()) - `; - - try { - const sessionData = { - uuid: auth.userId, - username: auth.username, - label: cleanLabel, - isAgent: true, - customScopes: effectiveScopes, - }; - await valkey.set( - sessionId, - JSON.stringify(sessionData), - "EX", - Math.floor(hours * 3600), - ); - } catch (err) { - console.error("[Valkey] Failed to cache delegated session:", err); - } - - auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, { - label: cleanLabel, - lifespan_hours: hours, - mode, - scopes: effectiveScopes, - }, getClientIp(c)); - - return c.json({ - success: true, - sessionId, - token: sessionId, - label: cleanLabel, - expiresAt: expiresAt.toISOString(), - scopes: effectiveScopes, - }); -}); - -// Update permissions on an active session -app.put("/api/sessions/:id/scopes", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetSessionId = c.req.param("id"); - const { customScopes = [] } = await c.req.json(); - - const session = await sqlWrapper.sql` - SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} - `.then((res: any) => res[0]); - - if (!session) { - return c.json({ error: "Session not found or access denied" }, 404); - } - - const effectiveScopes = Array.isArray(customScopes) - ? customScopes.map((s: string) => String(s).trim()).filter(Boolean) - : []; - - await sqlWrapper.sql` - UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId} - `; - - try { - const existingCached = await valkey.get(targetSessionId); - if (existingCached) { - const parsed = JSON.parse(existingCached); - parsed.customScopes = effectiveScopes; - await valkey.set(targetSessionId, JSON.stringify(parsed)); - } - } catch (_e) {} - - auditWrapper.auditLog( - auth.userId, - "session_scopes_updated", - targetSessionId, - { - scopes: effectiveScopes, - }, - getClientIp(c), - ); - - return c.json({ success: true, scopes: effectiveScopes }); -}); - -// Extend session TTL -app.post("/api/sessions/:id/extend", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetSessionId = c.req.param("id"); - const { extendHours = 1 } = await c.req.json(); - const additionalHours = Math.max(Number(extendHours) || 1, 1); - - const session = await sqlWrapper.sql` - SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} - `.then((res: any) => res[0]); - - if (!session) { - return c.json({ error: "Session not found or access denied" }, 404); - } - - const currentExpiry = new Date(session.expires_at).getTime(); - const newExpiry = new Date( - Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000, - ); - - await sqlWrapper.sql` - UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId} - `; - - try { - const ttlSeconds = Math.max( - 1, - Math.floor((newExpiry.getTime() - Date.now()) / 1000), - ); - await valkey.expire(targetSessionId, ttlSeconds); - } catch (_e) {} - - auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, { - extended_by_hours: additionalHours, - new_expires_at: newExpiry.toISOString(), - }, getClientIp(c)); - - return c.json({ success: true, newExpiresAt: newExpiry.toISOString() }); -}); - -// Revoke a specific session -app.delete("/api/sessions/:id", async (c) => { - const auth = await getAuthenticatedUser(c); - if (!auth) return c.json({ error: "Unauthorized" }, 401); - - const targetSessionId = c.req.param("id"); - - // Verify the session belongs to the user - const session = await sqlWrapper.sql` - SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} - `.then((res: any) => res[0]); - - if (!session) { - return c.json({ error: "Session not found or access denied" }, 404); - } - - // Remove from Valkey - try { - await valkey.del(targetSessionId); - } catch (err) { - console.error("Failed to delete session from cache:", err); - } - - // Remove from DB (or expire it immediately) - await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`; - - auditWrapper.auditLog(auth.userId, "session_revoked", null, { - revoked_session_id: targetSessionId, - }, getClientIp(c)); - - return c.json({ success: true }); -}); - // --------------------------------------------------------- // Authenticated Passkey Registration (Adding a new device) // --------------------------------------------------------- diff --git a/server/routes/auth_forward.ts b/server/routes/auth_forward.ts new file mode 100644 index 0000000..8916024 --- /dev/null +++ b/server/routes/auth_forward.ts @@ -0,0 +1,143 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../db.ts"; +import { + getAppByHost, + getAuthenticatedUser, + getUserGrant, + isGlobalAdmin, + isIpAllowed, + isPathBypassed, +} from "../auth-session.ts"; +import { verifyHttpSignature } from "../http_signatures.ts"; + +export const forwardAuthRoutes = new Hono(); + +// --------------------------------------------------------- +// ForwardAuth Ingress Check (Traefik Ingress Middleware) +// --------------------------------------------------------- + +forwardAuthRoutes.get("/api/forward-auth", async (c) => { + const host = c.req.header("X-Forwarded-Host"); + if (!host) { + return c.text("Bad Request: Missing X-Forwarded-Host header", 400); + } + + // 1. Resolve Target App (Valkey -> DB) + const appRecord = await getAppByHost(host); + if (!appRecord) { + const accept = c.req.header("Accept") || ""; + // If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view + if (accept.includes("text/html")) { + const rpID = Deno.env.get("RP_ID"); + const loginDomain = rpID || "auth.atyg.org"; + c.header( + "Cache-Control", + "no-store, no-cache, must-revalidate, max-age=0", + ); + return c.redirect( + `https://${loginDomain}/errors/unregistered?host=${ + encodeURIComponent(host) + }`, + 302, + ); + } + // Default-Deny if app is not registered (API requests) + return c.json({ error: "Application not registered" }, 403); + } + + // 1.5 Dynamic Bypass Check + const uri = c.req.header("X-Forwarded-Uri") || "/"; + const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1"; + const requestPath = new URL(uri, `http://${host}`).pathname; + + if ( + appRecord.is_public === true || + isPathBypassed(requestPath, appRecord.bypass_paths) || + isIpAllowed(clientIp, appRecord.allowed_cidrs) + ) { + // Append standard headers even on bypass for downstream context if needed + c.header("X-Forwarded-App-Id", appRecord.id); + return c.text("OK", 200); + } + + // 2. Validate Session OR HTTP Signature + const signatureInput = c.req.header("Signature-Input"); + const signature = c.req.header("Signature"); + + if (signatureInput && signature) { + // Headless Edge Node Path (RFC 9421) + try { + const fingerprint = await verifyHttpSignature(c.req.raw); + + const serviceName = `service-node:${fingerprint.substring(0, 8)}`; + const serviceId = fingerprint; + const scopes = "edge-node,daemon"; + + c.header("X-Forwarded-User", serviceName); + c.header("X-Forwarded-User-Id", serviceId); + c.header("X-Forwarded-Scopes", scopes); + c.header("X-Forwarded-App-Id", appRecord.id); + + return c.text("OK", 200); + } catch (err: any) { + return c.text(`Unauthorized: ${err.message}`, 401); + } + } + + // Standard User Session Path + const auth = await getAuthenticatedUser(c); + if (!auth) { + const accept = c.req.header("Accept") || ""; + const proto = c.req.header("X-Forwarded-Proto") || "https"; + const uri = c.req.header("X-Forwarded-Uri") || "/"; + const originalUrl = `${proto}://${host}${uri}`; + + // If a browser is requesting a webpage, seamlessly redirect to login + if (accept.includes("text/html")) { + const rpID = Deno.env.get("RP_ID"); + const loginDomain = rpID || "auth.atyg.org"; + c.header( + "Cache-Control", + "no-store, no-cache, must-revalidate, max-age=0", + ); + return c.redirect( + `https://${loginDomain}/login?redirect=${ + encodeURIComponent(originalUrl) + }`, + 302, + ); + } + + return c.text("Unauthorized", 401); + } + + const user = await sqlWrapper.sql` + SELECT id, username, account_status + FROM users + WHERE id = ${auth.userId} + `.then((res: any) => res[0]); + + if (!user || user.account_status !== "active") { + return c.text("Forbidden: Account inactive", 403); + } + + // 3. Resolve Grants and Roles + const globalAdmin = await isGlobalAdmin(auth.userId); + const grantRole = await getUserGrant(auth.userId, appRecord.id); + + if (!globalAdmin && !grantRole) { + return c.text("Forbidden: Access denied to this application", 403); + } + + const scopes = [ + ...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)), + ].join(","); + + // 4. Inject Headers + c.header("X-Forwarded-User", user.username); + c.header("X-Forwarded-User-Id", user.id); + c.header("X-Forwarded-Scopes", scopes); + c.header("X-Forwarded-App-Id", appRecord.id); + + return c.text("OK", 200); +}); diff --git a/server/routes/events.ts b/server/routes/events.ts new file mode 100644 index 0000000..09be065 --- /dev/null +++ b/server/routes/events.ts @@ -0,0 +1,285 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { encodeHex } from "jsr:@std/encoding@1/hex"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { getAuthenticatedUser, getCookieDomain } from "../auth-session.ts"; +import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; +import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; + +export const eventRoutes = new Hono(); + +// --------------------------------------------------------- +// Multi-Claim Event Passes & Short Code Join Portal +// --------------------------------------------------------- + +eventRoutes.post("/api/events", async (c) => { + const user = await getAuthenticatedUser(c); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + const body = await c.req.json().catch(() => ({})); + const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } = + body; + let { slug, pinCode } = body; + + if (!name || typeof name !== "string") { + return c.json({ error: "Event name is required" }, 400); + } + + if (!slug) { + slug = + name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + + "-" + Math.random().toString(36).substring(2, 6); + } + if (!pinCode) { + const randPin = Math.floor(100000 + Math.random() * 900000).toString(); + pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3); + } + + try { + const expiresAt = new Date( + Date.now() + Number(lifespanHours) * 3600 * 1000, + ); + const result = await sqlWrapper.sql` + INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at) + VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${ + Number(maxSeats) + }, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt}) + RETURNING * + `; + return c.json({ success: true, event: result[0] }); + } catch (e: any) { + console.error("[Events] Failed to create event pass:", e); + return c.json({ error: "Failed to create event pass" }, 500); + } +}); + +eventRoutes.post("/api/join", async (c) => { + let code = ""; + if ( + c.req.header("content-type")?.includes("application/x-www-form-urlencoded") + ) { + const fd = await c.req.formData(); + code = (fd.get("code") as string) || ""; + } else { + const body = await c.req.json().catch(() => ({})); + code = body.code || ""; + } + + if (!code || typeof code !== "string") { + return c.json({ error: "Event code or PIN is required" }, 400); + } + code = code.trim(); + + try { + const result = await sqlWrapper.sql` + UPDATE event_passes + SET seats_claimed = seats_claimed + 1 + WHERE (slug = ${code} OR pin_code = ${code}) + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + AND (max_seats = 0 OR seats_claimed < max_seats) + RETURNING * + `; + + if (!result || result.length === 0) { + return c.json( + { error: "Invalid event code or workshop capacity reached" }, + 404, + ); + } + + const event = result[0]; + const guestUuid = crypto.randomUUID(); + const username = `guest_${event.slug}_${event.seats_claimed}`; + + await sqlWrapper.sql` + INSERT INTO users (id, username, display_name, account_status) + VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') + ON CONFLICT DO NOTHING + `; + + const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + const sessionId = `ay_sess_${encodeHex(randomBytes)}`; + const label = `${event.name} Seat #${event.seats_claimed}`; + const ttl = (Number(event.lifespan_hours) || 3) * 3600; + + let customScopes = ["guest", "trial"]; + let appDomain = ""; + + if (event.app_id) { + const apps = await sqlWrapper + .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; + if (apps.length > 0) { + customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; + appDomain = apps[0].domain || ""; + } + } + + const expiresAt = new Date(Date.now() + ttl * 1000); + + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) + VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) + `; + + await valkey.setex( + sessionId, + ttl, + JSON.stringify({ + uuid: guestUuid, + username, + account_status: "guest", + customScopes, + }), + ); + + deleteCookie(c, "session_id", { path: "/" }); + const rpID = Deno.env.get("RP_ID"); + const cookieDomain = getCookieDomain(rpID); + + setCookie(c, "session_id", sessionId, { + domain: cookieDomain, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard"; + + if ( + c.req.header("accept")?.includes("text/html") && + !c.req.header("accept")?.includes("application/json") + ) { + return c.redirect(redirectUrl, 302); + } + + return c.json({ + success: true, + sessionId, + token: sessionId, + guestUuid, + username, + redirectUrl, + }); + } catch (e: any) { + console.error("[Events] Failed to join event:", e); + return c.json({ error: "Failed to join event" }, 500); + } +}); + +eventRoutes.get("/join/:slug", async (c) => { + const slug = c.req.param("slug"); + const format = c.req.query("format") || "html"; + + try { + const result = await sqlWrapper.sql` + UPDATE event_passes + SET seats_claimed = seats_claimed + 1 + WHERE slug = ${slug} + AND is_active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + AND (max_seats = 0 OR seats_claimed < max_seats) + RETURNING * + `; + + if (!result || result.length === 0) { + if (format === "env" || format === "json") { + return c.text("Invalid slug or workshop capacity reached", 404); + } + return c.redirect("/join?error=not_found", 302); + } + + const event = result[0]; + const guestUuid = crypto.randomUUID(); + const username = `guest_${event.slug}_${event.seats_claimed}`; + + await sqlWrapper.sql` + INSERT INTO users (id, username, display_name, account_status) + VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest') + ON CONFLICT DO NOTHING + `; + + const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + const sessionId = `ay_sess_${encodeHex(randomBytes)}`; + const label = `${event.name} Seat #${event.seats_claimed}`; + const ttl = (Number(event.lifespan_hours) || 3) * 3600; + + let customScopes = ["guest", "trial"]; + if (event.app_id) { + const apps = await sqlWrapper + .sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`; + if (apps.length > 0) { + customScopes = [`app:${apps[0].name}`, event.role || "viewer"]; + } + } + + const expiresAt = new Date(Date.now() + ttl * 1000); + + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at) + VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt}) + `; + + await valkey.setex( + sessionId, + ttl, + JSON.stringify({ + uuid: guestUuid, + username, + account_status: "guest", + customScopes, + }), + ); + + if (format === "env") { + return c.text( + `export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`, + ); + } else if (format === "json") { + return c.json({ + success: true, + token: sessionId, + username, + expiresAt: expiresAt.toISOString(), + }); + } + + deleteCookie(c, "session_id", { path: "/" }); + const rpID = Deno.env.get("RP_ID"); + setCookie(c, "session_id", sessionId, { + domain: getCookieDomain(rpID), + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttl, + }); + + return c.redirect("/dashboard", 302); + } catch (e: any) { + console.error("[Events] Failed to execute CLI join:", e); + return c.text("Internal Server Error", 500); + } +}); + +eventRoutes.get("/join", (c) => { + return c.html(EventJoinPage()); +}); + +eventRoutes.get("/e/:slug", async (c) => { + const slug = c.req.param("slug"); + try { + const result = await sqlWrapper.sql` + SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE + `; + if (!result || result.length === 0) { + return c.redirect("/join?error=event_not_found", 302); + } + return c.html(EventSplashPage({ event: result[0] })); + } catch (_e) { + return c.redirect("/join?error=db_error", 302); + } +}); diff --git a/server/routes/passes_magic.ts b/server/routes/passes_magic.ts new file mode 100644 index 0000000..a0a746a --- /dev/null +++ b/server/routes/passes_magic.ts @@ -0,0 +1,129 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { valkey } from "../valkey.ts"; +import { sqlWrapper } from "../db.ts"; +import { getCookieDomain } from "../auth-session.ts"; + +export const passRoutes = new Hono(); + +// --------------------------------------------------------- +// Ephemeral 1-Click Magic Link Redemption (/pass) +// --------------------------------------------------------- + +passRoutes.get("/", async (c) => { + const token = c.req.query("token"); + if (!token) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + + // 1. Validate against Valkey, fallback to PostgreSQL + let sessionDataStr = null; + try { + sessionDataStr = await valkey.get(token); + } catch (_err) {} + + let sessionInfo: any = null; + if (sessionDataStr) { + try { + sessionInfo = JSON.parse(sessionDataStr); + } catch (_err) {} + } + + let expiresAtDate: Date | null = null; + let customScopes: string[] = []; + + if (!sessionInfo || !sessionInfo.uuid) { + try { + const nowIso = new Date().toISOString(); + const session = await sqlWrapper.sql` + SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ${token} AND s.expires_at > ${nowIso} + `.then((res: any) => res[0]); + + if (!session) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + + sessionInfo = { + uuid: session.user_id, + username: session.username, + label: session.label, + isAgent: session.is_agent, + customScopes: session.custom_scopes, + }; + expiresAtDate = new Date(session.expires_at); + customScopes = session.custom_scopes || []; + + try { + const ttlSeconds = Math.max( + 1, + Math.floor((expiresAtDate.getTime() - Date.now()) / 1000), + ); + await valkey.setex(token, ttlSeconds, JSON.stringify(sessionInfo)); + } catch (_e) {} + } catch (_err) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + } else { + try { + const ttl = await valkey.ttl(token); + if (ttl <= 0) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + expiresAtDate = new Date(Date.now() + ttl * 1000); + customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes || + []; + } catch (_err) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + } + + if (!sessionInfo || !expiresAtDate) { + return c.redirect("/login?error=invalid_or_expired_pass", 302); + } + + // 2. Cookie Scoping + deleteCookie(c, "session_id", { path: "/" }); + + const rpID = Deno.env.get("RP_ID"); + const cookieDomain = getCookieDomain(rpID); + const ttlSeconds = Math.max( + 1, + Math.floor((expiresAtDate.getTime() - Date.now()) / 1000), + ); + setCookie(c, "session_id", token, { + path: "/", + domain: cookieDomain, + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: ttlSeconds, + }); + + // 3. Redirect URL Resolution + let targetDomain = null; + if (Array.isArray(customScopes)) { + const appScope = customScopes.find((s: string) => + typeof s === "string" && s.startsWith("app:") + ); + if (appScope) { + const appName = appScope.substring(4); + try { + const appRecord = await sqlWrapper.sql` + SELECT domain FROM apps WHERE name = ${appName} + `.then((res: any) => res[0]); + if (appRecord && appRecord.domain) { + targetDomain = appRecord.domain; + } + } catch (_err) {} + } + } + + if (targetDomain) { + return c.redirect(`https://${targetDomain}`, 302); + } else { + return c.redirect("/dashboard", 302); + } +}); diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts new file mode 100644 index 0000000..1f32555 --- /dev/null +++ b/server/routes/sessions.ts @@ -0,0 +1,239 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../db.ts"; +import { valkey } from "../valkey.ts"; +import { auditWrapper } from "../audit.ts"; +import { getAuthenticatedUser, getClientIp } from "../auth-session.ts"; + +export const sessionRoutes = new Hono(); + +// --------------------------------------------------------- +// Active Sessions Listing +// --------------------------------------------------------- + +sessionRoutes.get("/api/sessions", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const sessions = await sqlWrapper.sql` + SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at + FROM sessions + WHERE user_id = ${auth.userId} AND expires_at > NOW() + ORDER BY created_at DESC + `; + + return c.json({ sessions, currentSessionId: auth.sessionId }); +}); + +// --------------------------------------------------------- +// Delegate a child agent session with custom lifespan and scopes +// --------------------------------------------------------- + +sessionRoutes.post("/api/sessions/delegate", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { + label, + lifespanHours = 1, + mode = "read_only", + customScopes = [], + } = await c.req.json(); + + const cleanLabel = (label && typeof label === "string" && label.trim()) + ? label.trim() + : "AI Agent Session"; + const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days + const expiresAt = new Date(Date.now() + hours * 3600 * 1000); + + let effectiveScopes: string[] = []; + if (mode === "read_only") { + effectiveScopes = [ + "read:audit", + "read:users", + "read:apps", + "read:roles", + "read:sessions", + ]; + } else if (mode === "operator") { + effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"]; + } else if (mode === "admin") { + effectiveScopes = ["*"]; + } else if (mode === "custom" && Array.isArray(customScopes)) { + effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter( + Boolean, + ); + } + + const rawBytes = new Uint8Array(32); + crypto.getRandomValues(rawBytes); + const tokenHex = Array.from(rawBytes).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); + const sessionId = `ay_sess_${tokenHex}`; + + await sqlWrapper.sql` + INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at) + VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW()) + `; + + try { + const sessionData = { + uuid: auth.userId, + username: auth.username, + label: cleanLabel, + isAgent: true, + customScopes: effectiveScopes, + }; + await valkey.set( + sessionId, + JSON.stringify(sessionData), + "EX", + Math.floor(hours * 3600), + ); + } catch (err) { + console.error("[Valkey] Failed to cache delegated session:", err); + } + + auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, { + label: cleanLabel, + lifespan_hours: hours, + mode, + scopes: effectiveScopes, + }, getClientIp(c)); + + return c.json({ + success: true, + sessionId, + token: sessionId, + label: cleanLabel, + expiresAt: expiresAt.toISOString(), + scopes: effectiveScopes, + }); +}); + +// --------------------------------------------------------- +// Update permissions on an active session +// --------------------------------------------------------- + +sessionRoutes.put("/api/sessions/:id/scopes", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetSessionId = c.req.param("id"); + const { customScopes = [] } = await c.req.json(); + + const session = await sqlWrapper.sql` + SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} + `.then((res: any) => res[0]); + + if (!session) { + return c.json({ error: "Session not found or access denied" }, 404); + } + + const effectiveScopes = Array.isArray(customScopes) + ? customScopes.map((s: string) => String(s).trim()).filter(Boolean) + : []; + + await sqlWrapper.sql` + UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId} + `; + + try { + const existingCached = await valkey.get(targetSessionId); + if (existingCached) { + const parsed = JSON.parse(existingCached); + parsed.customScopes = effectiveScopes; + await valkey.set(targetSessionId, JSON.stringify(parsed)); + } + } catch (_e) {} + + auditWrapper.auditLog( + auth.userId, + "session_scopes_updated", + targetSessionId, + { + scopes: effectiveScopes, + }, + getClientIp(c), + ); + + return c.json({ success: true, scopes: effectiveScopes }); +}); + +// --------------------------------------------------------- +// Extend session TTL +// --------------------------------------------------------- + +sessionRoutes.post("/api/sessions/:id/extend", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetSessionId = c.req.param("id"); + const { extendHours = 1 } = await c.req.json(); + const additionalHours = Math.max(Number(extendHours) || 1, 1); + + const session = await sqlWrapper.sql` + SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} + `.then((res: any) => res[0]); + + if (!session) { + return c.json({ error: "Session not found or access denied" }, 404); + } + + const currentExpiry = new Date(session.expires_at).getTime(); + const newExpiry = new Date( + Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000, + ); + + await sqlWrapper.sql` + UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId} + `; + + try { + const ttlSeconds = Math.max( + 1, + Math.floor((newExpiry.getTime() - Date.now()) / 1000), + ); + await valkey.expire(targetSessionId, ttlSeconds); + } catch (_e) {} + + auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, { + extended_by_hours: additionalHours, + new_expires_at: newExpiry.toISOString(), + }, getClientIp(c)); + + return c.json({ success: true, newExpiresAt: newExpiry.toISOString() }); +}); + +// --------------------------------------------------------- +// Revoke a specific session +// --------------------------------------------------------- + +sessionRoutes.delete("/api/sessions/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const targetSessionId = c.req.param("id"); + + const session = await sqlWrapper.sql` + SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId} + `.then((res: any) => res[0]); + + if (!session) { + return c.json({ error: "Session not found or access denied" }, 404); + } + + try { + await valkey.del(targetSessionId); + } catch (err) { + console.error("Failed to delete session from cache:", err); + } + + await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`; + + auditWrapper.auditLog(auth.userId, "session_revoked", null, { + revoked_session_id: targetSessionId, + }, getClientIp(c)); + + return c.json({ success: true }); +});