557 lines
21 KiB
JavaScript
557 lines
21 KiB
JavaScript
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(),
|
|
};
|
|
}
|