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:
2025-08-26 20:44:05 +02:00
parent ce033c370a
commit c182ecf516
33 changed files with 543 additions and 146 deletions

View File

@@ -2,15 +2,15 @@
portfolio:
{% set container_port = 5000 %}
build:
context: {{docker_repository_path}}
context: {{ docker_repository_path }}
dockerfile: Dockerfile
image: application-portfolio
container_name: portfolio
image: {{ DESKTOP_IMAGE }}
container_name: {{ DESKTOP_CONTAINER }}
ports:
- 127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}
volumes:
- {{docker_repository_path}}app:/app
restart: unless-stopped
- {{ docker_repository_path }}app:/app
restart: {{ DOCKER_RESTART_POLICY }}
{% include 'roles/docker-container/templates/networks.yml.j2' %}
{% include 'roles/docker-container/templates/healthcheck/tcp.yml.j2' %}

View File

@@ -1,30 +1,46 @@
window.addEventListener("message", function(event) {
const allowedSuffix = ".{{ PRIMARY_DOMAIN }}";
const origin = event.origin;
// ===== Runtime loader for external JS files (no Jinja includes) =====
(function () {
// 1) Values injected by Ansible/Jinja
// Base URL where your files were deployed (e.g. CDN), made safe w/o trailing slash
const BASE_URL = ("{{ DESKTOP_JS_BASE_URL }}").replace(/\/+$/, "");
// List of files to load, in order
const FILES = [
{% for f in DESKTOP_JS_FILES -%}
"{{ f }}"{% if not loop.last %},{% endif %}
{%- endfor %}
];
// Cache buster (highest mtime computed during deploy)
const VERSION = "{{ javascript_file_version }}";
// 1. Only allow messages from *.{{ PRIMARY_DOMAIN }}
if (!origin.endsWith(allowedSuffix)) return;
// 2) Helper to load a <script> with proper query param
function loadScriptSequential(url) {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
// Append ?v=... (or &v=... if there are already params)
s.src = url + (url.includes("?") ? "&" : "?") + "v=" + encodeURIComponent(VERSION);
// Keep execution order: do not set async/defer
s.onload = () => resolve();
s.onerror = () => reject(new Error("Failed to load " + url));
document.head.appendChild(s);
});
}
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
// 3) Load all files in order
async function loadAll() {
for (const name of FILES) {
const fullUrl = BASE_URL + "/" + name.replace(/^\/+/, "");
await loadScriptSequential(fullUrl);
}
// Optional: hook after everything is ready
if (typeof window.onDesktopJsLoaded === "function") {
try { window.onDesktopJsLoaded(); } catch {}
}
}
});
{% if MODE_DEBUG | bool %}
console.log("[iframe-sync] Listener for iframe messages is active.");
{% endif %}
// 4) Start after DOM is ready (safe point to inject <script> tags)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadAll);
} else {
loadAll();
}
})();

View 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 %}

View 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.
========================================================================================== */

View File

@@ -24,31 +24,7 @@ applications:
icon: {{ app.icon }}
url: {{ app.url }}
iframe: {{ app.iframe }}
{% if app.title == 'Keycloak' %}
{% set keycloak_url = domains | get_url('web-app-keycloak', WEB_PROTOCOL) %}
{{ domains | get_url(application_id, WEB_PROTOCOL) }}
children:
- name: Administration
description: Access the central admin console
icon:
class: fa-solid fa-shield-halved
url: {{ keycloak_url }}/admin
iframe: {{ applications | get_app_conf( 'web-app-keycloak', 'features.desktop', False) }}
- name: Profile
description: Update your personal admin settings
icon:
class: fa-solid fa-user-gear
url: {{ keycloak_url }}/realms/{{ OIDC.CLIENT.ID }}/account
iframe: {{ applications | get_app_conf( 'web-app-keycloak', 'features.desktop', False) }}
- name: Logout
description: End your admin session securely
icon:
class: fa-solid fa-right-from-bracket
url: {{ keycloak_url }}/realms/{{ OIDC.CLIENT.ID }}/protocol/openid-connect/logout
iframe: false
{% endif %}
{% endfor %}
{% endfor %}

View File

