From ffa60e4a227aafb6f89659ac68116ef485ef3e3d Mon Sep 17 00:00:00 2001 From: CODEX Date: Tue, 23 Jun 2026 16:19:47 +0300 Subject: [PATCH] Validate BIM sessions against Launcher --- server/index.js | 157 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 14 deletions(-) diff --git a/server/index.js b/server/index.js index 08c9e4b..5fe231d 100644 --- a/server/index.js +++ b/server/index.js @@ -53,7 +53,9 @@ const INTERNAL_ACCESS_TOKEN = process.env.NODEDC_PLATFORM_SERVICE_TOKEN || ""; const BIM_SESSION_COOKIE = process.env.NODEDC_BIM_SESSION_COOKIE || "nodedc_bim_session"; +const BIM_HANDOFF_COOKIE = process.env.NODEDC_BIM_HANDOFF_COOKIE || "nodedc_bim_handoff"; const BIM_SESSION_TTL_MS = Number(process.env.NODEDC_BIM_SESSION_TTL_MS || 12 * 60 * 60 * 1000); +const BIM_HANDOFF_TTL_SECONDS = Number(process.env.NODEDC_BIM_HANDOFF_TTL_SECONDS || 60); const BIM_COOKIE_SECURE = parseBooleanEnv(process.env.NODEDC_BIM_COOKIE_SECURE, false); const BIM_COOKIE_SAMESITE = process.env.NODEDC_BIM_COOKIE_SAMESITE || (BIM_COOKIE_SECURE ? "None" : "Lax"); const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest"; @@ -757,8 +759,25 @@ const buildCookieHeader = (name, value, maxAgeSeconds) => { return parts.join("; "); }; +const appendSetCookieHeader = (res, cookie) => { + const existing = res.getHeader("Set-Cookie"); + if (!existing) { + res.setHeader("Set-Cookie", cookie); + return; + } + res.setHeader("Set-Cookie", Array.isArray(existing) ? [...existing, cookie] : [existing, cookie]); +}; + const clearBimSessionCookie = (res) => { - res.setHeader("Set-Cookie", buildCookieHeader(BIM_SESSION_COOKIE, "", 0)); + appendSetCookieHeader(res, buildCookieHeader(BIM_SESSION_COOKIE, "", 0)); +}; + +const setBimHandoffCookie = (res) => { + appendSetCookieHeader(res, buildCookieHeader(BIM_HANDOFF_COOKIE, randomBase64Url(16), BIM_HANDOFF_TTL_SECONDS)); +}; + +const clearBimHandoffCookie = (res) => { + appendSetCookieHeader(res, buildCookieHeader(BIM_HANDOFF_COOKIE, "", 0)); }; const pruneExpiredBimSessions = () => { @@ -770,17 +789,18 @@ const pruneExpiredBimSessions = () => { } }; -const createBimSession = (res, user) => { +const createBimSession = (res, user, {launcherSessionId = null} = {}) => { pruneExpiredBimSessions(); const id = randomBase64Url(32); const session = { id, user, + launcherSessionId: typeof launcherSessionId === "string" && launcherSessionId ? launcherSessionId : null, createdAt: nowIso(), expiresAt: Date.now() + BIM_SESSION_TTL_MS }; bimSessions.set(id, session); - res.setHeader("Set-Cookie", buildCookieHeader(BIM_SESSION_COOKIE, id, BIM_SESSION_TTL_MS / 1000)); + appendSetCookieHeader(res, buildCookieHeader(BIM_SESSION_COOKIE, id, BIM_SESSION_TTL_MS / 1000)); return session; }; @@ -797,6 +817,79 @@ const getCurrentBimSession = (req) => { return session; }; +const destroyCurrentBimSession = (req, res) => { + const sessionId = parseCookies(req.headers.cookie || "")[BIM_SESSION_COOKIE]; + if (sessionId) { + bimSessions.delete(sessionId); + } + clearBimSessionCookie(res); +}; + +const validateBimSessionWithLauncher = async (session) => { + if (!BIM_AUTH_REQUIRED) { + return {active: true, user: session?.user || null}; + } + if (!INTERNAL_ACCESS_TOKEN || !session?.launcherSessionId) { + return {active: false, error: "launcher_session_missing"}; + } + const response = await fetch(new URL("/api/internal/session/validate", LAUNCHER_INTERNAL_BASE_URL), { + method: "POST", + headers: { + "Authorization": `Bearer ${INTERNAL_ACCESS_TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + serviceSlug: BIM_SERVICE_SLUG, + launcherSessionId: session.launcherSessionId + }) + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.ok || payload.active !== true) { + return {active: false, error: payload?.error || `launcher_session_invalid_${response.status}`}; + } + return {active: true, user: payload.user || session.user}; +}; + +const getValidatedBimSession = async (req, res = null) => { + const session = getCurrentBimSession(req); + if (!session) { + return null; + } + try { + const validation = await validateBimSessionWithLauncher(session); + if (validation.active) { + if (validation.user) { + session.user = validation.user; + } + return session; + } + bimSessions.delete(session.id); + if (res) { + clearBimSessionCookie(res); + clearBimHandoffCookie(res); + } + return null; + } catch (err) { + console.warn("[server] Launcher session validation failed", err?.message || err); + bimSessions.delete(session.id); + if (res) { + clearBimSessionCookie(res); + clearBimHandoffCookie(res); + } + return null; + } +}; + +const consumeBimHandoffCookie = (req, res) => { + const token = parseCookies(req.headers.cookie || "")[BIM_HANDOFF_COOKIE]; + if (!token) { + return false; + } + req.__bimHandoffNavigation = true; + clearBimHandoffCookie(res); + return true; +}; + const getRequestBaseUrl = (req) => { if (BIM_PUBLIC_BASE_URL) { return BIM_PUBLIC_BASE_URL; @@ -853,7 +946,7 @@ const shouldPromoteShareNavigation = (req, url) => { if (url.searchParams.get(SHARE_AUTH_GUEST_PARAM) === "1") { return false; } - if (getCurrentBimSession(req)) { + if (req.__bimHandoffNavigation) { return false; } const accept = typeof req.headers.accept === "string" ? req.headers.accept : ""; @@ -872,6 +965,33 @@ const redirectShareNavigationToPromotion = (res, token) => { return true; }; +const currentRequestReturnPath = (url) => sanitizeReturnTo(`${url.pathname}${url.search || ""}`); + +const handleLauncherOwnedNavigation = (req, res, url) => { + if (!BIM_AUTH_REQUIRED || !INTERNAL_ACCESS_TOKEN || !isHtmlNavigation(req, url)) { + return false; + } + if (isPublicSharePath(url) && url.searchParams.get(SHARE_AUTH_GUEST_PARAM) === "1") { + destroyCurrentBimSession(req, res); + clearBimHandoffCookie(res); + return false; + } + if (consumeBimHandoffCookie(req, res)) { + return false; + } + if (isPublicSharePath(url)) { + return redirectShareNavigationToPromotion(res, shareTokenFromPath(url.pathname)); + } + if (getCurrentBimSession(req)) { + res.statusCode = 302; + res.setHeader("Location", buildLauncherLaunchUrl(currentRequestReturnPath(url))); + res.setHeader("Cache-Control", "no-store"); + res.end(); + return true; + } + return false; +}; + const sendAuthRequired = (req, res, url) => { const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`); if (!INTERNAL_ACCESS_TOKEN) { @@ -939,11 +1059,11 @@ const requestNeedsBimSession = (req, url) => { return isHtmlNavigation(req, url); }; -const ensureBimSessionForRequest = (req, res, url) => { +const ensureBimSessionForRequest = async (req, res, url) => { if (!requestNeedsBimSession(req, url)) { return false; } - if (getCurrentBimSession(req)) { + if (await getValidatedBimSession(req, res)) { return false; } sendAuthRequired(req, res, url); @@ -969,7 +1089,10 @@ const consumeLauncherHandoff = async (token) => { if (!response.ok || !payload?.ok) { throw new Error(payload?.error || `Launcher handoff failed: HTTP ${response.status}`); } - return payload.user; + return { + user: payload.user, + launcherSessionId: typeof payload.launcherSessionId === "string" ? payload.launcherSessionId : null + }; }; const safeProjectId = (id) => { @@ -1623,8 +1746,8 @@ const handleGetUploadVersions = async (req, res, searchParams) => { }); }; -const handleAuthSession = (req, res) => { - const session = getCurrentBimSession(req); +const handleAuthSession = async (req, res) => { + const session = await getValidatedBimSession(req, res); sendJSON(res, 200, { ok: true, authRequired: BIM_AUTH_REQUIRED, @@ -1643,8 +1766,9 @@ const handleLauncherHandoff = async (req, res, url) => { return; } try { - const user = await consumeLauncherHandoff(token); - createBimSession(res, user); + const handoff = await consumeLauncherHandoff(token); + createBimSession(res, handoff.user, {launcherSessionId: handoff.launcherSessionId}); + setBimHandoffCookie(res); res.statusCode = 302; res.setHeader("Location", nextPath); res.end(); @@ -1691,6 +1815,7 @@ const handleLogout = (req, res, {redirect = true} = {}) => { bimSessions.delete(sessionId); } clearBimSessionCookie(res); + clearBimHandoffCookie(res); res.setHeader("Cache-Control", "no-store"); if (redirect) { res.statusCode = 302; @@ -1704,7 +1829,7 @@ const handleLogout = (req, res, {redirect = true} = {}) => { }; const handleCreateShare = async (req, res) => { - const session = getCurrentBimSession(req); + const session = await getValidatedBimSession(req, res); if (BIM_AUTH_REQUIRED && !session) { sendJSON(res, 401, {ok: false, error: "bim_auth_required"}); return; @@ -1807,7 +1932,7 @@ const handleGetShare = async (req, res, token) => { sendText(res, 404, "Share not found"); return; } - const session = getCurrentBimSession(req); + const session = await getValidatedBimSession(req, res); const authenticated = !!session; const promotionUrls = BIM_AUTH_REQUIRED && !authenticated ? buildSharePromotionUrls(safeToken) @@ -3315,6 +3440,10 @@ const requestHandler = async (req, res) => { return handleLogout(req, res, {redirect: false}); } + if (handleLauncherOwnedNavigation(req, res, url)) { + return; + } + if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) { return handleGetShare(req, res, shareTokenFromPath(url.pathname)); } @@ -3323,7 +3452,7 @@ const requestHandler = async (req, res) => { return; } - if (ensureBimSessionForRequest(req, res, url)) { + if (await ensureBimSessionForRequest(req, res, url)) { return; }