NODEDC_DESIGN_GUIDELINE/server/nodedc-auth.mjs

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(),
};
}