Files
kevinveenbirkenbachandClaude Opus 5 aa6bbe3b20 feat(lint): render the software identity from its declared variables
SOFTWARE_NAME, SOFTWARE_DOMAIN, SOFTWARE_URL and ORGANIZATION are declared once in group_vars/all/00_general.yml and derive from each other. Eleven templates wrote the value instead, so a rename would reach the four declarations and every consumer that reads them, and silently miss the literal.

The lint reads the name from the declaration rather than carrying it, and searches case-insensitively: the domain is the name lowered, the URL is https:// plus the domain and the organization is the name again, so one literal finds the value of all four. A hit is reported with the variable it should have been, derived from the scheme in front of it and its case. The escape is nocheck: software-identity, same line or the one above.

Every migrated template renders the same bytes as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 21:38:45 +02:00

245 lines
9.4 KiB
Django/Jinja

/* ==========================================================================================
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.:
<!DOCTYPE html><html><body>Silent SSO</body></html>
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.
========================================================================================== */