diff --git a/server/dev-server.mjs b/server/dev-server.mjs index e549b39..b787b75 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -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 ` @@ -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); `; @@ -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"); }