Files
NODEDC_DESIGN_GUIDELINE/apps/device-manager/server/device-manager-auth.mjs
T

404 lines
13 KiB
JavaScript

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;
}