@@ -1,6 +1,6 @@
followus:
name: Follow Us
description: Follow us to stay up to recieve the newest {{ SOFTWARE_NAME }} updates
description: Follow us to stay up to receive the newest {{ SOFTWARE_NAME }} updates
icon:
class: fas fa-newspaper
{% if ["web-app-mastodon", "web-app-bluesky"] | any_in(group_names) %}
@@ -43,7 +43,7 @@ followus:
iframe: {{ applications | get_app_conf('web-app-peertube','features.desktop',True) }}
{% endif %}
{% if service_provider.contact.wordpress is defined and service_provider.contact.wordpress != "" %}
- name: Wordpress
- name: WordPress
description: Read {{ 'our' if service_provider.type == 'legal' else 'my' }} articles and stories.
icon:
class: fa-solid fa-blog
@@ -55,7 +55,7 @@ followus:
- name: Friendica
description: Visit {{ 'our' if service_provider.type == 'legal' else 'my' }} friendica profile
icon:
class: fas fa-net-wired
class: fa-solid fa-network-wired
identifier: "{{service_provider.contact.friendica}}"
url: "{{ WEB_PROTOCOL }}://{{ service_provider.contact.friendica.split('@')[2] }}/@{{ service_provider.contact.friendica.split('@')[1] }}"
iframe: {{ applications | get_app_conf('web-app-friendica','features.desktop',True) }}

View File

@@ -9,8 +9,8 @@
description: Access our comprehensive documentation and support resources to help you get the most out of the software.
icon:
class: fas fa-book
url: https://{{domains | get_domain('web-app-sphinx')}}
iframe: {{ applications | get_app_conf('web-app-sphinx','features.desktop',True) }}
url: {{ domains | get_url('web-app-sphinx', WEB_PROTOCOL) }}
iframe: {{ applications | get_app_conf('web-app-sphinx','features.desktop') }}
{% endif %}
@@ -20,8 +20,8 @@
description: Checkout the presentation
icon:
class: "fas fa-chalkboard-teacher"
url: https://{{domains | get_domain('web-app-navigator')}}
iframe: {{ applications | get_app_conf('web-app-navigator','features.desktop',True) }}
url: {{ domains | get_url('web-app-navigator', WEB_PROTOCOL) }}
iframe: {{ applications | get_app_conf('web-app-navigator','features.desktop') }}
{% endif %}
- name: Solutions

View File

@@ -17,4 +17,39 @@
description: Reload the application
icon:
class: fa-solid fa-rotate-right
url: "{{ WEB_PROTOCOL }}://{{ domains | get_domain('web-app-desktop') }}"
url: "{{ domains | get_url('web-app-desktop', WEB_PROTOCOL) }}"
{% if DESKTOP_OIDC_ENABLED | bool %}
- name: Account
description: Manage your Account
icon:
class: fa-solid fa-user
children:
- name: Profile
description: Manage your profile
icon:
class: fa-solid fa-id-card
url: {{ OIDC.CLIENT.ACCOUNT.PROFILE_URL }}
iframe: {{ DESKTOP_KEYCLOAK_IFRAME_ENABLED }}
- name: Security
description: Manage your security settings
icon:
class: fa-solid fa-user-gear
url: {{ OIDC.CLIENT.ACCOUNT.SECURITY_URL }}
iframe: {{ DESKTOP_KEYCLOAK_IFRAME_ENABLED }}
- name: Logout
description: "Logout from {{ SOFTWARE_NAME }} on {{ PRIMARY_DOMAIN }}"
target: "_top"
icon:
class: fa-solid fa-right-from-bracket
url: {{ OIDC.CLIENT.LOGOUT_URL }}
iframe: false # Neccesary to refresh desktop page after logout
- name: Login
description: "Login to {{ SOFTWARE_NAME }} on {{ PRIMARY_DOMAIN }}"
target: "_top"
icon:
class: fa-solid fa-right-to-bracket
url: {{ DESKTOP_KEYCLOAK_LOGIN_URL }}
iframe: false # Neccesary to refresh desktop page after login
{% endif %}

View File

@@ -0,0 +1,16 @@
# Serve a static silent-check-sso.html file directly from memory
location = {{ DESKTOP_LOCATION_SILENT_CHECK }} {
default_type text/html;
add_header X-Frame-Options "SAMEORIGIN";
add_header Cache-Control "no-store";
return 200 '<!DOCTYPE html>
<html>
<head>
<title>Silent SSO</title>
</head>
<body>
Checking SSO...
</body>
</html>';
}