Validate BIM sessions against Launcher

This commit is contained in:
CODEX 2026-06-23 16:19:47 +03:00
parent 66fc4960bf
commit ffa60e4a22
1 changed files with 143 additions and 14 deletions

View File

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