144 lines
4.4 KiB
TypeScript
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);
|
|
});
|