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); });