feat(device-manager): add standalone project workspace
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user