ФУНКЦИИ - NODEDC LAUNCHER: harden global logout flow

This commit is contained in:
DCCONSTRUCTIONS 2026-05-05 08:40:59 +03:00
parent 049b914916
commit 96e6a97d38
1 changed files with 71 additions and 13 deletions

View File

@ -18,6 +18,7 @@ const sessionTtlMs = 12 * 60 * 60 * 1000;
const oidcStateCookieName = "nodedc_oidc_state";
const maxOidcStateCookieEntries = 8;
const sessionCookieName = "nodedc_session";
const noStoreCacheControl = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
loadEnvFiles([
process.env.NODEDC_PLATFORM_ENV,
@ -37,6 +38,13 @@ let discoveryCache = null;
let jwksCache = null;
app.disable("x-powered-by");
app.use((req, res, next) => {
if (shouldDisableHttpCache(req)) {
lockNoStoreHeaders(res);
}
next();
});
app.use(express.json({ limit: maxStorageJsonBodyBytes }));
app.get("/healthz", (_req, res) => {
@ -85,6 +93,10 @@ app.get("/auth/login", asyncRoute(async (req, res) => {
authorizationUrl.searchParams.set("prompt", prompt);
}
if (prompt === "login") {
authorizationUrl.searchParams.set("max_age", "0");
}
res.redirect(authorizationUrl.toString());
}));
@ -175,24 +187,22 @@ app.get("/auth/logout", asyncRoute(async (req, res) => {
const discovery = await getOidcDiscovery();
const endSessionEndpoint = discovery.end_session_endpoint;
const loginRedirectUrl = buildLoginRedirectUrl(returnTo, { forceLogin: true });
const postLogoutRedirectUrl = buildLoggedOutRedirectUrl();
if (!endSessionEndpoint) {
setNoStore(res);
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), loginRedirectUrl));
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), null, loginRedirectUrl));
return;
}
const logoutUrl = new URL(endSessionEndpoint);
logoutUrl.searchParams.set("client_id", config.clientId);
logoutUrl.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUrl);
if (session?.tokenSet.idToken) {
logoutUrl.searchParams.set("id_token_hint", session.tokenSet.idToken);
}
setNoStore(res);
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), logoutUrl.toString()));
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), logoutUrl.toString(), loginRedirectUrl));
}));
app.get("/api/me", (req, res) => {
@ -568,7 +578,6 @@ function readConfig() {
clientId,
clientSecret,
redirectUri: process.env.LAUNCHER_OIDC_REDIRECT_URI ?? `${appBaseUrl}/auth/callback`,
loggedOutRedirectUri: process.env.LAUNCHER_OIDC_LOGGED_OUT_REDIRECT_URI ?? `${appBaseUrl}/auth/logged-out`,
appBaseUrl,
scope: process.env.LAUNCHER_OIDC_SCOPE ?? "openid email profile groups offline_access",
cookieDomain: process.env.LAUNCHER_COOKIE_DOMAIN || undefined,
@ -940,8 +949,9 @@ function normalizeLogoutUrl(value) {
}
}
function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
function renderGlobalLogoutPage(frontchannelLogoutUrls, identityProviderLogoutUrl, finalRedirectUrl) {
const logoutUrlsJson = JSON.stringify(frontchannelLogoutUrls);
const identityProviderLogoutUrlJson = JSON.stringify(identityProviderLogoutUrl);
const redirectUrlJson = JSON.stringify(finalRedirectUrl);
return `<!doctype html>
@ -979,6 +989,7 @@ function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
localStorage.setItem("nodedc:platform-session-event", JSON.stringify(eventPayload));
} catch {}
const logoutUrls = ${logoutUrlsJson};
const identityProviderLogoutUrl = ${identityProviderLogoutUrlJson};
const finalRedirectUrl = ${redirectUrlJson};
for (const logoutUrl of logoutUrls) {
fetch(logoutUrl, { mode: "no-cors", credentials: "include", keepalive: true }).catch(() => undefined);
@ -986,7 +997,22 @@ function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
image.referrerPolicy = "no-referrer";
image.src = logoutUrl;
}
window.setTimeout(() => window.location.replace(finalRedirectUrl), 900);
if (identityProviderLogoutUrl) {
fetch(identityProviderLogoutUrl, { mode: "no-cors", credentials: "include", keepalive: true }).catch(() => undefined);
const iframe = document.createElement("iframe");
iframe.title = "NODE.DC identity logout";
iframe.tabIndex = -1;
iframe.setAttribute("aria-hidden", "true");
iframe.style.position = "fixed";
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.opacity = "0";
iframe.style.pointerEvents = "none";
iframe.style.border = "0";
iframe.src = identityProviderLogoutUrl;
document.body.appendChild(iframe);
}
window.setTimeout(() => window.location.replace(finalRedirectUrl), 1200);
</script>
</body>
</html>`;
@ -1375,8 +1401,45 @@ function clearCookieOptions() {
return options;
}
function shouldDisableHttpCache(req) {
if (req.path.startsWith("/api/") || req.path.startsWith("/auth/")) {
return true;
}
if (req.method !== "GET" && req.method !== "HEAD") {
return false;
}
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
return accept.includes("text/html");
}
function lockNoStoreHeaders(res) {
const setHeader = res.setHeader.bind(res);
setNoStore(res);
res.setHeader = (name, value) => {
const normalizedName = String(name).toLowerCase();
if (normalizedName === "cache-control") {
return setHeader(name, noStoreCacheControl);
}
if (normalizedName === "pragma") {
return setHeader(name, "no-cache");
}
if (normalizedName === "expires") {
return setHeader(name, "0");
}
return setHeader(name, value);
};
}
function setNoStore(res) {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0");
res.setHeader("Cache-Control", noStoreCacheControl);
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
@ -1396,11 +1459,6 @@ function buildLoginRedirectUrl(returnTo, { forceLogin = false } = {}) {
return loginUrl.toString();
}
function buildLoggedOutRedirectUrl() {
const loggedOutUrl = new URL(config.loggedOutRedirectUri);
return loggedOutUrl.toString();
}
function randomBase64Url(size) {
return randomBytes(size).toString("base64url");
}