mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-31 07:48:04 +02:00
Refactor and cleanup OIDC, desktop, and web-app roles
- 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
This commit is contained in:
30
roles/web-app-desktop/templates/javascript/iframe.js.j2
Normal file
30
roles/web-app-desktop/templates/javascript/iframe.js.j2
Normal file
@@ -0,0 +1,30 @@
|
||||
window.addEventListener("message", function(event) {
|
||||
const allowedSuffix = ".{{ PRIMARY_DOMAIN }}";
|
||||
const origin = event.origin;
|
||||
|
||||
// 1. Only allow messages from *.{{ PRIMARY_DOMAIN }}
|
||||
if (!origin.endsWith(allowedSuffix)) return;
|
||||
|
||||
const data = event.data;
|
||||
|
||||
// 2. Only process valid iframeLocationChange messages
|
||||
if (data && data.type === "iframeLocationChange" && typeof data.href === "string") {
|
||||
try {
|
||||
const hrefUrl = new URL(data.href);
|
||||
|
||||
// 3. Only allow redirects to *.{{ PRIMARY_DOMAIN }}
|
||||
if (!hrefUrl.hostname.endsWith(allowedSuffix)) return;
|
||||
|
||||
// 4. Update the ?iframe= parameter in the browser URL
|
||||
const newUrl = new URL(window.location);
|
||||
newUrl.searchParams.set("iframe", hrefUrl.href);
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
} catch (e) {
|
||||
// Invalid or malformed URL – ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
{% if MODE_DEBUG | bool %}
|
||||
console.log("[iframe-sync] Listener for iframe messages is active.");
|
||||
{% endif %}
|
220
roles/web-app-desktop/templates/javascript/oidc.js.j2
Normal file
220
roles/web-app-desktop/templates/javascript/oidc.js.j2
Normal file
@@ -0,0 +1,220 @@
|
||||
/* ==========================================================================================
|
||||
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.
|
||||
========================================================================================== */
|
Reference in New Issue
Block a user