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:
Kevin Veen-Birkenbach 2025-08-26 20:44:05 +02:00
parent ce033c370a
commit c182ecf516
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
33 changed files with 543 additions and 146 deletions

View File

@ -7,17 +7,20 @@
#############################################
# @see https://en.wikipedia.org/wiki/OpenID_Connect
## Helper Variables:
# Helper Variables:
_oidc_client_realm: "{{ OIDC.CLIENT.REALM if OIDC.CLIENT is defined and OIDC.CLIENT.REALM is defined else SOFTWARE_NAME | lower }}"
_oidc_url: "{{
( OIDC.URL
if (OIDC is defined and OIDC.URL is defined)
else WEB_PROTOCOL ~ '://' ~ (domains | get_domain('web-app-keycloak'))
else domains | get_url('web-app-keycloak', WEB_PROTOCOL)
).rstrip('/')
}}"
_oidc_client_issuer_url: "{{ _oidc_url ~ '/realms/' ~ _oidc_client_realm }}"
_oidc_client_id: "{{ OIDC.CLIENT.ID if OIDC.CLIENT is defined and OIDC.CLIENT.ID is defined else SOFTWARE_NAME | lower }}"
_oidc_account_url: "{{ _oidc_client_issuer_url ~ '/account' }}"
_oidc_protocol_oidc: "{{ _oidc_client_issuer_url ~ '/protocol/openid-connect' }}"
# Definition
defaults_oidc:
URL: "{{ _oidc_url }}"
CLIENT:
@ -26,12 +29,16 @@ defaults_oidc:
REALM: "{{ _oidc_client_realm }}" # The realm to which the client belongs in the OIDC provider
ISSUER_URL: "{{ _oidc_client_issuer_url }}" # Base URL of the OIDC provider (issuer)
DISCOVERY_DOCUMENT: "{{ _oidc_client_issuer_url ~ '/.well-known/openid-configuration' }}" # URL for fetching the provider's configuration details
AUTHORIZE_URL: "{{ _oidc_client_issuer_url ~ '/protocol/openid-connect/auth' }}" # Endpoint to start the authorization process
TOKEN_URL: "{{ _oidc_client_issuer_url ~ '/protocol/openid-connect/token' }}" # Endpoint to exchange authorization codes for tokens (note: 'token_url' may be a typo for 'token_url')
USER_INFO_URL: "{{ _oidc_client_issuer_url ~ '/protocol/openid-connect/userinfo' }}" # Endpoint to retrieve user information
LOGOUT_URL: "{{ _oidc_client_issuer_url ~ '/protocol/openid-connect/logout' }}" # Endpoint to log out the user
CHANGE_CREDENTIALS: "{{ _oidc_client_issuer_url ~ '/account/account-security/signing-in' }}" # URL for managing or changing user credentials
CERTS: "{{ _oidc_client_issuer_url ~ '/protocol/openid-connect/certs' }}" # JSON Web Key Set (JWKS)
AUTHORIZE_URL: "{{ _oidc_protocol_oidc ~ '/auth' }}" # Endpoint to start the authorization process
TOKEN_URL: "{{ _oidc_protocol_oidc ~ '/token' }}" # Endpoint to exchange authorization codes for tokens (note: 'token_url' may be a typo for 'token_url')
USER_INFO_URL: "{{ _oidc_protocol_oidc ~ '/userinfo' }}" # Endpoint to retrieve user information
LOGOUT_URL: "{{ _oidc_protocol_oidc ~ '/logout' }}" # Endpoint to log out the user
CERTS: "{{ _oidc_protocol_oidc ~ '/certs' }}" # JSON Web Key Set (JWKS)
ACCOUNT:
URL: "{{ _oidc_account_url }}" # Entry point for the user settings console
PROFILE_URL: "{{ _oidc_account_url ~ '/#/personal-info' }}" # Section for managing personal information
SECURITY_URL: "{{ _oidc_account_url ~ '/#/security/signingin' }}" # Section for managing login and security settings
CHANGE_CREDENTIALS: "{{ _oidc_account_url ~ '/account-security/signing-in' }}" # URL for managing or changing user credentials
RESET_CREDENTIALS: "{{ _oidc_client_issuer_url ~ '/login-actions/reset-credentials?client_id=' ~ _oidc_client_id }}" # Password reset url
BUTTON_TEXT: "SSO Login ({{ PRIMARY_DOMAIN | upper }})" # Default button text
ATTRIBUTES:

View File

