ФУНКЦИИ - NODEDC LAUNCHER: sync logout across tabs
This commit is contained in:
parent
316bb0a1df
commit
049b914916
|
|
@ -145,6 +145,16 @@ app.get("/auth/logged-out", (req, res) => {
|
||||||
res.redirect(buildLoginRedirectUrl(returnTo, { forceLogin: true }));
|
res.redirect(buildLoginRedirectUrl(returnTo, { forceLogin: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/auth/session-sync", (req, res) => {
|
||||||
|
const allowedOrigins = getSessionSyncAllowedOrigins();
|
||||||
|
setNoStore(res);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors ${allowedOrigins.join(" ")}`
|
||||||
|
);
|
||||||
|
res.type("html").send(renderSessionSyncBridgePage(allowedOrigins));
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/auth/logout", asyncRoute(async (req, res) => {
|
app.get("/auth/logout", asyncRoute(async (req, res) => {
|
||||||
const session = getCurrentSession(req);
|
const session = getCurrentSession(req);
|
||||||
const returnTo = sanitizeReturnTo(req.query.returnTo);
|
const returnTo = sanitizeReturnTo(req.query.returnTo);
|
||||||
|
|
@ -954,6 +964,20 @@ function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
|
||||||
<p>Закрываем сессии подключённых приложений и платформенный вход.</p>
|
<p>Закрываем сессии подключённых приложений и платформенный вход.</p>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
const eventPayload = {
|
||||||
|
type: "nodedc:session:logout",
|
||||||
|
id: globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : String(Date.now()) + "-" + Math.random().toString(36).slice(2),
|
||||||
|
source: "launcher-global-logout",
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const channel = new BroadcastChannel("nodedc-platform-session");
|
||||||
|
channel.postMessage(eventPayload);
|
||||||
|
channel.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
localStorage.setItem("nodedc:platform-session-event", JSON.stringify(eventPayload));
|
||||||
|
} catch {}
|
||||||
const logoutUrls = ${logoutUrlsJson};
|
const logoutUrls = ${logoutUrlsJson};
|
||||||
const finalRedirectUrl = ${redirectUrlJson};
|
const finalRedirectUrl = ${redirectUrlJson};
|
||||||
for (const logoutUrl of logoutUrls) {
|
for (const logoutUrl of logoutUrls) {
|
||||||
|
|
@ -968,6 +992,91 @@ function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSessionSyncBridgePage(allowedOrigins) {
|
||||||
|
const allowedOriginsJson = JSON.stringify(allowedOrigins);
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>NODE.DC session sync</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
const allowedOrigins = new Set(${allowedOriginsJson});
|
||||||
|
const parentOrigin = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(document.referrer).origin;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const channelName = "nodedc-platform-session";
|
||||||
|
const storageKey = "nodedc:platform-session-event";
|
||||||
|
let lastEventId = null;
|
||||||
|
|
||||||
|
function isAllowedOrigin(origin) {
|
||||||
|
return allowedOrigins.has(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLogoutEvent(payload) {
|
||||||
|
return payload && payload.type === "nodedc:session:logout" && typeof payload.id === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardToParent(payload) {
|
||||||
|
if (!isLogoutEvent(payload) || payload.id === lastEventId || !isAllowedOrigin(parentOrigin)) return;
|
||||||
|
lastEventId = payload.id;
|
||||||
|
window.parent.postMessage(payload, parentOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(payload) {
|
||||||
|
if (!isLogoutEvent(payload)) return;
|
||||||
|
try {
|
||||||
|
const channel = new BroadcastChannel(channelName);
|
||||||
|
channel.postMessage(payload);
|
||||||
|
channel.close();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (!isAllowedOrigin(event.origin) || !isLogoutEvent(event.data)) return;
|
||||||
|
publish(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = new BroadcastChannel(channelName);
|
||||||
|
channel.addEventListener("message", (event) => forwardToParent(event.data));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key !== storageKey || !event.newValue) return;
|
||||||
|
try {
|
||||||
|
forwardToParent(JSON.parse(event.newValue));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionSyncAllowedOrigins() {
|
||||||
|
const origins = new Set([new URL(config.appBaseUrl).origin]);
|
||||||
|
|
||||||
|
for (const logoutUrl of getFrontchannelLogoutUrls()) {
|
||||||
|
try {
|
||||||
|
origins.add(new URL(logoutUrl).origin);
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...origins];
|
||||||
|
}
|
||||||
|
|
||||||
function readLauncherData() {
|
function readLauncherData() {
|
||||||
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
type LauncherAuthApp,
|
type LauncherAuthApp,
|
||||||
} from "../shared/api/authApi";
|
} from "../shared/api/authApi";
|
||||||
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
||||||
|
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||||
import {
|
import {
|
||||||
AdminOverlay,
|
AdminOverlay,
|
||||||
|
|
@ -190,6 +191,17 @@ export function LauncherApp() {
|
||||||
window.location.replace(buildLoginRedirectUrl(authSession.loginUrl));
|
window.location.replace(buildLoginRedirectUrl(authSession.loginUrl));
|
||||||
}, [authSession]);
|
}, [authSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isRedirecting = false;
|
||||||
|
|
||||||
|
return subscribeToNodeDCLogoutEvents(() => {
|
||||||
|
if (isRedirecting) return;
|
||||||
|
|
||||||
|
isRedirecting = true;
|
||||||
|
window.location.replace(buildLoginRedirectUrl("/auth/login?prompt=login"));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
|
|
@ -511,6 +523,10 @@ export function LauncherApp() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
window.location.replace(authSession.logoutUrl);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="launcher-app">
|
<div className="launcher-app">
|
||||||
<TopBar
|
<TopBar
|
||||||
|
|
@ -525,7 +541,7 @@ export function LauncherApp() {
|
||||||
onToggleAdmin={() => setAdminOpen((current) => !current)}
|
onToggleAdmin={() => setAdminOpen((current) => !current)}
|
||||||
onOpenShowcase={() => setAdminOpen(false)}
|
onOpenShowcase={() => setAdminOpen(false)}
|
||||||
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
onOpenProfileSettings={() => setProfileSettingsOpen(true)}
|
||||||
onLogout={() => window.location.replace(authSession.logoutUrl)}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="launcher-main">
|
<main className="launcher-main">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
export const NODEDC_SESSION_EVENT_TYPE = "nodedc:session:logout";
|
||||||
|
export const NODEDC_SESSION_CHANNEL_NAME = "nodedc-platform-session";
|
||||||
|
export const NODEDC_SESSION_STORAGE_KEY = "nodedc:platform-session-event";
|
||||||
|
|
||||||
|
export interface NodeDCSessionEvent {
|
||||||
|
type: typeof NODEDC_SESSION_EVENT_TYPE;
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishNodeDCLogoutEvent(source = "launcher") {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const event: NodeDCSessionEvent = {
|
||||||
|
type: NODEDC_SESSION_EVENT_TYPE,
|
||||||
|
id: createEventId(),
|
||||||
|
source,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = new BroadcastChannel(NODEDC_SESSION_CHANNEL_NAME);
|
||||||
|
channel.postMessage(event);
|
||||||
|
channel.close();
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(NODEDC_SESSION_STORAGE_KEY, JSON.stringify(event));
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToNodeDCLogoutEvents(onLogout: (event: NodeDCSessionEvent) => void) {
|
||||||
|
if (typeof window === "undefined") return () => undefined;
|
||||||
|
|
||||||
|
let lastEventId: string | null = null;
|
||||||
|
let channel: BroadcastChannel | null = null;
|
||||||
|
|
||||||
|
const handleEvent = (payload: unknown) => {
|
||||||
|
if (!isNodeDCSessionEvent(payload) || payload.id === lastEventId) return;
|
||||||
|
|
||||||
|
lastEventId = payload.id;
|
||||||
|
onLogout(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
channel = new BroadcastChannel(NODEDC_SESSION_CHANNEL_NAME);
|
||||||
|
channel.addEventListener("message", (event) => handleEvent(event.data));
|
||||||
|
} catch {
|
||||||
|
channel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== NODEDC_SESSION_STORAGE_KEY || !event.newValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
handleEvent(JSON.parse(event.newValue));
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
channel?.close();
|
||||||
|
window.removeEventListener("storage", handleStorage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeDCSessionEvent(value: unknown): value is NodeDCSessionEvent {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
|
||||||
|
const event = value as Partial<NodeDCSessionEvent>;
|
||||||
|
return (
|
||||||
|
event.type === NODEDC_SESSION_EVENT_TYPE &&
|
||||||
|
typeof event.id === "string" &&
|
||||||
|
typeof event.source === "string" &&
|
||||||
|
typeof event.createdAt === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEventId() {
|
||||||
|
try {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
} catch {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue