import { randomBytes } from "node:crypto"; const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const DEFAULT_VALIDATION_TTL_MS = 20_000; const DEFAULT_VALIDATION_GRACE_MS = 30_000; export function createDeviceManagerAuth({ env = process.env, fetchImpl = fetch, now = Date.now, } = {}) { const authRequired = booleanValue( env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED, env.NODE_ENV === "production", ); const serviceSlug = textValue(env.NODEDC_DEVICE_MANAGER_SERVICE_SLUG, "device-core"); const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173"); const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl); const internalToken = textValue( env.NODEDC_INTERNAL_ACCESS_TOKEN || env.NODEDC_PLATFORM_SERVICE_TOKEN, "", ); const sessionCookie = textValue( env.NODEDC_DEVICE_MANAGER_SESSION_COOKIE, "nodedc_device_manager_session", ); const sessionTtlMs = boundedInteger( env.NODEDC_DEVICE_MANAGER_SESSION_TTL_MS, DEFAULT_SESSION_TTL_MS, 60_000, 24 * 60 * 60 * 1000, ); const validationTtlMs = boundedInteger( env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_TTL_MS, DEFAULT_VALIDATION_TTL_MS, 15_000, 30_000, ); const validationGraceMs = boundedInteger( env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_GRACE_MS, DEFAULT_VALIDATION_GRACE_MS, 0, 60_000, ); const secureCookie = booleanValue( env.NODEDC_DEVICE_MANAGER_COOKIE_SECURE, authRequired, ); const sessions = new Map(); function buildCookie(value, maxAgeSeconds) { return [ `${sessionCookie}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`, ...(secureCookie ? ["Secure"] : []), ].join("; "); } function createSession(response, handoff) { pruneSessions(); const id = randomBytes(32).toString("base64url"); const createdAt = now(); sessions.set(id, { id, user: handoff.user, launcherSessionId: handoff.launcherSessionId, expiresAt: createdAt + sessionTtlMs, validatedAt: createdAt, validationInFlight: null, }); appendCookie(response, buildCookie(id, sessionTtlMs / 1000)); } function currentSession(request) { const id = parseCookies(request.headers.cookie)[sessionCookie]; const session = id ? sessions.get(id) : null; if (!session || session.expiresAt <= now()) { if (id) sessions.delete(id); return null; } return session; } async function launcherRequest(pathname, payload) { if (!internalToken) throw serviceError("device_manager_auth_not_configured", 503); const response = await fetchImpl(new URL(pathname, launcherInternalUrl), { method: "POST", headers: { Authorization: `Bearer ${internalToken}`, "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 handleHandoff(request, response, url) { const nextPath = safeReturnTo( url.searchParams.get("next_path") || url.searchParams.get("returnTo"), ); if (!authRequired) return redirect(response, nextPath); const token = String(url.searchParams.get("token") || ""); if (!token) return sendText(response, 400, "Missing Launcher handoff token."); try { const result = await launcherRequest("/api/internal/handoff/consume", { token, serviceSlug, }); if (!result.response.ok || result.body?.ok !== true || !result.body?.user) { return sendText(response, 401, "Launcher handoff rejected."); } createSession(response, { user: result.body.user, launcherSessionId: result.body.launcherSessionId ?? null, }); return redirect(response, nextPath); } catch { return sendText(response, 401, "Launcher handoff failed."); } } async function validatedSession(request, response) { if (!authRequired) { return attachSession(request, { user: { id: "local-device-admin", email: "local-device-admin@nodedc.local", name: "Local Device Admin", avatarUrl: null, groups: ["nodedc:superadmin"], }, }); } const session = currentSession(request); if (!session) { clearCookie(response); return null; } if (now() - session.validatedAt <= validationTtlMs) { return attachSession(request, session); } if (!session.validationInFlight) { session.validationInFlight = launcherRequest("/api/internal/session/validate", { serviceSlug, launcherSessionId: session.launcherSessionId, }).finally(() => { session.validationInFlight = null; }); } try { const { response: upstream, body } = await session.validationInFlight; if (upstream.ok && body?.ok === true && body.active === true) { session.user = body.user || session.user; session.validatedAt = now(); return attachSession(request, session); } if (upstream.ok && body?.ok === true && body.active === false) { sessions.delete(session.id); clearCookie(response); return null; } } catch { // Read-only grace is resolved below; mutations always fail closed. } const readOnly = request.method === "GET" || request.method === "HEAD"; if (readOnly && now() - session.validatedAt <= validationTtlMs + validationGraceMs) { return attachSession(request, session); } request.nodedcDeviceManagerAuthUnavailable = true; return null; } async function authorize(request, response, url) { if ( url.pathname === "/healthz" || url.pathname === "/auth/nodedc/handoff" || url.pathname === "/auth/logout" ) return false; const session = await validatedSession(request, response); if (!session) { if (request.nodedcDeviceManagerAuthUnavailable) { sendJson(response, 503, { ok: false, error: "device_manager_auth_unavailable" }); return true; } const loginUrl = new URL("/auth/login", launcherBaseUrl); const launch = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl); launch.searchParams.set("returnTo", safeReturnTo(`${url.pathname}${url.search}`)); loginUrl.searchParams.set("returnTo", `${launch.pathname}${launch.search}`); if (isHtmlRequest(request, url)) return redirect(response, loginUrl.toString()); sendJson(response, 401, { ok: false, error: "device_manager_auth_required", loginUrl: loginUrl.toString(), }); return true; } const access = resolveAccess(session.user); if (!access.allowed) { sendJson(response, 403, { ok: false, error: access.blocked ? "device_manager_access_blocked" : "device_manager_access_denied", }); return true; } request.nodedcDeviceManagerAccess = access; return false; } function handleLogout(request, response) { const id = parseCookies(request.headers.cookie)[sessionCookie]; if (id) sessions.delete(id); clearCookie(response); redirect(response, "/"); } function currentContext(request) { const user = request.nodedcDeviceManagerSession?.user; const access = request.nodedcDeviceManagerAccess ?? resolveAccess(user); if (!user || !access.allowed) return null; const id = cleanOpaque(user.id || user.subject || user.sub); if (!id) return null; const email = String(user.email || "").trim().slice(0, 240); const displayName = String(user.name || user.displayName || email || "NODE.DC") .trim() .slice(0, 240); const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim(); const userRef = `user:${id}`; const ownerScopes = ["admin", "owner"].includes(access.hubRole) ? [{ scopeKind: "personal", ownerRef: userRef, displayName }] : []; return { user: { id, email, displayName, avatarUrl: /^https:\/\//i.test(avatar) || avatar.startsWith("/") ? avatar : null, initials: initials(displayName), }, actor: { userRef, hubRole: access.hubRole, groupRefs: access.groups.map((group) => `group:${group}`), ownerScopes, }, profileUrl: new URL("/profile", launcherBaseUrl).toString(), }; } function clearCookie(response) { appendCookie(response, buildCookie("", 0)); } function pruneSessions() { const current = now(); for (const [id, session] of sessions) { if (session.expiresAt <= current) sessions.delete(id); } } return { authRequired, serviceSlug, authorize, currentContext, handleHandoff, handleLogout, }; } function resolveAccess(user) { if (!user || typeof user !== "object") { return { allowed: false, blocked: false, hubRole: "viewer", groups: [] }; } const groups = normalizedGroups(user); if (groups.includes("nodedc:device-core:blocked")) { return { allowed: false, blocked: true, hubRole: "viewer", groups }; } const id = cleanOpaque(user.id || user.subject || user.sub); if (!id) return { allowed: false, blocked: false, hubRole: "viewer", groups }; const hubRole = id === "user_root" || groups.includes("nodedc:superadmin") ? "owner" : groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin") ? "admin" : groups.includes("nodedc:device-core:viewer") ? "viewer" : "member"; return { allowed: true, blocked: false, hubRole, groups }; } function normalizedGroups(user) { const values = [user.groups, user.roles, user.roleKeys, user.permissions]; const groups = []; for (const value of values) { const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; for (const item of items) { const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug; const normalized = String(raw || "").trim().toLowerCase(); if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized); } } return [...new Set(groups)].sort(); } function attachSession(request, session) { request.nodedcDeviceManagerSession = session; return session; } function cleanOpaque(value) { const normalized = String(value || "").trim(); return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized) ? normalized : null; } function parseCookies(header = "") { const values = {}; for (const part of String(header).split(";")) { const index = part.indexOf("="); if (index < 1) continue; const key = part.slice(0, index).trim(); try { values[key] = decodeURIComponent(part.slice(index + 1).trim()); } catch { values[key] = part.slice(index + 1).trim(); } } return values; } function appendCookie(response, value) { const current = response.getHeader("Set-Cookie"); response.setHeader("Set-Cookie", current ? [current, value].flat() : value); } function safeReturnTo(value) { return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : "/"; } function isHtmlRequest(request, url) { return request.method === "GET" && !url.pathname.startsWith("/api/") && (url.pathname === "/" || String(request.headers.accept || "").includes("text/html")); } function redirect(response, location) { response.statusCode = 302; response.setHeader("Location", location); response.setHeader("Cache-Control", "no-store"); response.end(); } function sendJson(response, status, body) { response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify(body)); } function sendText(response, status, body) { response.statusCode = status; response.setHeader("Content-Type", "text/plain; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(body); } function initials(value) { return value.split(/\s+/).filter(Boolean).slice(0, 2) .map((part) => part[0]).join("").toUpperCase() || "DC"; } function booleanValue(value, fallback) { if (value == null || value === "") return fallback; return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); } function boundedInteger(value, fallback, min, max) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback; } function textValue(value, fallback) { return String(value || fallback).trim(); } function baseUrl(value, fallback) { return textValue(value, fallback).replace(/\/$/, ""); } function serviceError(code, statusCode) { const error = new Error(code); error.statusCode = statusCode; return error; }