Validate BIM sessions against Launcher
This commit is contained in:
parent
66fc4960bf
commit
ffa60e4a22
157
server/index.js
157
server/index.js
|
|
@ -53,7 +53,9 @@ const INTERNAL_ACCESS_TOKEN =
|
||||||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
|
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
|
||||||
"";
|
"";
|
||||||
const BIM_SESSION_COOKIE = process.env.NODEDC_BIM_SESSION_COOKIE || "nodedc_bim_session";
|
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_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_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 BIM_COOKIE_SAMESITE = process.env.NODEDC_BIM_COOKIE_SAMESITE || (BIM_COOKIE_SECURE ? "None" : "Lax");
|
||||||
const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest";
|
const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest";
|
||||||
|
|
@ -757,8 +759,25 @@ const buildCookieHeader = (name, value, maxAgeSeconds) => {
|
||||||
return parts.join("; ");
|
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) => {
|
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 = () => {
|
const pruneExpiredBimSessions = () => {
|
||||||
|
|
@ -770,17 +789,18 @@ const pruneExpiredBimSessions = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBimSession = (res, user) => {
|
const createBimSession = (res, user, {launcherSessionId = null} = {}) => {
|
||||||
pruneExpiredBimSessions();
|
pruneExpiredBimSessions();
|
||||||
const id = randomBase64Url(32);
|
const id = randomBase64Url(32);
|
||||||
const session = {
|
const session = {
|
||||||
id,
|
id,
|
||||||
user,
|
user,
|
||||||
|
launcherSessionId: typeof launcherSessionId === "string" && launcherSessionId ? launcherSessionId : null,
|
||||||
createdAt: nowIso(),
|
createdAt: nowIso(),
|
||||||
expiresAt: Date.now() + BIM_SESSION_TTL_MS
|
expiresAt: Date.now() + BIM_SESSION_TTL_MS
|
||||||
};
|
};
|
||||||
bimSessions.set(id, session);
|
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;
|
return session;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -797,6 +817,79 @@ const getCurrentBimSession = (req) => {
|
||||||
return session;
|
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) => {
|
const getRequestBaseUrl = (req) => {
|
||||||
if (BIM_PUBLIC_BASE_URL) {
|
if (BIM_PUBLIC_BASE_URL) {
|
||||||
return 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") {
|
if (url.searchParams.get(SHARE_AUTH_GUEST_PARAM) === "1") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (getCurrentBimSession(req)) {
|
if (req.__bimHandoffNavigation) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
|
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
|
||||||
|
|
@ -872,6 +965,33 @@ const redirectShareNavigationToPromotion = (res, token) => {
|
||||||
return true;
|
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 sendAuthRequired = (req, res, url) => {
|
||||||
const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`);
|
const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`);
|
||||||
if (!INTERNAL_ACCESS_TOKEN) {
|
if (!INTERNAL_ACCESS_TOKEN) {
|
||||||
|
|
@ -939,11 +1059,11 @@ const requestNeedsBimSession = (req, url) => {
|
||||||
return isHtmlNavigation(req, url);
|
return isHtmlNavigation(req, url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureBimSessionForRequest = (req, res, url) => {
|
const ensureBimSessionForRequest = async (req, res, url) => {
|
||||||
if (!requestNeedsBimSession(req, url)) {
|
if (!requestNeedsBimSession(req, url)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (getCurrentBimSession(req)) {
|
if (await getValidatedBimSession(req, res)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
sendAuthRequired(req, res, url);
|
sendAuthRequired(req, res, url);
|
||||||
|
|
@ -969,7 +1089,10 @@ const consumeLauncherHandoff = async (token) => {
|
||||||
if (!response.ok || !payload?.ok) {
|
if (!response.ok || !payload?.ok) {
|
||||||
throw new Error(payload?.error || `Launcher handoff failed: HTTP ${response.status}`);
|
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) => {
|
const safeProjectId = (id) => {
|
||||||
|
|
@ -1623,8 +1746,8 @@ const handleGetUploadVersions = async (req, res, searchParams) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAuthSession = (req, res) => {
|
const handleAuthSession = async (req, res) => {
|
||||||
const session = getCurrentBimSession(req);
|
const session = await getValidatedBimSession(req, res);
|
||||||
sendJSON(res, 200, {
|
sendJSON(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
authRequired: BIM_AUTH_REQUIRED,
|
authRequired: BIM_AUTH_REQUIRED,
|
||||||
|
|
@ -1643,8 +1766,9 @@ const handleLauncherHandoff = async (req, res, url) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const user = await consumeLauncherHandoff(token);
|
const handoff = await consumeLauncherHandoff(token);
|
||||||
createBimSession(res, user);
|
createBimSession(res, handoff.user, {launcherSessionId: handoff.launcherSessionId});
|
||||||
|
setBimHandoffCookie(res);
|
||||||
res.statusCode = 302;
|
res.statusCode = 302;
|
||||||
res.setHeader("Location", nextPath);
|
res.setHeader("Location", nextPath);
|
||||||
res.end();
|
res.end();
|
||||||
|
|
@ -1691,6 +1815,7 @@ const handleLogout = (req, res, {redirect = true} = {}) => {
|
||||||
bimSessions.delete(sessionId);
|
bimSessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
clearBimSessionCookie(res);
|
clearBimSessionCookie(res);
|
||||||
|
clearBimHandoffCookie(res);
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
res.statusCode = 302;
|
res.statusCode = 302;
|
||||||
|
|
@ -1704,7 +1829,7 @@ const handleLogout = (req, res, {redirect = true} = {}) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateShare = async (req, res) => {
|
const handleCreateShare = async (req, res) => {
|
||||||
const session = getCurrentBimSession(req);
|
const session = await getValidatedBimSession(req, res);
|
||||||
if (BIM_AUTH_REQUIRED && !session) {
|
if (BIM_AUTH_REQUIRED && !session) {
|
||||||
sendJSON(res, 401, {ok: false, error: "bim_auth_required"});
|
sendJSON(res, 401, {ok: false, error: "bim_auth_required"});
|
||||||
return;
|
return;
|
||||||
|
|
@ -1807,7 +1932,7 @@ const handleGetShare = async (req, res, token) => {
|
||||||
sendText(res, 404, "Share not found");
|
sendText(res, 404, "Share not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = getCurrentBimSession(req);
|
const session = await getValidatedBimSession(req, res);
|
||||||
const authenticated = !!session;
|
const authenticated = !!session;
|
||||||
const promotionUrls = BIM_AUTH_REQUIRED && !authenticated
|
const promotionUrls = BIM_AUTH_REQUIRED && !authenticated
|
||||||
? buildSharePromotionUrls(safeToken)
|
? buildSharePromotionUrls(safeToken)
|
||||||
|
|
@ -3315,6 +3440,10 @@ const requestHandler = async (req, res) => {
|
||||||
return handleLogout(req, res, {redirect: false});
|
return handleLogout(req, res, {redirect: false});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (handleLauncherOwnedNavigation(req, res, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) {
|
if (req.method === "GET" && /^\/api\/shares\/[^/]+\/?$/.test(url.pathname)) {
|
||||||
return handleGetShare(req, res, shareTokenFromPath(url.pathname));
|
return handleGetShare(req, res, shareTokenFromPath(url.pathname));
|
||||||
}
|
}
|
||||||
|
|
@ -3323,7 +3452,7 @@ const requestHandler = async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ensureBimSessionForRequest(req, res, url)) {
|
if (await ensureBimSessionForRequest(req, res, url)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue