404 lines
13 KiB
JavaScript
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;
|
|
}
|