- Allowed guest accounts to be evaluated in `forward-auth`
- Validated `guest` account's `customScopes` and rejected ungranted access
- Added Array parameterization and `UNION` query in `getDashboardApps`
- Mapped `customScopes` to `getDashboardApps` in the UI route `/dashboard`
- Wired web and CLI joins in `events.ts` to `auditWrapper.auditLog` using correct schema (`event.id`, `{slug, method}`)
- Added `auditWrapper.auditLog` unit test validations in `events.test.ts`
- Added guest session scope unit tests in `forward_auth.test.ts`
- Moved Markdown tasks logic from `tasks/new/` to `tasks/complete/`
Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
169 lines
5.1 KiB
TypeScript
169 lines
5.1 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" && user.account_status !== "guest")
|
|
) {
|
|
return c.text("Forbidden: Account inactive", 403);
|
|
}
|
|
|
|
// 3. Resolve Grants and Roles
|
|
let scopes = "";
|
|
|
|
if (
|
|
user.account_status === "guest" ||
|
|
(auth.customScopes && auth.customScopes.length > 0)
|
|
) {
|
|
// Guest or delegated session handling
|
|
const customScopes = auth.customScopes || [];
|
|
const hasAppScope = customScopes.includes(`app:${appRecord.name}`) ||
|
|
customScopes.includes("*");
|
|
|
|
if (!hasAppScope) {
|
|
return c.text(
|
|
"Forbidden: Access denied to this application (Guest/Delegated)",
|
|
403,
|
|
);
|
|
}
|
|
|
|
scopes = customScopes.filter(Boolean).join(",") || "viewer";
|
|
} else {
|
|
// Standard user handling
|
|
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);
|
|
}
|
|
|
|
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);
|
|
});
|