@ -10,7 +10,8 @@
name: python-requests
state: present
- meta: flush_handlers
- name: "Flush webserver handlers"
meta: flush_handlers
- include_role:
name: sys-service

View File

@ -20,7 +20,7 @@ def get_expected_statuses(domain: str, parts: list[str], redirected_domains: set
if (parts and parts[0] == 'www') or (domain in redirected_domains):
return [301]
if domain == '{{ domains | get_domain('web-app-yourls') }}':
return [{{ applications | get_app_conf('web-app-yourls', 'server.status_codes.landingpage', True) }}]
return [{{ applications | get_app_conf('web-app-yourls', 'server.status_codes.landingpage') }}]
return [200, 302, 301]
# file in which fqdn server configs are deposit

View File

@ -1,7 +1,6 @@
- name: Include dependency 'sys-daemon'
include_role:
name: sys-daemon
public: true
when: run_once_sys_daemon is not defined
- name: "reset (if enabled)"

View File

@ -1,4 +1,3 @@
# roles/sys-srv-web-inj-compose/filter_plugins/inj_snippets.py
"""
Jinja filter: `inj_features(kind)` filters a list of features to only those
that actually provide the corresponding snippet template file.

View File

@ -1,4 +1,4 @@
{# roles/sys-srv-web-inj-compose/templates/location.lua.j2 #}
{# Jinja macro: expands feature snippets into Lua array pushes at render time #}
{% macro push_snippets(list_name, features) -%}
{% set kind = list_name | regex_replace('_snippets$','') %}
{% for f in features if inj_enabled.get(f) -%}
@ -14,18 +14,20 @@ header_filter_by_lua_block {
local ct = ngx.header.content_type or ""
if ct:lower():find("^text/html") then
ngx.ctx.is_html = true
-- IMPORTANT: body will be modified → drop Content-Length to avoid mismatches
ngx.header.content_length = nil
else
ngx.ctx.is_html = false
end
}
body_filter_by_lua_block {
-- only apply further processing if this is an HTML response
-- Only process HTML responses
if not ngx.ctx.is_html then
return
end
-- initialize or reuse the buffer
-- Buffer all chunks until EOF
ngx.ctx.buf = ngx.ctx.buf or {}
local chunk, eof = ngx.arg[1], ngx.arg[2]
@ -34,39 +36,56 @@ body_filter_by_lua_block {
end
if not eof then
-- drop intermediate chunks; well emit only on eof
-- Swallow intermediate chunks; emit once at EOF
ngx.arg[1] = nil
return
end
-- on eof: concatenate all buffered chunks
-- Concatenate the full HTML
local whole = table.concat(ngx.ctx.buf)
ngx.ctx.buf = nil -- clear buffer
ngx.ctx.buf = nil
-- remove html CSP, due to management via Infinito.Nexus policies
whole = whole:gsub(
'<meta[^>]-http%-equiv=["\']Content%-Security%-Policy["\'][^>]->%s*',
''
)
-- Remove inline CSP <meta http-equiv="Content-Security-Policy"> (case-insensitive)
local meta_re = [[<meta[^>]+http-equiv=["']Content-Security-Policy["'][^>]*>\s*]]
whole = ngx.re.gsub(whole, meta_re, "", "ijo")
-- build a list of head-injection snippets
-- Build head snippets (rendered by Jinja at template time)
local head_snippets = {}
{{ push_snippets('head_snippets', inj_head_features) }}
-- inject all collected snippets right before </head>
local head_payload = table.concat(head_snippets, "\n") .. "</head>"
whole = ngx.re.gsub(whole, "</head>", head_payload, "ijo", nil, 1)
-- build a list of body-injection snippets
-- Inject before </head> (first occurrence)
local function repl_head(_) return head_payload end
local new, n, err = ngx.re.sub(whole, [[</head\s*>]], repl_head, "ijo")
if new then
whole = new
else
ngx.log(ngx.WARN, "No </head> found; trying <body> fallback: ", err or "nil")
-- Fallback: inject right AFTER the opening <body ...> tag
local body_open_re = [[<body\b[^>]*>]]
new, n, err = ngx.re.sub(whole, body_open_re, "$0\n" .. table.concat(head_snippets, "\n"), "ijo")
if new then
whole = new
else
ngx.log(ngx.ERR, "Head-fallback failed: ", err or "nil")
end
end
-- Build body snippets (rendered by Jinja at template time)
local body_snippets = {}
{{ push_snippets('body_snippets', inj_body_features) }}
-- inject all collected snippets right before </body>
local body_payload = table.concat(body_snippets, "\n") .. "</body>"
whole = ngx.re.gsub(whole, "</body>", body_payload, "ijo", nil, 1)
-- finally send the modified HTML out
ngx.arg[1] = whole
-- Inject before </body> (first occurrence), or append if missing
local function repl_body(_) return body_payload end
new, n, err = ngx.re.sub(whole, [[</body\s*>]], repl_body, "ijo")
if new then
whole = new
else
ngx.log(ngx.WARN, "No </body> found; appending body snippets at end: ", err or "nil")
whole = whole .. table.concat(body_snippets, "\n")
end
-- Emit the modified HTML
ngx.arg[1] = whole or ""
}

View File

@ -2,9 +2,10 @@ features:
matomo: true
css: true
desktop: false
oidc: true # Needs to be activated so that the login url is working
simpleicons: true # Activate Brand Icons for your groups
javascript: true # Necessary for URL sync
logout: false # Doesn't have own user data. Just a frame.
logout: true
server:
csp:
whitelist:
@ -19,6 +20,7 @@ server:
- https://cdn.jsdelivr.net
connect-src:
- https://ka-f.fontawesome.com
- "{{ WEB_PROTOCOL }}://auth.{{ PRIMARY_DOMAIN }}"
frame-src:
- "{{ WEB_PROTOCOL }}://*.{{ PRIMARY_DOMAIN }}"
flags:
@ -31,4 +33,8 @@ server:
domains:
canonical:
- "{{ PRIMARY_DOMAIN }}"
docker:
services:
desktop:
name: "desktop"
image: "application-portfolio"

View File

@ -2,13 +2,16 @@
include_tasks: "02_validate.yml"
when: MODE_ASSERT | bool
- name: "Include JS routines"
include_tasks: "03_javascript.yml"
- name: "load docker, proxy for '{{ application_id }}'"
include_role:
name: cmp-docker-proxy
- name: "Check if host-specific config.yaml exists in {{ config_inventory_path }}"
- name: "Check if host-specific config.yaml exists in {{ DESKTOP_INVENTORY_CONFIG_PATH }}"
stat:
path: "{{ config_inventory_path }}"
path: "{{ DESKTOP_INVENTORY_CONFIG_PATH }}"
delegate_to: localhost
become: false
register: config_file
@ -42,7 +45,7 @@
- name: Copy host-specific config.yaml if it exists
template:
src: "{{ config_inventory_path }}"
src: "{{ DESKTOP_INVENTORY_CONFIG_PATH }}"
dest: "{{ docker_repository_path }}/app/config.yaml"
notify: docker compose up
when: config_file.stat.exists
@ -57,5 +60,5 @@
- name: add docker-compose.yml
template:
src: docker-compose.yml.j2
dest: "{docker_compose.directories.instance}}docker-compose.yml"
dest: "{{ docker_compose.directories.instance }}docker-compose.yml"
notify: docker compose up

View File

@ -0,0 +1,19 @@
- name: "load required 'web-svc-cdn' for {{ application_id }}"
include_role:
name: web-svc-cdn
public: false
when: run_once_web_svc_cdn is not defined
- name: Ensure {{ DESKTOP_JS_SERVER_DIR }} exists
file:
path: "{{ DESKTOP_JS_SERVER_DIR }}"
state: directory
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0755'
- name: "Include file specific JS Routines"
include_tasks: "_javascript_file.yml"
loop: "{{ DESKTOP_JS_FILES }}"
loop_control:
loop_var: js_file_name

View File

@ -0,0 +1,17 @@
- name: Deploy {{ js_file_name }}
template:
src: "javascript/{{ js_file_name }}.j2"
dest: "{{ DESKTOP_JS_SERVER_DIR }}/{{ js_file_name }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'
- name: Get stat for {{ js_file_name }}
stat:
path: "{{ DESKTOP_JS_SERVER_DIR }}/{{ js_file_name }}"
register: javascript_file_stat
- name: Update javascript_file_version with highest mtime
set_fact:
javascript_file_version: >-
{{ [ (javascript_file_version | default(0) | int), (javascript_file_stat.stat.mtime | int) ] | max }}

View File

@ -4,13 +4,13 @@
build:
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
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;
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
}
}
// 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);
});
}
{% if MODE_DEBUG | bool %}
console.log("[iframe-sync] Listener for iframe messages is active.");
{% endif %}
// 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 {}
}
}
// 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,30 +24,6 @@ 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 %}

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>';
}

View File

@ -1,4 +1,39 @@
# General
application_id: "web-app-desktop"
## Webserver
proxy_extra_configuration: "{{ lookup('template', 'nginx/sso.html.conf.j2') }}"
## Docker
docker_repository_address: "https://github.com/kevinveenbirkenbach/port-ui"
config_inventory_path: "{{ inventory_dir }}/files/{{ inventory_hostname }}/docker/web-app-desktop/config.yaml.j2"
docker_pull_git_repository: true
# Desktop
## Javascript
DESKTOP_JS_CDN_URL: "{{ domains | get_url('web-svc-cdn', WEB_PROTOCOL) }}"
DESKTOP_JS_FILES: ['iframe.js','oidc.js']
DESKTOP_JS_BASE_PATH: "{{ application_id | get_entity_name }}/js"
DESKTOP_JS_SERVER_DIR: "{{ [ NGINX.DIRECTORIES.DATA.CDN, DESKTOP_JS_BASE_PATH ] | path_join }}"
DESKTOP_JS_BASE_URL: "{{ (DESKTOP_JS_CDN_URL | trim('/')) ~ '/' ~ (DESKTOP_JS_BASE_PATH | trim('/')) }}"
## Webserver
DESKTOP_LOCATION_SILENT_CHECK: "/silent-check-sso.html"
## Configuration
DESKTOP_INVENTORY_CONFIG_PATH: "{{ inventory_dir }}/files/{{ inventory_hostname }}/docker/web-app-desktop/config.yaml.j2"
## OIDC
DESKTOP_KEYCLOAK_LOGIN_URL: >-
{{ OIDC.CLIENT.AUTHORIZE_URL
~ '?client_id=' ~ OIDC.CLIENT.ID
~ '&response_type=code'
~ '&scope=openid%20profile%20email'
~ '&redirect_uri=' ~ (domains | get_url(application_id, WEB_PROTOCOL)) | urlencode }}
DESKTOP_KEYCLOAK_IFRAME_ENABLED: "{{ applications | get_app_conf( 'web-app-keycloak', 'features.desktop') }}"
DESKTOP_OIDC_ENABLED: "{{ applications | get_app_conf( application_id, 'features.oidc') }}"
## Docker
DESKTOP_CONTAINER: "{{ applications | get_app_conf( application_id, 'docker.services.desktop.name') }}"
DESKTOP_IMAGE: "{{ applications | get_app_conf( application_id, 'docker.services.desktop.image') }}"

View File

@ -3,7 +3,6 @@
include_tasks: 01_meta.yml
when: not KEYCLOAK_LOAD_DEPENDENCIES | bool
- name: "Load cleanup routine for '{{ application_id }}'"
include_tasks: 02_cleanup.yml

View File

@ -27,7 +27,7 @@ server:
rbac:
roles:
mail-bot:
description: "Has an token to send and recieve emails"
description: "Has an token to send and receive emails"
docker:
services:
redis:

View File

@ -1,4 +1,4 @@
title: "Blog" # Wordpress titel
title: "Blog" # WordPress titel
max_upload_size: "15M" # Low default upload size, because you should use Peertube for Videos and Funkwhale for Audio files
plugins:
wp-discourse:

View File

@ -63,7 +63,7 @@
register: wp_is_multisite
changed_when: false
- name: "Update Single Side Wordpress domain"
- name: "Update Single Side WordPress domain"
include_tasks: 04_update_domain.yml
when: (wp_is_multisite.stdout | trim) == '0'
vars:

View File

@ -1,4 +1,4 @@
users: # Credentials
administrator: # Wordpress administrator
administrator: # WordPress administrator
username: "administrator"
email: "administrator@{{ PRIMARY_DOMAIN }}"

View File

@ -3,7 +3,7 @@ application_id: "web-app-wordpress"
database_type: "mariadb"
host_msmtp_conf: "{{docker_compose.directories.config}}msmtprc.conf"
# Wordpress Specific
# WordPress Specific
wordpress_max_upload_size: "{{ applications | get_app_conf(application_id, 'max_upload_size') }}"
wordpress_custom_image: "wordpress_custom"
wordpress_docker_html_path: "/var/www/html"

View File

@ -24,5 +24,5 @@ server:
rbac:
roles:
mail-bot:
description: "Has an token to send and recieve emails"
description: "Has an token to send and receive emails"