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