|
|
|
@@ -4,7 +4,9 @@ const SESSION_KEY = 'portalAuthContext';
|
|
|
|
|
const IDENTITY_COOKIE = 'portal_auth_user';
|
|
|
|
|
const CONTEXT_COOKIE = 'portal_auth_context';
|
|
|
|
|
const AUTH_CHANNEL_NAME = 'portal-auth-session';
|
|
|
|
|
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched']);
|
|
|
|
|
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched', 'session-expired']);
|
|
|
|
|
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
|
|
const SESSION_CHECK_INTERVAL_MS = 15 * 1000;
|
|
|
|
|
let authChannel = null;
|
|
|
|
|
let authSyncStarted = false;
|
|
|
|
|
|
|
|
|
@@ -38,13 +40,59 @@ function writeSharedContext(context, maxAge) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasMatchingIdentity(token, context) {
|
|
|
|
|
const expiresAt = tokenExpiry(token);
|
|
|
|
|
return Boolean(
|
|
|
|
|
token
|
|
|
|
|
&& context
|
|
|
|
|
&& readCookie(IDENTITY_COOKIE) === String(context.userId),
|
|
|
|
|
&& readCookie(IDENTITY_COOKIE) === String(context.userId)
|
|
|
|
|
&& (!expiresAt || Date.now() < expiresAt),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tokenExpiry(token) {
|
|
|
|
|
try {
|
|
|
|
|
const payload = token.split('.')[1];
|
|
|
|
|
if (!payload) return 0;
|
|
|
|
|
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
|
|
|
|
const value = JSON.parse(decodeURIComponent(atob(normalized).split('').map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`).join('')));
|
|
|
|
|
return Number(value.exp || 0) * 1000;
|
|
|
|
|
} catch {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sessionExpired() {
|
|
|
|
|
clearAuthSession({ eventType: 'session-expired' });
|
|
|
|
|
if (isProtectedPortalPage()) {
|
|
|
|
|
window.location.replace(`${loginPathForCurrentPortal()}?reason=session-expired`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkSessionLifetime() {
|
|
|
|
|
const token = readCookie('auth_token');
|
|
|
|
|
const context = readSharedContext() || readSessionContext();
|
|
|
|
|
if (!token || !context || !hasMatchingIdentity(token, context)) return;
|
|
|
|
|
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
|
|
|
|
|
const lastActivityAt = Number(context.lastActivityAt || Date.now());
|
|
|
|
|
if ((expiresAt && Date.now() >= expiresAt) || Date.now() - lastActivityAt >= SESSION_IDLE_TIMEOUT_MS) {
|
|
|
|
|
sessionExpired();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function touchActivity() {
|
|
|
|
|
const token = readCookie('auth_token');
|
|
|
|
|
const context = readSharedContext() || readSessionContext();
|
|
|
|
|
if (!token || !context || !hasMatchingIdentity(token, context)) return;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
|
|
|
|
|
if (expiresAt && now >= expiresAt) { sessionExpired(); return; }
|
|
|
|
|
if (now - Number(context.lastActivityAt || 0) < 60_000) return;
|
|
|
|
|
const next = { ...context, lastActivityAt: now, expiresAt };
|
|
|
|
|
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : 1800;
|
|
|
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
|
|
|
writeSharedContext(next, remaining);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openAuthChannel() {
|
|
|
|
|
if (!('BroadcastChannel' in window)) return null;
|
|
|
|
|
if (authChannel) return authChannel;
|
|
|
|
@@ -80,7 +128,8 @@ function redirectForExternalAuthChange(type) {
|
|
|
|
|
window.location.replace('/portal/guest/home/?reason=signed-out');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
window.location.replace(`${loginPathForCurrentPortal()}?reason=account-switched`);
|
|
|
|
|
const reason = type === 'session-expired' ? 'session-expired' : 'account-switched';
|
|
|
|
|
window.location.replace(`${loginPathForCurrentPortal()}?reason=${reason}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleExternalAuthChange(type) {
|
|
|
|
@@ -108,6 +157,11 @@ export function startAuthSync() {
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener('focus', reconcileSharedSession);
|
|
|
|
|
window.addEventListener('pageshow', reconcileSharedSession);
|
|
|
|
|
['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => {
|
|
|
|
|
window.addEventListener(eventName, touchActivity, { passive: true });
|
|
|
|
|
});
|
|
|
|
|
window.setInterval(checkSessionLifetime, SESSION_CHECK_INTERVAL_MS);
|
|
|
|
|
checkSessionLifetime();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getAccessToken() {
|
|
|
|
@@ -130,6 +184,8 @@ export function setAuthSession(loginData, username) {
|
|
|
|
|
roles: Array.isArray(loginData.roles) ? loginData.roles : [],
|
|
|
|
|
dataScope: loginData.data_scope || 'self',
|
|
|
|
|
permissions: Array.isArray(loginData.permissions) ? loginData.permissions : [],
|
|
|
|
|
expiresAt: Date.now() + maxAge * 1000,
|
|
|
|
|
lastActivityAt: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
document.cookie = `${IDENTITY_COOKIE}=${encodeURIComponent(context.userId)}; path=/; max-age=${maxAge}; SameSite=Strict`;
|
|
|
|
|
writeSharedContext(context, maxAge);
|
|
|
|
@@ -190,8 +246,12 @@ export function updateAuthPermissions(permissions, dataScope) {
|
|
|
|
|
permissions: Array.isArray(permissions) ? [...permissions] : [],
|
|
|
|
|
dataScope: dataScope || context.dataScope,
|
|
|
|
|
};
|
|
|
|
|
const expiresAt = Number(next.expiresAt || tokenExpiry(readCookie('auth_token')));
|
|
|
|
|
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : 1800;
|
|
|
|
|
next.expiresAt = expiresAt;
|
|
|
|
|
next.lastActivityAt = Date.now();
|
|
|
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
|
|
|
writeSharedContext(next, 1800);
|
|
|
|
|
writeSharedContext(next, remaining);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|