NODEDC_LAUNCHER/server/dev-server.mjs

660 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from "express";
import { createServer as createHttpServer } from "node:http";
import { randomBytes, randomUUID, createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer as createViteServer } from "vite";
import { createRemoteJWKSet, jwtVerify } from "jose";
const serverRoot = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(serverRoot, "..");
const maxStorageJsonBodyBytes = "260mb";
const pendingLoginTtlMs = 10 * 60 * 1000;
const sessionTtlMs = 12 * 60 * 60 * 1000;
const oidcStateCookieName = "nodedc_oidc_state";
const sessionCookieName = "nodedc_session";
loadEnvFiles([
process.env.NODEDC_PLATFORM_ENV,
resolve(projectRoot, ".env"),
resolve(projectRoot, "..", "..", "NODEDC", "platform", "infra", ".env"),
]);
const config = readConfig();
const app = express();
const httpServer = createHttpServer(app);
const pendingLogins = new Map();
const sessions = new Map();
let discoveryCache = null;
let jwksCache = null;
app.disable("x-powered-by");
app.use(express.json({ limit: maxStorageJsonBodyBytes }));
app.get("/healthz", (_req, res) => {
res.json({ ok: true, service: "nodedc-launcher-bff", oidcConfigured: config.oidcConfigured });
});
app.get("/auth/login", asyncRoute(async (req, res) => {
ensureOidcConfigured();
const discovery = await getOidcDiscovery();
const state = randomBase64Url(32);
const nonce = randomBase64Url(32);
const codeVerifier = randomBase64Url(64);
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const returnTo = sanitizeReturnTo(req.query.returnTo);
pruneExpiredState();
pendingLogins.set(state, {
codeVerifier,
nonce,
returnTo,
expiresAt: Date.now() + pendingLoginTtlMs,
});
res.cookie(oidcStateCookieName, state, cookieOptions(pendingLoginTtlMs));
const authorizationUrl = new URL(discovery.authorization_endpoint);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", config.clientId);
authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
authorizationUrl.searchParams.set("scope", config.scope);
authorizationUrl.searchParams.set("state", state);
authorizationUrl.searchParams.set("nonce", nonce);
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
authorizationUrl.searchParams.set("code_challenge_method", "S256");
const prompt = sanitizePrompt(req.query.prompt);
if (prompt) {
authorizationUrl.searchParams.set("prompt", prompt);
}
res.redirect(authorizationUrl.toString());
}));
app.get("/auth/callback", asyncRoute(async (req, res) => {
ensureOidcConfigured();
const error = typeof req.query.error === "string" ? req.query.error : null;
if (error) {
throw new Error(`OIDC provider returned error: ${error}`);
}
const code = typeof req.query.code === "string" ? req.query.code : null;
const state = typeof req.query.state === "string" ? req.query.state : null;
const cookieState = parseCookies(req.headers.cookie)[oidcStateCookieName];
if (!code || !state || state !== cookieState) {
throw new Error("OIDC callback state validation failed");
}
const pendingLogin = pendingLogins.get(state);
pendingLogins.delete(state);
res.clearCookie(oidcStateCookieName, clearCookieOptions());
if (!pendingLogin || pendingLogin.expiresAt < Date.now()) {
throw new Error("OIDC login state expired");
}
const discovery = await getOidcDiscovery();
const tokenSet = await exchangeCodeForTokens(discovery, code, pendingLogin.codeVerifier);
const claims = await verifyIdToken(discovery, tokenSet.id_token, pendingLogin.nonce);
const sessionId = randomBase64Url(48);
const session = {
id: sessionId,
user: normalizeUser(claims),
tokenSet: {
idToken: tokenSet.id_token,
accessToken: tokenSet.access_token ?? null,
expiresAt: tokenSet.expires_in ? Date.now() + Number(tokenSet.expires_in) * 1000 : null,
},
createdAt: Date.now(),
expiresAt: Date.now() + sessionTtlMs,
};
pruneExpiredSessions();
sessions.set(sessionId, session);
res.cookie(sessionCookieName, sessionId, cookieOptions(sessionTtlMs));
res.redirect(pendingLogin.returnTo);
}));
app.get("/auth/logout", asyncRoute(async (req, res) => {
const session = getCurrentSession(req);
const returnTo = sanitizeReturnTo(req.query.returnTo);
const globalLogout = req.query.global === "1" || req.query.global === "true";
if (session) {
sessions.delete(session.id);
}
res.clearCookie(sessionCookieName, clearCookieOptions());
if (!globalLogout || !config.oidcConfigured) {
res.redirect(returnTo);
return;
}
const discovery = await getOidcDiscovery();
const endSessionEndpoint = discovery.end_session_endpoint;
if (!endSessionEndpoint) {
res.redirect(returnTo);
return;
}
const logoutUrl = new URL(endSessionEndpoint);
logoutUrl.searchParams.set("client_id", config.clientId);
logoutUrl.searchParams.set("post_logout_redirect_uri", new URL(returnTo, config.appBaseUrl).toString());
if (session?.tokenSet.idToken) {
logoutUrl.searchParams.set("id_token_hint", session.tokenSet.idToken);
}
res.redirect(logoutUrl.toString());
}));
app.get("/api/me", (req, res) => {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
res.json({
authenticated: true,
user: session.user,
groups: session.user.groups,
isSuperAdmin: session.user.groups.includes("nodedc:superadmin"),
logoutUrl: "/auth/logout",
});
});
app.get("/api/apps", (req, res) => {
const session = getCurrentSession(req);
if (!session) {
res.status(401).json({ authenticated: false, loginUrl: "/auth/login" });
return;
}
res.json({ apps: getAppsForUser(session.user.groups) });
});
app.post("/api/storage/upload", asyncRoute(async (req, res) => {
const result = await saveUploadedFile(req.body);
res.json(result);
}));
app.post("/api/storage/data", asyncRoute(async (req, res) => {
await saveLauncherData(req.body);
res.json({ ok: true, url: "/storage/launcher-data.json" });
}));
const vite = await createViteServer({
root: projectRoot,
appType: "spa",
server: {
middlewareMode: true,
hmr: { server: httpServer },
},
});
app.use(vite.middlewares);
app.use((error, _req, res, _next) => {
vite.ssrFixStacktrace(error);
const message = error instanceof Error ? error.message : "Unexpected server error";
res.status(500).json({ error: message });
});
httpServer.listen(config.port, "0.0.0.0", () => {
console.log(`NODE.DC launcher BFF listening on http://0.0.0.0:${config.port}`);
});
function readConfig() {
const issuer = process.env.LAUNCHER_OIDC_ISSUER ?? "";
const clientId = process.env.LAUNCHER_OIDC_CLIENT_ID ?? "";
const clientSecret = process.env.LAUNCHER_OIDC_CLIENT_SECRET ?? "";
const launcherDomain = process.env.LAUNCHER_DOMAIN ?? "localhost:5173";
const appBaseUrl = process.env.LAUNCHER_BASE_URL ?? `http://${launcherDomain}`;
return {
port: Number(process.env.PORT ?? "5173"),
issuer,
clientId,
clientSecret,
redirectUri: process.env.LAUNCHER_OIDC_REDIRECT_URI ?? `${appBaseUrl}/auth/callback`,
appBaseUrl,
scope: process.env.LAUNCHER_OIDC_SCOPE ?? "openid email profile groups offline_access",
cookieDomain: process.env.LAUNCHER_COOKIE_DOMAIN || undefined,
cookieSecure: process.env.COOKIE_SECURE === "true",
oidcConfigured: Boolean(issuer && clientId && clientSecret),
};
}
function ensureOidcConfigured() {
if (!config.oidcConfigured) {
throw new Error("Launcher OIDC is not configured. Set LAUNCHER_OIDC_ISSUER, LAUNCHER_OIDC_CLIENT_ID and LAUNCHER_OIDC_CLIENT_SECRET.");
}
}
async function getOidcDiscovery() {
if (discoveryCache && discoveryCache.expiresAt > Date.now()) {
return discoveryCache.discovery;
}
const discoveryUrl = new URL("./.well-known/openid-configuration", ensureTrailingSlash(config.issuer));
const response = await fetch(discoveryUrl, { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error(`Unable to load OIDC discovery from ${discoveryUrl}: HTTP ${response.status}`);
}
const discovery = await response.json();
discoveryCache = { discovery, expiresAt: Date.now() + 5 * 60 * 1000 };
return discovery;
}
async function exchangeCodeForTokens(discovery, code, codeVerifier) {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.redirectUri,
code_verifier: codeVerifier,
});
const response = await fetch(discovery.token_endpoint, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OIDC token exchange failed: HTTP ${response.status} ${errorText}`);
}
const tokenSet = await response.json();
if (!tokenSet.id_token) {
throw new Error("OIDC token response does not contain id_token");
}
return tokenSet;
}
async function verifyIdToken(discovery, idToken, nonce) {
if (!jwksCache || jwksCache.uri !== discovery.jwks_uri) {
jwksCache = {
uri: discovery.jwks_uri,
jwks: createRemoteJWKSet(new URL(discovery.jwks_uri)),
};
}
const { payload } = await jwtVerify(idToken, jwksCache.jwks, {
issuer: discovery.issuer ?? config.issuer,
audience: config.clientId,
});
if (payload.nonce !== nonce) {
throw new Error("OIDC nonce validation failed");
}
return payload;
}
function normalizeUser(claims) {
const groups = normalizeGroups(claims.groups);
const email = typeof claims.email === "string" ? claims.email : "";
const name =
typeof claims.name === "string" && claims.name
? claims.name
: typeof claims.preferred_username === "string" && claims.preferred_username
? claims.preferred_username
: email || String(claims.sub);
return {
sub: String(claims.sub),
email,
name,
preferredUsername: typeof claims.preferred_username === "string" ? claims.preferred_username : null,
groups,
};
}
function normalizeGroups(groupsClaim) {
if (Array.isArray(groupsClaim)) {
return [...new Set(groupsClaim.filter((group) => typeof group === "string"))];
}
if (typeof groupsClaim === "string" && groupsClaim) {
return [groupsClaim];
}
return [];
}
function getAppsForUser(userGroups) {
const groupSet = new Set(userGroups);
const catalog = getAppCatalog();
return catalog.map((app) => {
const matchedGroups = app.requiredGroups.filter((group) => groupSet.has(group));
const isSuperAdmin = groupSet.has("nodedc:superadmin");
const isPublic = app.requiredGroups.length === 0;
const hasAccess = isSuperAdmin || isPublic || matchedGroups.length > 0;
return {
...app,
matchedGroups: isSuperAdmin ? ["nodedc:superadmin", ...matchedGroups] : matchedGroups,
hasAccess,
accessReason: hasAccess ? "Доступ подтверждён" : "Нет доступа",
};
});
}
function getAppCatalog() {
const launcherData = readLauncherData();
const services = Array.isArray(launcherData?.services) ? launcherData.services : [];
const serviceCatalog = services.map((service) => {
const specialGroups = specialRequiredGroups(service.slug);
const requiredGroups = specialGroups.length
? specialGroups
: service.authentikGroupName
? [service.authentikGroupName]
: [];
return {
id: service.id,
slug: service.slug,
title: service.title,
description: service.description,
url: getServiceUrl(service),
openUrl: getServiceUrl(service),
status: service.status ?? "disabled",
provider: "authentik",
requiredGroups,
media: {
icon: service.iconUrl ?? null,
coverImage: service.coverImageUrl ?? null,
accentColor: service.accentColor ?? null,
},
};
});
return [
{
id: "launcher",
slug: "launcher",
title: "NODE.DC Launcher",
description: "Единая точка входа в приложения NODE.DC.",
url: config.appBaseUrl,
openUrl: config.appBaseUrl,
status: "active",
provider: "authentik",
requiredGroups: ["nodedc:launcher:admin", "nodedc:launcher:user"],
},
...serviceCatalog.filter((service) => service.slug !== "launcher"),
];
}
function specialRequiredGroups(slug) {
if (slug === "launcher" || slug === "nodedc") return ["nodedc:launcher:admin", "nodedc:launcher:user"];
if (slug === "task-manager") return ["nodedc:taskmanager:admin", "nodedc:taskmanager:user"];
return [];
}
function getServiceUrl(service) {
if (service.slug === "task-manager") {
const taskBaseUrl = process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`;
return `${taskBaseUrl.replace(/\/$/, "")}/auth/oidc/login/`;
}
return service.launchUrl || service.url || "#";
}
function readLauncherData() {
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
try {
if (!existsSync(dataPath)) return null;
return JSON.parse(readFileSync(dataPath, "utf8"));
} catch {
return null;
}
}
async function saveUploadedFile(payload) {
if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки");
}
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
if (!match) {
throw new Error("Файл должен прийти data-url с base64");
}
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
const storedName = buildStoredFileName(payload.fileName, mimeType);
const fileBuffer = Buffer.from(match[2], "base64");
await Promise.all(
getWritableStorageRoots().map(async (storageRoot) => {
const uploadDir = join(storageRoot, "uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, storedName), fileBuffer);
})
);
return {
ok: true,
url: `/storage/uploads/${storedName}`,
fileName: storedName,
originalFileName: payload.fileName,
mimeType,
};
}
async function saveLauncherData(payload) {
await Promise.all(
getWritableStorageRoots().map(async (storageRoot) => {
await mkdir(storageRoot, { recursive: true });
await writeFile(join(storageRoot, "launcher-data.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
})
);
}
function getWritableStorageRoots() {
const roots = [join(projectRoot, "public", "storage")];
const distRoot = join(projectRoot, "dist");
if (existsSync(distRoot)) {
roots.push(join(distRoot, "storage"));
}
return roots;
}
function buildStoredFileName(fileName, mimeType) {
const extension = extname(fileName) || extensionFromMimeType(mimeType);
const rawBase = fileName.slice(0, extension ? -extension.length : undefined);
const safeBase =
rawBase
.normalize("NFKD")
.replace(/[^\w.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "upload";
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBase}${extension.toLowerCase()}`;
}
function extensionFromMimeType(mimeType) {
if (mimeType === "image/jpeg") return ".jpg";
if (mimeType === "image/png") return ".png";
if (mimeType === "image/gif") return ".gif";
if (mimeType === "image/webp") return ".webp";
if (mimeType === "video/mp4") return ".mp4";
if (mimeType === "video/webm") return ".webm";
if (mimeType === "video/quicktime") return ".mov";
return "";
}
function isUploadPayload(payload) {
return Boolean(
payload &&
typeof payload === "object" &&
typeof payload.fileName === "string" &&
typeof payload.mimeType === "string" &&
typeof payload.dataUrl === "string"
);
}
function getCurrentSession(req) {
const sessionId = parseCookies(req.headers.cookie)[sessionCookieName];
if (!sessionId) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return session;
}
function pruneExpiredSessions() {
for (const [sessionId, session] of sessions) {
if (session.expiresAt < Date.now()) {
sessions.delete(sessionId);
}
}
}
function pruneExpiredState() {
for (const [state, pendingLogin] of pendingLogins) {
if (pendingLogin.expiresAt < Date.now()) {
pendingLogins.delete(state);
}
}
}
function parseCookies(cookieHeader) {
if (!cookieHeader) return {};
return Object.fromEntries(
cookieHeader.split(";").flatMap((part) => {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) return [];
const key = part.slice(0, separatorIndex).trim();
const value = part.slice(separatorIndex + 1).trim();
return [[key, decodeURIComponent(value)]];
})
);
}
function cookieOptions(maxAgeMs) {
const options = {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/",
maxAge: maxAgeMs,
};
if (config.cookieDomain) {
options.domain = config.cookieDomain;
}
return options;
}
function clearCookieOptions() {
const options = {
httpOnly: true,
sameSite: "lax",
secure: config.cookieSecure,
path: "/",
};
if (config.cookieDomain) {
options.domain = config.cookieDomain;
}
return options;
}
function randomBase64Url(size) {
return randomBytes(size).toString("base64url");
}
function sanitizeReturnTo(returnTo) {
if (typeof returnTo !== "string" || !returnTo.startsWith("/") || returnTo.startsWith("//")) {
return "/";
}
return returnTo;
}
function sanitizePrompt(prompt) {
if (prompt === "login" || prompt === "none" || prompt === "consent" || prompt === "select_account") {
return prompt;
}
return null;
}
function ensureTrailingSlash(value) {
return value.endsWith("/") ? value : `${value}/`;
}
function asyncRoute(handler) {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
function loadEnvFiles(candidates) {
for (const candidate of candidates) {
if (!candidate) continue;
const envPath = resolve(projectRoot, candidate);
if (!existsSync(envPath)) continue;
const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
const separatorIndex = trimmed.indexOf("=");
const key = trimmed.slice(0, separatorIndex).trim();
const value = stripEnvQuotes(trimmed.slice(separatorIndex + 1).trim());
if (!process.env[key]) {
process.env[key] = value;
}
}
}
}
function stripEnvQuotes(value) {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
return value;
}