import { randomBytes } from "node:crypto"; import { extname } from "node:path"; const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const DEFAULT_SESSION_VALIDATION_TTL_MS = 20_000; const MIN_SESSION_VALIDATION_TTL_MS = 15_000; const MAX_SESSION_VALIDATION_TTL_MS = 30_000; const DEFAULT_SESSION_VALIDATION_GRACE_MS = 30_000; const MAX_SESSION_VALIDATION_GRACE_MS = 60_000; const DEFAULT_SESSION_VALIDATION_RETRY_MS = 2_000; function parseBoolean(value, fallback) { if (value === undefined || value === null || value === "") return fallback; return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); } function boundedInteger(value, fallback, minimum, maximum) { const parsed = Number.parseInt(String(value ?? ""), 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(maximum, Math.max(minimum, parsed)); } function normalizedBaseUrl(value, fallback) { return String(value || fallback).trim().replace(/\/$/, ""); } function sanitizeReturnTo(value, fallback = "/") { return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : fallback; } function parseCookies(cookieHeader) { if (!cookieHeader) return {}; return Object.fromEntries( String(cookieHeader) .split(";") .flatMap((part) => { const separator = part.indexOf("="); if (separator === -1) return []; const key = part.slice(0, separator).trim(); const rawValue = part.slice(separator + 1).trim(); try { return [[key, decodeURIComponent(rawValue)]]; } catch { return [[key, rawValue]]; } }) ); } function appendSetCookie(response, cookie) { const current = response.getHeader("Set-Cookie"); if (!current) { response.setHeader("Set-Cookie", cookie); return; } response.setHeader("Set-Cookie", Array.isArray(current) ? [...current, cookie] : [current, cookie]); } function sendJson(response, statusCode, body) { response.statusCode = statusCode; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify(body)); } function sendText(response, statusCode, body) { response.statusCode = statusCode; response.setHeader("Content-Type", "text/plain; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(body); } function isHtmlNavigation(request, url) { if (request.method !== "GET" && request.method !== "HEAD") return false; if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return false; const accept = String(request.headers.accept || ""); return url.pathname === "/" || (!extname(url.pathname) && accept.includes("text/html")); } function identityGroupNames(user) { if (!user || typeof user !== "object") return new Set(); const values = [user.groups, user.roles, user.roleKeys, user.permissions]; const names = new Set(); for (const value of values) { const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; for (const item of items) { const name = typeof item === "string" ? item : item && typeof item === "object" ? item.name || item.key || item.slug || item.id : ""; const normalized = String(name || "").trim().toLowerCase(); if (normalized) names.add(normalized); } } return names; } function resolveFoundryAccess(user, authRequired) { // Local development deliberately remains operable without a Launcher. In a // deployed instance every decision below comes only from the revalidated // Launcher/Authentik session identity, never from a browser header. if (!authRequired && !user) return { role: "admin", allowed: true, admin: true }; if (!user || typeof user !== "object") return { role: "none", allowed: false, admin: false }; const id = String(user.id || user.subject || user.sub || "").trim().toLowerCase(); const groups = identityGroupNames(user); // A block is deny-first: it wins even over an accidentally retained admin // group, so Launcher can revoke a service session deterministically. if (groups.has("nodedc:module-foundry:blocked")) return { role: "blocked", allowed: false, admin: false }; if (id === "user_root" || groups.has("nodedc:superadmin") || groups.has("nodedc:module-foundry:admin")) { return { role: "admin", allowed: true, admin: true }; } if (groups.has("nodedc:module-foundry:user") || groups.has("nodedc:module-foundry:access")) { return { role: "user", allowed: true, admin: false }; } return { role: "none", allowed: false, admin: false }; } export function createFoundryAuth(options = {}) { const now = typeof options.now === "function" ? options.now : Date.now; const fetchImpl = typeof options.fetch === "function" ? options.fetch : fetch; const authRequired = parseBoolean(process.env.NODEDC_FOUNDRY_AUTH_REQUIRED, process.env.NODE_ENV === "production"); const serviceSlug = String(process.env.NODEDC_FOUNDRY_SERVICE_SLUG || "module-foundry").trim() || "module-foundry"; const launcherBaseUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173"); const launcherInternalUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl); const internalAccessToken = String( process.env.NODEDC_INTERNAL_ACCESS_TOKEN || process.env.NODEDC_PLATFORM_SERVICE_TOKEN || "" ).trim(); const sessionCookie = String(process.env.NODEDC_FOUNDRY_SESSION_COOKIE || "nodedc_foundry_session").trim(); const sessionTtlMs = Math.max( 60 * 1000, Number.parseInt(process.env.NODEDC_FOUNDRY_SESSION_TTL_MS || String(DEFAULT_SESSION_TTL_MS), 10) || DEFAULT_SESSION_TTL_MS ); // Launcher revocation remains bounded while a tile burst no longer performs // one control-plane validation per object. Production configuration is // deliberately clamped to a narrow range; tests may inject deterministic // values without changing runtime policy. const sessionValidationTtlMs = options.validationTtlMs ?? boundedInteger( process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_TTL_MS, DEFAULT_SESSION_VALIDATION_TTL_MS, MIN_SESSION_VALIDATION_TTL_MS, MAX_SESSION_VALIDATION_TTL_MS, ); const sessionValidationGraceMs = options.validationGraceMs ?? boundedInteger( process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_GRACE_MS, DEFAULT_SESSION_VALIDATION_GRACE_MS, 0, MAX_SESSION_VALIDATION_GRACE_MS, ); const sessionValidationRetryMs = options.validationRetryMs ?? boundedInteger( process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_RETRY_MS, DEFAULT_SESSION_VALIDATION_RETRY_MS, 250, 5_000, ); const cookieSecure = parseBoolean(process.env.NODEDC_FOUNDRY_COOKIE_SECURE, authRequired); const cookieSameSite = String(process.env.NODEDC_FOUNDRY_COOKIE_SAMESITE || "Lax").trim() || "Lax"; const sessions = new Map(); function emitAuthDiagnostic({ outcome, reason, launcherStatus, durationMs }) { // The allowlisted record intentionally excludes the local cookie id, // Launcher session id, internal bearer token and user identity. const record = Object.freeze({ event: "foundry_session_validation", outcome, reason, launcherStatus: Number.isInteger(launcherStatus) ? launcherStatus : null, durationMs: Math.max(0, Math.round(Number(durationMs) || 0)), serviceSlug, }); if (typeof options.onDiagnostic === "function") { try { options.onDiagnostic(record); } catch { // Diagnostics are best-effort and must never change an authorization // decision or turn a successful validation into a transient failure. } return; } const method = outcome === "active" ? "info" : "warn"; try { console[method](JSON.stringify(record)); } catch { // A broken log sink must not affect the request path. } } function buildCookie(value, maxAgeSeconds) { const parts = [ `${sessionCookie}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", `SameSite=${cookieSameSite}`, `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`, ]; if (cookieSecure) parts.push("Secure"); return parts.join("; "); } function clearSession(request, response) { const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie]; if (sessionId) sessions.delete(sessionId); appendSetCookie(response, buildCookie("", 0)); } function pruneExpiredSessions() { const currentTime = now(); for (const [id, session] of sessions.entries()) { if (!session || session.expiresAt <= currentTime) sessions.delete(id); } } function createSession(response, handoff) { pruneExpiredSessions(); const createdAtMs = now(); const id = randomBytes(32).toString("base64url"); const session = { id, user: handoff.user || null, launcherSessionId: typeof handoff.launcherSessionId === "string" ? handoff.launcherSessionId : null, createdAt: new Date(createdAtMs).toISOString(), expiresAt: createdAtMs + sessionTtlMs, lastValidatedAt: createdAtMs, validationInFlight: null, validationRetryAt: 0, lastValidationOutcome: "active", }; sessions.set(id, session); appendSetCookie(response, buildCookie(id, sessionTtlMs / 1000)); return session; } function currentSession(request) { const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie]; if (!sessionId) return null; const session = sessions.get(sessionId); if (!session || session.expiresAt <= now()) { sessions.delete(sessionId); return null; } return session; } function buildLauncherLaunchUrl(nextPath) { const launchUrl = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl); launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath)); return launchUrl.toString(); } function buildLauncherLoginUrl(nextPath) { const loginUrl = new URL("/auth/login", launcherBaseUrl); const launchUrl = new URL(buildLauncherLaunchUrl(nextPath)); loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`); return loginUrl.toString(); } async function launcherRequest(pathname, payload) { if (!internalAccessToken) { throw new Error("NODE.DC internal access configuration is not available."); } const response = await fetchImpl(new URL(pathname, launcherInternalUrl), { method: "POST", headers: { Authorization: `Bearer ${internalAccessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal: AbortSignal.timeout(8_000), }); const body = await response.json().catch(() => null); return { response, body }; } async function consumeHandoff(token) { const { response, body } = await launcherRequest("/api/internal/handoff/consume", { token, serviceSlug }); if (!response.ok || !body?.ok) { throw new Error(body?.error || `Launcher handoff failed: HTTP ${response.status}`); } return { user: body.user || null, launcherSessionId: typeof body.launcherSessionId === "string" ? body.launcherSessionId : null, }; } async function validateSession(session) { if (!authRequired) return { outcome: "active", user: session?.user || null, reason: "auth_not_required" }; if (!session?.launcherSessionId) { return { outcome: "inactive", reason: "launcher_session_missing", launcherStatus: null }; } const { response, body } = await launcherRequest("/api/internal/session/validate", { serviceSlug, launcherSessionId: session.launcherSessionId, }); if (response.ok && body?.ok === true && body.active === true) { return { outcome: "active", user: body.user || session.user || null, reason: "launcher_session_active", launcherStatus: response.status, }; } // Launcher deliberately represents a revoked/expired user session as a // successful validation response with active=false. HTTP failures here // describe the internal validation service (including its own bearer-token // configuration), so they must not destroy an otherwise valid user cookie. if (response.ok && body?.ok === true && body.active === false) { return { outcome: "inactive", reason: "launcher_session_inactive", launcherStatus: response.status, }; } return { outcome: "transient", reason: response.status >= 500 ? "launcher_unavailable" : response.status === 401 || response.status === 403 ? "launcher_internal_auth_rejected" : "launcher_validation_invalid_response", launcherStatus: response.status, }; } function isReadOnlyRequest(request) { return request.method === "GET" || request.method === "HEAD"; } function transientFailureReason(error) { return error?.name === "TimeoutError" || error?.name === "AbortError" ? "launcher_timeout" : "launcher_network_error"; } function startSessionValidation(session) { if (session.validationInFlight) return session.validationInFlight; const startedAt = now(); const validation = (async () => { try { const result = await validateSession(session); if (result.outcome === "active") { session.user = result.user; session.lastValidatedAt = now(); session.validationRetryAt = 0; } else if (result.outcome === "transient") { session.validationRetryAt = now() + sessionValidationRetryMs; } session.lastValidationOutcome = result.outcome; emitAuthDiagnostic({ outcome: result.outcome, reason: result.reason, launcherStatus: result.launcherStatus, durationMs: now() - startedAt, }); return result; } catch (error) { const result = { outcome: "transient", reason: transientFailureReason(error), launcherStatus: null, }; session.validationRetryAt = now() + sessionValidationRetryMs; session.lastValidationOutcome = result.outcome; emitAuthDiagnostic({ ...result, durationMs: now() - startedAt, }); return result; } finally { session.validationInFlight = null; } })(); session.validationInFlight = validation; return validation; } function useSession(request, session) { request.nodedcFoundrySession = session; return session; } function canUseValidationGrace(request, session) { if (!isReadOnlyRequest(request)) return false; const validationAge = Math.max(0, now() - session.lastValidatedAt); return validationAge <= sessionValidationTtlMs + sessionValidationGraceMs; } async function getValidatedSession(request, response) { const session = currentSession(request); if (!session) { // Sessions intentionally remain process-local: after a deploy/restart the // opaque cookie cannot be reconstructed safely. Expire a stale cookie and // require a fresh Launcher handoff instead of treating it as recoverable. const staleSessionId = parseCookies(request.headers.cookie || "")[sessionCookie]; if (staleSessionId) { clearSession(request, response); emitAuthDiagnostic({ outcome: "inactive", reason: "local_session_missing_or_expired", launcherStatus: null, durationMs: 0, }); } return null; } if (now() - session.lastValidatedAt <= sessionValidationTtlMs) { return useSession(request, session); } let validation; if (session.lastValidationOutcome === "transient" && now() < session.validationRetryAt) { validation = { outcome: "transient", reason: "launcher_retry_backoff", launcherStatus: null }; } else { validation = await startSessionValidation(session); } // Logout or another explicit invalidation may have removed the process-local // session while a shared validation was in flight. Never resurrect it. if (sessions.get(session.id) !== session) return null; if (validation.outcome === "active") return useSession(request, session); if (validation.outcome === "inactive") { clearSession(request, response); return null; } if (canUseValidationGrace(request, session)) return useSession(request, session); request.nodedcFoundryAuthUnavailable = true; return null; } function sendAuthRequired(request, response, url) { const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`); if (!internalAccessToken) { sendText(response, 503, "Module Foundry access is not configured on this server."); return; } const loginUrl = buildLauncherLoginUrl(nextPath); if (isHtmlNavigation(request, url)) { response.statusCode = 302; response.setHeader("Location", loginUrl); response.setHeader("Cache-Control", "no-store"); response.end(); return; } sendJson(response, 401, { ok: false, authenticated: false, error: "module_foundry_auth_required", loginUrl, }); } function sendAuthUnavailable(request, response) { if (String(request.url || "").startsWith("/api/")) { sendJson(response, 503, { ok: false, authenticated: true, error: "module_foundry_auth_unavailable", }); return; } sendText(response, 503, "Module Foundry session validation is temporarily unavailable."); } function sendAccessDenied(response, access) { sendJson(response, 403, { ok: false, authenticated: true, error: access?.role === "blocked" ? "module_foundry_access_blocked" : "module_foundry_access_denied", }); } async function handleLauncherHandoff(request, response, url) { if (!authRequired) { response.statusCode = 302; response.setHeader("Location", sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/")); response.end(); return; } const token = String(url.searchParams.get("token") || ""); const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/"); if (!token) { sendText(response, 400, "Missing Launcher handoff token."); return; } try { const handoff = await consumeHandoff(token); createSession(response, handoff); response.statusCode = 302; response.setHeader("Location", nextPath); response.setHeader("Cache-Control", "no-store"); response.end(); } catch (error) { sendText(response, 401, `Launcher handoff failed: ${error.message || "unknown error"}`); } } function handleLogout(request, response) { clearSession(request, response); response.statusCode = 302; response.setHeader("Location", "/"); response.setHeader("Cache-Control", "no-store"); response.end(); } async function ensureRequestSession(request, response, url) { if (!authRequired) return false; if (url.pathname === "/auth/nodedc/handoff" || url.pathname === "/auth/logout") return false; if (url.pathname === "/healthz") return false; if (url.pathname === "/api/mcp" || url.pathname === "/api/ai-workspace/entitlements") return false; if (await getValidatedSession(request, response)) { const access = currentUserAccess(request); if (access.allowed) return false; sendAccessDenied(response, access); return true; } if (request.nodedcFoundryAuthUnavailable) { sendAuthUnavailable(request, response); return true; } sendAuthRequired(request, response, url); return true; } function currentUserAccess(request) { return resolveFoundryAccess(request.nodedcFoundrySession?.user, authRequired); } function currentUserProfile(request) { const user = request.nodedcFoundrySession?.user; if (!user || typeof user !== "object") return null; const id = String(user.id || user.subject || user.sub || "").trim(); const email = String(user.email || "").trim(); const displayName = String(user.name || user.displayName || email || "NODE.DC").trim().slice(0, 240); const avatarCandidate = String(user.avatarUrl || user.avatar_url || user.picture || "").trim(); const avatarUrl = /^https:\/\//i.test(avatarCandidate) || avatarCandidate.startsWith("/") ? avatarCandidate : null; const initials = displayName.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "DC"; return { id, email, displayName, avatarUrl, initials }; } return { authRequired, serviceSlug, handleLauncherHandoff, handleLogout, ensureRequestSession, currentUserAccess, currentUserProfile, profileUrl: new URL("/profile", launcherBaseUrl).toString(), }; }