/* ========================================================================================== Purpose: Hide "Login" and show "Account" when a Keycloak SSO session exists, wire login/logout clicks to the Keycloak adapter, and keep state fresh. NOTE: Ensure CSP allows {{ OIDC.URL }} for script-src-elem, connect-src, and frame-src. ========================================================================================== */ /* ======================= 1) Configuration (filled by Jinja/Ansible) ======================= */ const KC_CONFIG = { url: "{{ OIDC.URL }}", // e.g. https://auth.{{ SOFTWARE_DOMAIN }} realm: "{{ OIDC.CLIENT.REALM }}", // e.g. cymais.cloud clientId: "{{ OIDC.CLIENT.ID }}", // e.g. cymais.cloud redirectUri: "{{ DASHBOARD_APP_BASE_URL }}", // canonical app URL for login/logout round-trips silentCheckSsoRedirectUri: "{{ (DASHBOARD_APP_BASE_URL | regex_replace('/+$', '')) ~ DASHBOARD_LOCATION_SILENT_CHECK }}" }; {% set _oidc_tor_enabled = lookup('config', application_id, 'services.tor.enabled', False) | bool %} {% if _oidc_tor_enabled %} {% set _oidc_tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %} {% set _oidc_mirror_onion = lookup('tls', 'web-svc-mirror', 'url.base') | regex_replace('/+$', '') | to_onion_url(_oidc_tor_node, DOMAIN_PRIMARY) %} const KC_MIRROR_BASE = "{{ _oidc_mirror_onion }}"; {% else %} const KC_MIRROR_BASE = null; {% endif %} const KC_JS_ASSET_URL = "{{ lookup('asset', application_id, 'keycloak-js', 'lib/keycloak.js') }}"; const DEBUG = {{ MODE_DEBUG | string | lower }}; // Keycloak's hidden session-status iframe is noisy on newer Chromium builds and // collides with dashboard-embedded OIDC flows. We keep explicit init/redirect // handling plus token refresh below, so hidden iframe polling can stay off. const KEYCLOAK_IFRAME_POLLING_ENABLED = false; /* ============================================== 2) Helpers for menu manipulation ============================================== */ function normalizedLabel(el) { return (el?.getAttribute?.("data-label") || el?.ariaLabel || el?.title || el?.textContent || "") .replace(/\s+/g, " ").trim().toLowerCase(); } function findMenuItemByText(label) { const wanted = (label || "").trim().toLowerCase(); if (!wanted) return null; const nodes = document.querySelectorAll( "nav a, nav button, nav .nav-link, nav .dropdown-toggle, nav .dropdown-item, .navbar a, .navbar button" ); for (const el of nodes) { const text = (el.getAttribute?.("data-label") || el.ariaLabel || el.title || el.textContent || "") .replace(/\s+/g, " ").trim().toLowerCase(); if (text !== wanted) continue; const container = el.closest("li, .nav-item, .dropdown, .btn-group, .menu-item") || el.closest(".navbar-nav > *") || el.parentElement || el; return container; } return null; } function setItemVisible(el, visible) { if (!el) return; el.style.display = visible ? "" : "none"; if (el.toggleAttribute) el.toggleAttribute("hidden", !visible); el.setAttribute?.("aria-hidden", visible ? "false" : "true"); } /* ============================================== 3) Dynamically load the Keycloak.js adapter ============================================== */ async function loadKeycloakAdapter(src) { const mod = await import(src); if (typeof mod.default !== "function") { throw new Error("keycloak.js exposed no default export at " + src); } return mod.default; } /* ============================================== 4) UI logic: toggle Account/Login visibility ============================================== */ let keycloak = null; let authenticated = false; function applyAuthMenuVisibility(authenticated) { try { const accountItem = findMenuItemByText("Account"); const loginItem = findMenuItemByText("Login"); setItemVisible(accountItem, !!authenticated); setItemVisible(loginItem, !authenticated); if (DEBUG) console.log("[oidc] applyAuthMenuVisibility:", { authenticated, accountItem, loginItem }); } catch (e) { console.warn("[oidc] applyAuthMenuVisibility failed:", e); } } function syncAuthMenuVisibility(nextAuthenticated) { authenticated = !!nextAuthenticated; applyAuthMenuVisibility(authenticated); } function wireLoginLogoutClicks() { const loginItem = findMenuItemByText("Login"); const logoutItem = findMenuItemByText("Logout"); // child under "Account" const loginA = loginItem?.querySelector("a,button"); const logoutA = logoutItem?.querySelector("a,button"); // Intercept login click to use the adapter (gracefully falls back to href if adapter failed) loginA?.addEventListener("click", (ev) => { try { if (keycloak) { ev.preventDefault(); keycloak.login({ redirectUri: KC_CONFIG.redirectUri }); if (DEBUG) console.log("[oidc] login clicked → keycloak.login()"); } } catch (e) { console.warn("[oidc] login handler error:", e); } }, { capture: true }); // Intercept logout click logoutA?.addEventListener("click", (ev) => { try { if (keycloak) { ev.preventDefault(); keycloak.logout({ redirectUri: KC_CONFIG.redirectUri }); if (DEBUG) console.log("[oidc] logout clicked → keycloak.logout()"); } } catch (e) { console.warn("[oidc] logout handler error:", e); } }, { capture: true }); } /* ============================================== 5) Initialize Keycloak with silent SSO check ============================================== */ async function initAuthUI() { // Default UI state until we know better syncAuthMenuVisibility(false); const kcJsUrl = (KC_MIRROR_BASE && window.location.hostname.endsWith(".onion") && KC_JS_ASSET_URL.startsWith("https://")) ? KC_MIRROR_BASE + "/" + KC_JS_ASSET_URL.replace(/^https:\/\//, "") : KC_JS_ASSET_URL; let Keycloak; try { Keycloak = await loadKeycloakAdapter(kcJsUrl); } catch (e) { console.error("[oidc] Failed to load adapter:", e); return; // nothing else to do } // Initialize the Keycloak instance try { keycloak = new Keycloak({ url: KC_CONFIG.url, realm: KC_CONFIG.realm, clientId: KC_CONFIG.clientId }); keycloak.onReady = (nextAuthenticated) => syncAuthMenuVisibility(nextAuthenticated); keycloak.onAuthSuccess = () => syncAuthMenuVisibility(true); keycloak.onAuthLogout = () => syncAuthMenuVisibility(false); keycloak.onAuthRefreshSuccess = () => syncAuthMenuVisibility(true); keycloak.onAuthRefreshError = () => syncAuthMenuVisibility(false); const hasAuthCode = /\bcode=/.test(window.location.search); const onLoadMode = hasAuthCode ? "login-required" : "check-sso"; authenticated = await keycloak.init({ onLoad: onLoadMode, pkceMethod: "S256", silentCheckSsoRedirectUri: KC_CONFIG.silentCheckSsoRedirectUri, checkLoginIframe: KEYCLOAK_IFRAME_POLLING_ENABLED, tokenMinValid: 30 }); } catch (e) { console.error("[oidc] Keycloak init failed:", e); } syncAuthMenuVisibility(authenticated); wireLoginLogoutClicks(); // Persona-helper readiness contract — flag name SPOT is // `OIDC_LOGIN_READY_FLAG` in // `roles/test-e2e-playwright/files/personas/utils/keycloak.js`. // Must stay in sync with that constant. window.__oidcLoginReady = true; // Schedule token refresh only if we are authenticated async function scheduleRefresh() { if (!keycloak?.tokenParsed?.exp) return; const now = Math.floor(Date.now() / 1000); const exp = keycloak.tokenParsed.exp; const refreshInMs = Math.max((exp - now - 30), 1) * 1000; setTimeout(async () => { try { const ok = await keycloak.updateToken(60); // refresh if <60s valid syncAuthMenuVisibility(!!ok || !!keycloak?.authenticated); if (DEBUG) console.log("[oidc] token refresh → ok:", ok); } catch (e) { console.warn("[oidc] token refresh failed:", e); syncAuthMenuVisibility(false); } scheduleRefresh(); }, refreshInMs); } if (authenticated) scheduleRefresh(); // Re-apply if the navbar is re-rendered dynamically const navbar = document.querySelector("nav.navbar") || document.querySelector(".navbar") || document.querySelector("nav"); if (navbar && "MutationObserver" in window) { new MutationObserver(() => syncAuthMenuVisibility(!!keycloak?.authenticated || authenticated)) .observe(navbar, { childList: true, subtree: true }); } if (DEBUG) { console.log("[oidc] init done", { authenticated, kcJsUrl, checkLoginIframe: KEYCLOAK_IFRAME_POLLING_ENABLED, silentCheckSsoRedirectUri: KC_CONFIG.silentCheckSsoRedirectUri }); } } /* ============================================== 6) Start when DOM is ready ============================================== */ if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initAuthUI, { once: true }); } else { // If script is injected after DOMContentLoaded (e.g., via runtime loader), run immediately initAuthUI(); } /* ========================================================================================== NOTE: You must provide a file {{ DASHBOARD_LOCATION_SILENT_CHECK }} at your web root, e.g.: Silent SSO This file is loaded in an invisible iframe by Keycloak to check login state. Also ensure CSP allows {{ OIDC.URL }} in script-src-elem, connect-src, and frame-src. ========================================================================================== */