From ee02daba795e33ee48bb2cf96fed53e1c4e98c49 Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Thu, 27 Aug 2026 20:52:45 -0700 Subject: [PATCH] feat(phase-5a): complete runtime switchover to src/main.ts and isolate cleanup task (#61) --- deno.json | 4 +- src/core/forward_auth.ts | 98 ++++++++++ src/core/http_signatures.ts | 180 ++++++++++++++++++ src/core/rpc.ts | 171 +++++++++++++++++ src/core/session.ts | 41 ++++ .../forward_auth/forward_auth.test.ts | 31 +++ src/features/forward_auth/routes.ts | 164 ++++++++++++++++ src/features/sessions/pass_routes.test.ts | 13 ++ src/features/sessions/pass_routes.ts | 129 +++++++++++++ src/main.ts | 37 +++- tasks/audits/2026-0827-audit-2-phase-4.md | 43 +++-- tasks/cleanup-legacy-directories.md | 53 ++++++ 12 files changed, 945 insertions(+), 19 deletions(-) create mode 100644 src/core/forward_auth.ts create mode 100644 src/core/http_signatures.ts create mode 100644 src/core/rpc.ts create mode 100644 src/features/forward_auth/forward_auth.test.ts create mode 100644 src/features/forward_auth/routes.ts create mode 100644 src/features/sessions/pass_routes.test.ts create mode 100644 src/features/sessions/pass_routes.ts create mode 100644 tasks/cleanup-legacy-directories.md diff --git a/deno.json b/deno.json index 5bd68c9..f15edf9 100644 --- a/deno.json +++ b/deno.json @@ -7,8 +7,8 @@ ], "license": "MIT OR Apache-2.0", "tasks": { - "dev": "deno run --watch -A --unstable-ffi server/main.ts", - "start": "deno run -A --unstable-ffi server/main.ts", + "dev": "deno run --watch -A --unstable-ffi src/main.ts", + "start": "deno run -A --unstable-ffi src/main.ts", "test": "deno test -A --unstable-ffi", "lint": "deno lint && deno run -A scripts/lint_arch.ts", "lint:arch": "deno run -A scripts/lint_arch.ts", diff --git a/src/core/forward_auth.ts b/src/core/forward_auth.ts new file mode 100644 index 0000000..eb8da17 --- /dev/null +++ b/src/core/forward_auth.ts @@ -0,0 +1,98 @@ +/** + * Deterministic fast path prefix matcher for dynamic bypasses + */ +export function isPathBypassed( + requestPath: string, + bypassPaths?: string[], +): boolean { + if (!bypassPaths || bypassPaths.length === 0) return false; + + for (const pattern of bypassPaths) { + if (pattern === requestPath) return true; + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -2); + if (requestPath === prefix || requestPath.startsWith(prefix + "/")) { + return true; + } + } + } + + return false; +} + +/** + * Pure native Deno bitwise CIDR matcher + */ +export function isIpAllowed( + clientIp: string, + allowedCidrs?: string[], +): boolean { + if (!allowedCidrs || allowedCidrs.length === 0) return false; + + const parseIp4 = (ip: string) => { + const parts = ip.split("."); + if (parts.length !== 4) return null; + return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> + 0; + }; + + const primaryIp = clientIp.split(",")[0].trim(); + const ipNum = parseIp4(primaryIp); + + for (const cidr of allowedCidrs) { + const [subnet, maskStr] = cidr.split("/"); + if (!maskStr) { + if (subnet === primaryIp) return true; + continue; + } + + if (ipNum !== null && subnet.includes(".")) { + const subnetNum = parseIp4(subnet); + if (subnetNum !== null) { + const maskBits = parseInt(maskStr, 10); + const mask = maskBits === 0 + ? 0 + : ((0xffffffff << (32 - maskBits)) >>> 0); + if ((ipNum & mask) === (subnetNum & mask)) { + return true; + } + } + } else if (subnet === clientIp) { + return true; + } + } + + return false; +} + +/** + * Validates a given URL to ensure it is safe to redirect to. + */ +export function isSafeRedirectUrl( + rawUrl: string, + customDomain?: string, +): boolean { + if (!rawUrl) return false; + if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) { + return true; + } + try { + const parsed = new URL(rawUrl); + const host = parsed.hostname; + const root = customDomain || Deno.env.get("RP_ID") || "atyg.org"; + const cleanRoot = root.replace(/^\./, ""); + + if ( + host === "localhost" || + host === "atyg.org" || + host.endsWith(".atyg.org") || + host === cleanRoot || + host.endsWith(`.${cleanRoot}`) + ) { + return true; + } + } catch (_e) { + return false; + } + return false; +} diff --git a/src/core/http_signatures.ts b/src/core/http_signatures.ts new file mode 100644 index 0000000..7e18a70 --- /dev/null +++ b/src/core/http_signatures.ts @@ -0,0 +1,180 @@ +import { valkey } from "./valkey.ts"; + +/** + * Computes the RFC 7638 JWK Thumbprint of an Ed25519 OKP key. + * This canonicalizes the JWK by ordering the keys: crv, kty, x + */ +export async function computeJwkThumbprint( + jwk: { kty: string; crv: string; x: string }, +): Promise { + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) { + throw new Error("Invalid Ed25519 JWK"); + } + + const canonicalJwk = `{"crv":"Ed25519","kty":"OKP","x":"${jwk.x}"}`; + const data = new TextEncoder().encode(canonicalJwk); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + return hex; +} + +/** + * Extracts and verifies an RFC 9421 HTTP Message Signature on a given Request. + * Returns the fingerprint if verified, or throws an error. + */ +export async function verifyHttpSignature(req: Request): Promise { + const signatureInput = req.headers.get("Signature-Input"); + const signature = req.headers.get("Signature"); + + if (!signatureInput || !signature) { + throw new Error("Missing HTTP Message Signature headers"); + } + + const sigMatch = signatureInput.match(/sig1=\(([^)]+)\)(.*)/); + if (!sigMatch) { + throw new Error("Invalid Signature-Input format"); + } + + const componentsStr = sigMatch[1]; + const paramsStr = sigMatch[2]; + + const components = componentsStr.split(" ").map((s) => s.replace(/"/g, "")); + + const params = new Map(); + const paramRegex = /;([a-z]+)=([^;]+)/g; + let match; + while ((match = paramRegex.exec(paramsStr)) !== null) { + let val = match[2]; + if (val.startsWith('"') && val.endsWith('"')) { + val = val.slice(1, -1); + } + params.set(match[1], val); + } + + // 1. Time bounds validation + const created = parseInt(params.get("created") || "0", 10); + const expires = parseInt(params.get("expires") || "0", 10); + + if (!created || !expires) { + throw new Error("Missing created or expires timestamp"); + } + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - created) > 30) { + throw new Error("Signature created timestamp out of drift bounds"); + } + if (now > expires) { + throw new Error("Signature expired"); + } + + // 2. Parse HWK + const hwkBase64 = params.get("hwk"); + if (!hwkBase64) { + throw new Error("Missing inline hwk parameter"); + } + + let hwk; + try { + const hwkJson = atob(hwkBase64.replace(/-/g, "+").replace(/_/g, "/")); + hwk = JSON.parse(hwkJson); + } catch (_err) { + throw new Error("Invalid base64url or JSON in hwk parameter"); + } + + if (hwk.kty !== "OKP" || hwk.crv !== "Ed25519" || !hwk.x) { + throw new Error("Only Ed25519 OKP keys are supported"); + } + + const fingerprint = await computeJwkThumbprint(hwk); + + // 3. Check Valkey Authorization First + try { + const isMember = await valkey.sismember( + "auth:hwk:fingerprints", + fingerprint, + ); + if (isMember !== 1) { + throw new Error("Fingerprint not authorized"); + } + } catch (err: any) { + if (err.message?.includes("Fingerprint not authorized")) { + throw err; + } + console.error("Valkey error during signature verification:", err); + throw new Error("Internal server error"); + } + + // 4. Build Canonical Signature Base + const url = new URL(req.url); + let signatureBase = ""; + + const xForwardedMethod = req.headers.get("X-Forwarded-Method"); + const xForwardedUri = req.headers.get("X-Forwarded-Uri"); + const xForwardedHost = req.headers.get("X-Forwarded-Host"); + + const originalMethod = xForwardedMethod || req.method; + const originalPath = xForwardedUri + ? new URL(xForwardedUri, "http://localhost").pathname + + new URL(xForwardedUri, "http://localhost").search + : url.pathname + url.search; + const originalHost = xForwardedHost || req.headers.get("host") || url.host; + + for (const comp of components) { + if (comp === "@method") { + signatureBase += `"@method": ${originalMethod.toLowerCase()}\n`; + } else if (comp === "@path") { + signatureBase += `"@path": ${originalPath}\n`; + } else if (comp === "@authority") { + signatureBase += `"@authority": ${originalHost}\n`; + } else { + const headerVal = req.headers.get(comp); + if (headerVal === null) { + throw new Error(`Missing required signature component: ${comp}`); + } + signatureBase += `"${comp}": ${headerVal}\n`; + } + } + + signatureBase += `"@signature-params": (${componentsStr})${paramsStr}`; + + // 5. Verify the signature + const sigValueMatch = signature.match(/sig1=:([^:]+):/); + if (!sigValueMatch) { + throw new Error("Invalid Signature header format"); + } + + const rawSigValue = sigValueMatch[1]; + let sigBytes; + try { + const sigStr = atob(rawSigValue.replace(/-/g, "+").replace(/_/g, "/")); + sigBytes = new Uint8Array(sigStr.length); + for (let i = 0; i < sigStr.length; i++) { + sigBytes[i] = sigStr.charCodeAt(i); + } + } catch { + throw new Error("Invalid base64 encoding in signature"); + } + + const importedKey = await crypto.subtle.importKey( + "jwk", + hwk, + { name: "Ed25519" }, + false, + ["verify"], + ); + + const msgBytes = new TextEncoder().encode(signatureBase); + const isValid = await crypto.subtle.verify( + "Ed25519", + importedKey, + sigBytes, + msgBytes, + ); + + if (!isValid) { + throw new Error("Invalid cryptographic signature"); + } + + return fingerprint; +} diff --git a/src/core/rpc.ts b/src/core/rpc.ts new file mode 100644 index 0000000..01a0039 --- /dev/null +++ b/src/core/rpc.ts @@ -0,0 +1,171 @@ +import { AuthService } from "../../sdk/gen/auth_connect.ts"; +import { + universalServerRequestFromFetch, + universalServerResponseToFetch, +} from "npm:@connectrpc/connect@^1.4.0/protocol"; +import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; +import { createConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; +import type { Hono } from "jsr:@hono/hono@4"; + +import { spireWrapper } from "./spire_ffi.ts"; +import { sqlWrapper } from "./db.ts"; +import { valkey } from "./valkey.ts"; +import { auditWrapper } from "./audit.ts"; + +export const connectRoutes = (router: ConnectRouter) => { + router.service(AuthService, { + async validateSession(req, context) { + try { + const spiffeId = spireWrapper.extractSpiffeIdFromCert( + context.requestHeader.get("x-peer-cert") || "", + ); + + if (!spiffeId) { + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; + } + + const appRecord = await sqlWrapper + .sql`SELECT id FROM apps WHERE spiffe_id = ${spiffeId}`.then(( + res: any, + ) => res[0]); + + if (!appRecord) { + auditWrapper.auditLog(null, "session_validation_failed", null, { + reason: "Unauthorized SPIFFE ID", + }, "internal-grpc"); + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; + } + + const token = req.token; + if (!token) { + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; + } + + let sessionDataStr; + try { + sessionDataStr = await valkey.get(token); + } catch (err: unknown) { + console.error("Valkey error during validateSession:", err); + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", + }; + } + + if (!sessionDataStr) { + auditWrapper.auditLog( + null, + "session_validation_failed", + appRecord.id, + { + reason: "Session invalid or expired", + }, + "internal-grpc", + ); + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; + } + + let sessionData; + try { + sessionData = JSON.parse(sessionDataStr); + } catch (err: unknown) { + console.error("JSON parse error during validateSession:", err); + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", + }; + } + + if (!sessionData || !sessionData.uuid) { + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", + }; + } + + const userId = sessionData.uuid; + + const grantRecord = await sqlWrapper + .sql`SELECT role FROM user_grants WHERE user_id = ${userId} AND app_id = ${appRecord.id}` + .then((res: any) => res[0]); + + if (!grantRecord) { + auditWrapper.auditLog( + userId, + "session_validation_failed", + appRecord.id, + { + reason: "Access denied (RBAC)", + }, + "internal-grpc", + ); + return { + valid: false, + uuid: "", + scopes: [], + error: "Validation failed", + }; + } + + return { + valid: true, + uuid: userId, + scopes: [grantRecord.role], + error: "", + }; + } catch (err: unknown) { + console.error("Unexpected error in validateSession:", err); + return { + valid: false, + uuid: "", + scopes: [], + error: "Internal server error", + }; + } + }, + }); +}; + +export const startConnectRpcServer = (app: Hono) => { + const router = createConnectRouter(); + connectRoutes(router); + const handlers = router.handlers; + + app.all("/auth.v1.AuthService/*", async (c) => { + const url = new URL(c.req.url); + const handler = handlers.find((h) => h.requestPath === url.pathname); + if (!handler) { + return new Response("Not Found", { status: 404 }); + } + + const uReq = universalServerRequestFromFetch(c.req.raw, {}); + const uRes = await handler(uReq); + return universalServerResponseToFetch(uRes); + }); +}; diff --git a/src/core/session.ts b/src/core/session.ts index cc02b88..a46314c 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -231,3 +231,44 @@ export async function requireAdmin(c: Context, next: () => Promise) { } await next(); } + +/** + * Resolves application record by domain with Valkey caching. + */ +export async function getAppByHost(host: string): Promise { + const cacheKey = `auth:app_by_host:${host}`; + try { + const cached = await valkey.get(cacheKey); + if (cached) return JSON.parse(cached); + } catch (_e) {} + + try { + const app = await sqlWrapper.sql` + SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs + FROM apps WHERE domain = ${host} LIMIT 1 + `.then((res: any) => res[0]); + + if (app) { + await valkey.setex(cacheKey, 300, JSON.stringify(app)).catch(() => {}); + return app; + } + } catch (_e) {} + return null; +} + +/** + * Resolves role grant for a user on a given app. + */ +export async function getUserGrant( + userId: string, + appId: string, +): Promise { + try { + const grant = await sqlWrapper.sql` + SELECT role FROM user_grants WHERE user_id = ${userId} AND app_id = ${appId} LIMIT 1 + `.then((res: any) => res[0]); + return grant?.role || null; + } catch (_e) { + return null; + } +} diff --git a/src/features/forward_auth/forward_auth.test.ts b/src/features/forward_auth/forward_auth.test.ts new file mode 100644 index 0000000..6c74a81 --- /dev/null +++ b/src/features/forward_auth/forward_auth.test.ts @@ -0,0 +1,31 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import app from "../../main.ts"; +import { + isIpAllowed, + isPathBypassed, + isSafeRedirectUrl, +} from "../../core/forward_auth.ts"; + +Deno.test("[ForwardAuth] GET /api/forward-auth without host returns 400", async () => { + const res = await app.fetch(new Request("http://localhost/api/forward-auth")); + assertEquals(res.status, 400); +}); + +Deno.test("[ForwardAuth] Helpers: isPathBypassed correctly matches prefixes", () => { + assertEquals(isPathBypassed("/public/styles.css", ["/public/*"]), true); + assertEquals(isPathBypassed("/api/healthz", ["/api/healthz"]), true); + assertEquals(isPathBypassed("/admin", ["/public/*"]), false); +}); + +Deno.test("[ForwardAuth] Helpers: isIpAllowed handles IPv4 CIDR matching", () => { + assertEquals(isIpAllowed("192.168.1.50", ["192.168.1.0/24"]), true); + assertEquals(isIpAllowed("10.0.0.1", ["192.168.1.0/24"]), false); + assertEquals(isIpAllowed("127.0.0.1", ["127.0.0.1"]), true); +}); + +Deno.test("[ForwardAuth] Helpers: isSafeRedirectUrl validates target destinations", () => { + assertEquals(isSafeRedirectUrl("/dashboard"), true); + assertEquals(isSafeRedirectUrl("https://auth.atyg.org/login"), true); + assertEquals(isSafeRedirectUrl("https://evil.com"), false); + assertEquals(isSafeRedirectUrl("//evil.com"), false); +}); diff --git a/src/features/forward_auth/routes.ts b/src/features/forward_auth/routes.ts new file mode 100644 index 0000000..80f1712 --- /dev/null +++ b/src/features/forward_auth/routes.ts @@ -0,0 +1,164 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { sqlWrapper } from "../../core/db.ts"; +import { + getAppByHost, + getAuthenticatedUser, + getUserGrant, + isGlobalAdmin, +} from "../../core/session.ts"; +import { isIpAllowed, isPathBypassed } from "../../core/forward_auth.ts"; +import { verifyHttpSignature } from "../../core/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 (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, + ); + } + 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) + ) { + 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) { + 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 (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); + } + + if (auth.isPaused) { + return c.text("Forbidden: Session Paused by Host", 403); + } + + 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) + ) { + 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 { + 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); +}); diff --git a/src/features/sessions/pass_routes.test.ts b/src/features/sessions/pass_routes.test.ts new file mode 100644 index 0000000..b6c32da --- /dev/null +++ b/src/features/sessions/pass_routes.test.ts @@ -0,0 +1,13 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import app from "../../main.ts"; + +Deno.test("[Passes] GET /pass without token redirects to /login?error=invalid_or_expired_pass", async () => { + const res = await app.fetch(new Request("http://localhost/pass")); + assertEquals(res.status, 302); + assertEquals( + res.headers.get("location")?.includes( + "/login?error=invalid_or_expired_pass", + ), + true, + ); +}); diff --git a/src/features/sessions/pass_routes.ts b/src/features/sessions/pass_routes.ts new file mode 100644 index 0000000..c0fb22f --- /dev/null +++ b/src/features/sessions/pass_routes.ts @@ -0,0 +1,129 @@ +import { Hono } from "jsr:@hono/hono@4"; +import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie"; +import { valkey } from "../../core/valkey.ts"; +import { sqlWrapper } from "../../core/db.ts"; +import { getCookieDomain } from "../../core/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/src/main.ts b/src/main.ts index 9ceaa1f..e4656d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,19 @@ import { Hono } from "jsr:@hono/hono@4"; import { serveStatic } from "jsr:@hono/hono@4/deno"; +import { MetadataService } from "jsr:@simplewebauthn/server@13"; + import { initDb } from "./core/db.ts"; import { pingValkey } from "./core/valkey.ts"; import { contentNegotiation } from "./core/content_negotiation.ts"; import { payloadCapGuard } from "./core/auth_guards.ts"; +import { startConnectRpcServer } from "./core/rpc.ts"; import { authRoutes } from "./features/auth/routes.tsx"; import { adminRoutes } from "./features/admin/routes.tsx"; import { eventsRoutes } from "./features/events/routes.tsx"; import { sessionRoutes } from "./features/sessions/routes.tsx"; +import { passRoutes } from "./features/sessions/pass_routes.ts"; +import { forwardAuthRoutes } from "./features/forward_auth/routes.ts"; const app: Hono = new Hono(); @@ -18,22 +23,48 @@ app.use("*", contentNegotiation()); // Serve static assets (specifically Datastar and client scripts) app.use("/public/*", serveStatic({ root: "./" })); -// Wire Feature Slices +// Wire Sub-routers and Vertical Slices +app.route("/pass", passRoutes); +app.route("/", forwardAuthRoutes); app.route("/", authRoutes); app.route("/", eventsRoutes); app.route("/", sessionRoutes); app.route("/admin", adminRoutes); app.route("/api/admin", adminRoutes); -// Basic health check for foundation +// Workload Mesh ConnectRPC Daemon +startConnectRpcServer(app); + +// Basic health check app.get("/healthz", (c) => c.text("OK")); if (import.meta.main) { - console.log("[Auth-Yes Next] Bootstrapping core foundation..."); + const rpID = Deno.env.get("RP_ID"); + const origin = Deno.env.get("ORIGIN"); + + if (!rpID || !origin) { + console.warn( + "[Auth-Yes] Warning: RP_ID or ORIGIN not set in environment. Falling back to defaults.", + ); + } + + console.log("[Auth-Yes] Initializing FIDO MDS3 Metadata Blob..."); + try { + await MetadataService.initialize(); + console.log("[Auth-Yes] FIDO MDS3 Metadata Blob successfully loaded."); + } catch (error) { + console.warn( + "[Auth-Yes] Failed to initialize FIDO MDS3 Metadata Blob (offline mode):", + error, + ); + } + + console.log("[Auth-Yes] Initializing database and cache..."); await initDb(); await pingValkey(); const port = parseInt(Deno.env.get("PORT") || "8000", 10); + console.log(`[Auth-Yes] Hypermedia Server running on port ${port}`); Deno.serve({ port }, app.fetch); } diff --git a/tasks/audits/2026-0827-audit-2-phase-4.md b/tasks/audits/2026-0827-audit-2-phase-4.md index 583fe69..fe37a55 100644 --- a/tasks/audits/2026-0827-audit-2-phase-4.md +++ b/tasks/audits/2026-0827-audit-2-phase-4.md @@ -3,27 +3,42 @@ ## 1. Test Suite & Verification - **`deno fmt`**: Passed (All 6 architectural test suites formatted). -- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed with 0 errors; all files $\le 72$ lines, zero banned DOM API violations). -- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, `ui/`, `infra/`, `src/`). -- **`deno test -A --no-check`**: Passed (90 tests across 30 steps with 0 failures). +- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed + with 0 errors; all files $\le 72$ lines, zero banned DOM API violations). +- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, + `ui/`, `infra/`, `src/`). +- **`deno test -A --no-check`**: Passed (90 tests across 30 steps with 0 + failures). ## 2. Scope Implemented & Verified -1. **Transport Efficiency & Latency (`src/tests/arch/transport_efficiency.test.ts`):** - - Asserts non-streaming point-to-point actions (`/join`, `/login`) execute rapidly without SSE overhead. - - Asserts the 16KB payload ceiling guard rejects oversized bodies with 413/400. -2. **SSE Stream Lifecycle & Leak Teardown (`src/tests/arch/sse_lifecycle.test.ts`):** - - Asserts `streamDatastar` handles client `AbortSignal` disconnects gracefully and executes clean teardown logic. +1. **Transport Efficiency & Latency + (`src/tests/arch/transport_efficiency.test.ts`):** + - Asserts non-streaming point-to-point actions (`/join`, `/login`) execute + rapidly without SSE overhead. + - Asserts the 16KB payload ceiling guard rejects oversized bodies with + 413/400. +2. **SSE Stream Lifecycle & Leak Teardown + (`src/tests/arch/sse_lifecycle.test.ts`):** + - Asserts `streamDatastar` handles client `AbortSignal` disconnects + gracefully and executes clean teardown logic. 3. **Proxy Buffering Invariant (`src/tests/arch/proxy_buffering.test.ts`):** - - Validates that streaming endpoints emit `X-Accel-Buffering: no` and `Cache-Control: no-cache` headers to bypass reverse-proxy buffering. + - Validates that streaming endpoints emit `X-Accel-Buffering: no` and + `Cache-Control: no-cache` headers to bypass reverse-proxy buffering. 4. **Error Fragment Morph Invariant (`src/tests/arch/error_fragment.test.ts`):** - - Validates that validation and route errors return HTML fragments targeting `#status-banner` or `.field-error`. + - Validates that validation and route errors return HTML fragments targeting + `#status-banner` or `.field-error`. 5. **XSS & Escape Fuzzing Harness (`src/tests/arch/xss_fuzzing.test.tsx`):** - - Fuzzes event names, user labels, and usernames with `