auth-yes/server/routes/auth_forward.ts

144 lines
4.4 KiB
TypeScript

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