АРХ - NODEDC LAUNCHER: BFF OIDC и app access
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user