mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-27 05:55:15 +02:00
- Improved OIDC variable definitions (12_oidc.yml) - Added account/security/profile URLs - Restructured web-app-desktop tasks and JS handling - Introduced oidc.js and iframe.js with runtime loader - Fixed nginx.conf, LDAP, and healthcheck templates spacing - Improved Lua injection for CSP and snippets - Fixed typos (WordPress, receive, etc.) - Added silent-check-sso nginx location Conversation: https://chatgpt.com/share/68ae0060-4fac-800f-9f02-22592a4087d3
221 lines
7.9 KiB
Django/Jinja
221 lines
7.9 KiB
Django/Jinja
/* ==========================================================================================
|
|
roles/web-app-desktop/templates/javascript/oidc.js.j2
|
|
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.infinito.nexus
|
|
realm: "{{ OIDC.CLIENT.REALM }}", // e.g. cymais.cloud
|
|
clientId: "{{ OIDC.CLIENT.ID }}", // e.g. cymais.cloud
|
|
redirectUri: window.location.origin, // where to return after login/logout
|
|
silentCheckSsoRedirectUri: window.location.origin + "{{ DESKTOP_LOCATION_SILENT_CHECK }}"
|
|
};
|
|
|
|
const DEBUG = {{ 'true' if MODE_DEBUG | default(false) else '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
|
|
============================================== */
|
|
function loadKeycloakAdapter(src) {
|
|
return new Promise((resolve, reject) => {
|
|
const s = document.createElement("script");
|
|
s.src = src;
|
|
s.onload = resolve;
|
|
s.onerror = () => reject(new Error("Failed to load keycloak.js from " + src));
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
|
|
/* ==============================================
|
|
4) UI logic: toggle Account/Login visibility
|
|
============================================== */
|
|
let keycloak = null;
|
|
|
|
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 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
|
|
applyAuthMenuVisibility(false);
|
|
|
|
// Load keycloak.js
|
|
const kcJsUrl = "https://cdn.jsdelivr.net/npm/keycloak-js@latest/dist/keycloak.min.js";
|
|
try {
|
|
await loadKeycloakAdapter(kcJsUrl);
|
|
} catch (e) {
|
|
console.error("[oidc] Failed to load adapter:", e);
|
|
return; // nothing else to do
|
|
}
|
|
|
|
if (typeof window.Keycloak !== "function") {
|
|
console.error("[oidc] window.Keycloak is not available after loading:", kcJsUrl);
|
|
return;
|
|
}
|
|
|
|
// Initialize the Keycloak instance
|
|
let authenticated = false;
|
|
try {
|
|
keycloak = new Keycloak({ url: KC_CONFIG.url, realm: KC_CONFIG.realm, clientId: KC_CONFIG.clientId });
|
|
|
|
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: true,
|
|
tokenMinValid: 30
|
|
});
|
|
} catch (e) {
|
|
console.error("[oidc] Keycloak init failed:", e);
|
|
}
|
|
|
|
applyAuthMenuVisibility(!!authenticated);
|
|
wireLoginLogoutClicks();
|
|
|
|
// 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
|
|
applyAuthMenuVisibility(!!ok || !!keycloak?.authenticated);
|
|
if (DEBUG) console.log("[oidc] token refresh → ok:", ok);
|
|
} catch (e) {
|
|
console.warn("[oidc] token refresh failed:", e);
|
|
applyAuthMenuVisibility(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(() => applyAuthMenuVisibility(!!keycloak?.authenticated || !!authenticated))
|
|
.observe(navbar, { childList: true, subtree: true });
|
|
}
|
|
|
|
if (DEBUG) {
|
|
console.log("[oidc] init done", {
|
|
authenticated,
|
|
kcJsUrl,
|
|
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 {{ DESKTOP_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.
|
|
========================================================================================== */
|