diff --git a/.env.example b/.env.example index f8dadb4..efa2617 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,37 @@ # Leave empty to auto-generate and persist to save.json # OVERLORD_AGENT_TOKEN= +# ============================================ +# OPTIONAL: OIDC / SSO login +# ============================================ +# Generic OIDC works with providers such as Authentik, Authelia, Keycloak, Zitadel, and Dex. +# Configure your provider callback/redirect URI as: +# https://YOUR_OVERLORD_HOST/api/oidc/callback +# Keep local username/password login enabled as a fallback. +# OVERLORD_OIDC_ENABLED=true +# OVERLORD_OIDC_LABEL=Sign in with SSO +# OVERLORD_OIDC_ISSUER=https://auth.example.com/application/o/overlord/ +# OVERLORD_OIDC_CLIENT_ID=overlord +# OVERLORD_OIDC_CLIENT_SECRET=change-me +# OVERLORD_OIDC_REDIRECT_URI=https://overlord.example.com/api/oidc/callback +# OVERLORD_OIDC_DEFAULT_ROLE=viewer +# OVERLORD_OIDC_ALLOWED_DOMAINS=example.com +# OVERLORD_OIDC_ADMIN_GROUPS=overlord-admins +# OVERLORD_OIDC_OPERATOR_GROUPS=overlord-operators +# OVERLORD_OIDC_VIEWER_GROUPS=overlord-viewers + +# ============================================ +# OPTIONAL: Login screen branding +# ============================================ +# Use a logo URL, or leave it empty and set a Font Awesome icon class. +# Logo URLs may be absolute http(s) URLs or root-relative paths such as /assets/logo.png. +# OVERLORD_LOGIN_BRAND_NAME=Acme SOC +# OVERLORD_LOGIN_TITLE=Welcome to Acme Overlord +# OVERLORD_LOGIN_SUBTITLE=Sign in with your Acme identity +# OVERLORD_LOGIN_LOGO_URL=/assets/acme-logo.png +# OVERLORD_LOGIN_LOGO_ALT=Acme logo +# OVERLORD_LOGIN_ICON_CLASS=fa-solid fa-shield-halved + # ============================================ # ADVANCED: Custom TLS certificates # ============================================ diff --git a/Overlord-Server/public/assets/login.js b/Overlord-Server/public/assets/login.js index 6caff3f..927354b 100644 --- a/Overlord-Server/public/assets/login.js +++ b/Overlord-Server/public/assets/login.js @@ -4,6 +4,52 @@ const pass = document.getElementById("pass"); const mfaGroup = document.getElementById("mfa-group"); const mfaCode = document.getElementById("mfa-code"); const errEl = document.getElementById("error"); +const oidcLogin = document.getElementById("oidc-login"); +const oidcLoginLabel = document.getElementById("oidc-login-label"); +const loginBrand = document.getElementById("login-brand"); +const loginTitle = document.getElementById("login-title"); +const loginSubtitle = document.getElementById("login-subtitle"); +const loginIcon = document.getElementById("login-icon"); +const loginLogo = document.getElementById("login-logo"); + +(() => { + const params = new URLSearchParams(window.location.search); + const oidcError = params.get("oidc_error"); + if (oidcError) { + errEl.textContent = oidcError; + window.history.replaceState({}, document.title, "/login.html"); + } +})(); + +(async () => { + try { + const res = await fetch("/api/login/branding"); + if (!res.ok) return; + const data = await res.json(); + + if (loginBrand && data.productName) loginBrand.textContent = data.productName; + if (loginTitle && data.title) loginTitle.textContent = data.title; + if (loginSubtitle && data.subtitle) loginSubtitle.textContent = data.subtitle; + if (data.productName) document.title = `${data.productName} Login`; + + if (loginIcon && data.iconClass) { + loginIcon.className = `${data.iconClass} login-crown`; + } + + if (loginLogo && loginIcon && data.logoUrl) { + loginLogo.alt = data.logoAlt || `${data.productName || "Overlord"} logo`; + loginLogo.onload = () => { + loginLogo.style.display = ""; + loginIcon.style.display = "none"; + }; + loginLogo.onerror = () => { + loginLogo.style.display = "none"; + loginIcon.style.display = ""; + }; + loginLogo.src = data.logoUrl; + } + } catch {} +})(); (async () => { try { @@ -16,6 +62,19 @@ const errEl = document.getElementById("error"); } catch {} })(); +(async () => { + try { + const res = await fetch("/api/oidc/status"); + if (!res.ok) return; + const data = await res.json(); + if (data.enabled && data.loginUrl && oidcLogin) { + oidcLogin.href = data.loginUrl; + if (oidcLoginLabel && data.label) oidcLoginLabel.textContent = data.label; + oidcLogin.style.display = ""; + } + } catch {} +})(); + if (typeof anime !== "undefined") { const particles = document.querySelectorAll(".particle"); particles.forEach((particle, i) => { diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 475ed22..5278126 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -1060,6 +1060,14 @@ article.card[data-bookmarked="true"] { filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.5)); } +.login-logo { + width: 56px; + height: 56px; + object-fit: contain; + border-radius: 12px; + filter: drop-shadow(0 4px 12px rgba(122, 91, 255, 0.35)); +} + .login-brand { text-align: center; font-size: 28px; diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 5f09009..2325792 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -82,6 +82,28 @@ const tlsCertbotCertFileInput = document.getElementById("tls-certbot-cert-file") const tlsCertbotKeyFileInput = document.getElementById("tls-certbot-key-file"); const tlsCertbotCaFileInput = document.getElementById("tls-certbot-ca-file"); +const oidcForm = document.getElementById("oidc-form"); +const oidcPermissionNote = document.getElementById("oidc-permission-note"); +const oidcSaveBtn = document.getElementById("oidc-save-btn"); +const oidcEnabledInput = document.getElementById("oidc-enabled"); +const oidcLabelInput = document.getElementById("oidc-label"); +const oidcIssuerInput = document.getElementById("oidc-issuer"); +const oidcClientIdInput = document.getElementById("oidc-client-id"); +const oidcClientSecretInput = document.getElementById("oidc-client-secret"); +const oidcSecretHint = document.getElementById("oidc-secret-hint"); +const oidcRedirectUriInput = document.getElementById("oidc-redirect-uri"); +const oidcScopesInput = document.getElementById("oidc-scopes"); +const oidcClientAuthMethodInput = document.getElementById("oidc-client-auth-method"); +const oidcAutoProvisionInput = document.getElementById("oidc-auto-provision"); +const oidcAllowEmailLinkInput = document.getElementById("oidc-allow-email-link"); +const oidcDefaultRoleInput = document.getElementById("oidc-default-role"); +const oidcAllowedDomainsInput = document.getElementById("oidc-allowed-domains"); +const oidcAllowedEmailsInput = document.getElementById("oidc-allowed-emails"); +const oidcGroupClaimInput = document.getElementById("oidc-group-claim"); +const oidcAdminGroupsInput = document.getElementById("oidc-admin-groups"); +const oidcOperatorGroupsInput = document.getElementById("oidc-operator-groups"); +const oidcViewerGroupsInput = document.getElementById("oidc-viewer-groups"); + const appearanceForm = document.getElementById("appearance-form"); const appearancePermissionNote = document.getElementById("appearance-permission-note"); const appearanceSaveBtn = document.getElementById("appearance-save-btn"); @@ -99,6 +121,7 @@ const wipeOfflineMessage = document.getElementById("wipe-offline-message"); let currentUser = null; let securityConfig = null; let tlsConfig = null; +let oidcConfig = null; function showMessage(text, type = "ok") { if (!messageEl) return; @@ -212,6 +235,7 @@ async function renderPermissionsOverview() { perms: [ "system:security", "system:tls", + "system:oidc", "system:registration", "system:notifications", "system:chat", @@ -425,6 +449,94 @@ async function loadTlsSettings() { setTlsFormDisabled(false); } +function listToCsv(value) { + return Array.isArray(value) ? value.join(", ") : ""; +} + +function csvToList(value) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function setOidcFormDisabled(disabled) { + const controls = [ + oidcEnabledInput, + oidcLabelInput, + oidcIssuerInput, + oidcClientIdInput, + oidcClientSecretInput, + oidcRedirectUriInput, + oidcScopesInput, + oidcClientAuthMethodInput, + oidcAutoProvisionInput, + oidcAllowEmailLinkInput, + oidcDefaultRoleInput, + oidcAllowedDomainsInput, + oidcAllowedEmailsInput, + oidcGroupClaimInput, + oidcAdminGroupsInput, + oidcOperatorGroupsInput, + oidcViewerGroupsInput, + oidcSaveBtn, + ]; + + for (const control of controls) { + if (!control) continue; + control.disabled = disabled; + } +} + +function applyOidcForm() { + const cfg = oidcConfig || {}; + if (oidcEnabledInput) oidcEnabledInput.checked = Boolean(cfg.enabled); + if (oidcLabelInput) oidcLabelInput.value = cfg.label || "Single sign-on"; + if (oidcIssuerInput) oidcIssuerInput.value = cfg.issuer || ""; + if (oidcClientIdInput) oidcClientIdInput.value = cfg.clientId || ""; + if (oidcClientSecretInput) oidcClientSecretInput.value = ""; + if (oidcSecretHint) { + oidcSecretHint.textContent = cfg.clientSecretSet + ? "A client secret is saved. Leave this blank to keep it." + : "No client secret is currently saved."; + } + if (oidcRedirectUriInput) oidcRedirectUriInput.value = cfg.redirectUri || ""; + if (oidcScopesInput) oidcScopesInput.value = listToCsv(cfg.scopes || ["openid", "profile", "email"]); + if (oidcClientAuthMethodInput) oidcClientAuthMethodInput.value = cfg.clientAuthMethod || "client_secret_post"; + if (oidcAutoProvisionInput) oidcAutoProvisionInput.checked = cfg.autoProvision !== false; + if (oidcAllowEmailLinkInput) oidcAllowEmailLinkInput.checked = Boolean(cfg.allowEmailLink); + if (oidcDefaultRoleInput) oidcDefaultRoleInput.value = cfg.defaultRole || "viewer"; + if (oidcAllowedDomainsInput) oidcAllowedDomainsInput.value = listToCsv(cfg.allowedDomains); + if (oidcAllowedEmailsInput) oidcAllowedEmailsInput.value = listToCsv(cfg.allowedEmails); + if (oidcGroupClaimInput) oidcGroupClaimInput.value = cfg.groupClaim || "groups"; + if (oidcAdminGroupsInput) oidcAdminGroupsInput.value = listToCsv(cfg.adminGroups); + if (oidcOperatorGroupsInput) oidcOperatorGroupsInput.value = listToCsv(cfg.operatorGroups); + if (oidcViewerGroupsInput) oidcViewerGroupsInput.value = listToCsv(cfg.viewerGroups); +} + +async function loadOidcSettings() { + if (!currentUser) return; + + if (!userHas("system:oidc")) { + if (oidcPermissionNote) oidcPermissionNote.classList.remove("hidden"); + setOidcFormDisabled(true); + return; + } + + if (oidcPermissionNote) oidcPermissionNote.classList.add("hidden"); + const res = await fetch("/api/settings/oidc", { credentials: "include" }); + if (!res.ok) { + showMessage("Failed to load OIDC settings.", "error"); + setOidcFormDisabled(true); + return; + } + + const data = await res.json().catch(() => ({})); + oidcConfig = data.oidc || null; + applyOidcForm(); + setOidcFormDisabled(false); +} + function updateNavLayoutButtons(mode, sidebarBtn, topbarBtn) { const active = ["bg-indigo-600/80", "border-indigo-500", "text-white"]; const inactive = ["bg-slate-800", "border-slate-700", "text-slate-400", "hover:bg-slate-700", "hover:text-slate-200"]; @@ -723,6 +835,82 @@ async function saveTlsSettings(event) { showMessage("TLS settings updated. Restart server to apply."); } +async function saveOidcSettings(event) { + event.preventDefault(); + if (!requireUiPermission("system:oidc", "OIDC settings permission required.")) { + return; + } + + const payload = { + enabled: !!oidcEnabledInput?.checked, + label: String(oidcLabelInput?.value || "").trim(), + issuer: String(oidcIssuerInput?.value || "").trim(), + clientId: String(oidcClientIdInput?.value || "").trim(), + redirectUri: String(oidcRedirectUriInput?.value || "").trim(), + scopes: csvToList(oidcScopesInput?.value), + clientAuthMethod: oidcClientAuthMethodInput?.value || "client_secret_post", + autoProvision: !!oidcAutoProvisionInput?.checked, + allowEmailLink: !!oidcAllowEmailLinkInput?.checked, + defaultRole: oidcDefaultRoleInput?.value || "viewer", + allowedDomains: csvToList(oidcAllowedDomainsInput?.value), + allowedEmails: csvToList(oidcAllowedEmailsInput?.value), + groupClaim: String(oidcGroupClaimInput?.value || "").trim(), + adminGroups: csvToList(oidcAdminGroupsInput?.value), + operatorGroups: csvToList(oidcOperatorGroupsInput?.value), + viewerGroups: csvToList(oidcViewerGroupsInput?.value), + }; + + const clientSecret = String(oidcClientSecretInput?.value || ""); + if (clientSecret) { + payload.clientSecret = clientSecret; + } + + if (payload.enabled) { + if (!payload.issuer) { + showMessage("OIDC issuer URL is required when OIDC is enabled.", "error"); + oidcIssuerInput?.focus(); + return; + } + if (!payload.redirectUri) { + showMessage("OIDC redirect URI is required when OIDC is enabled.", "error"); + oidcRedirectUriInput?.focus(); + return; + } + if (!payload.clientId) { + showMessage("OIDC client ID is required when OIDC is enabled.", "error"); + oidcClientIdInput?.focus(); + return; + } + if (!payload.scopes.length || !payload.scopes.includes("openid")) { + showMessage("OIDC scopes must include openid.", "error"); + oidcScopesInput?.focus(); + return; + } + if (payload.clientAuthMethod !== "none" && !clientSecret && !oidcConfig?.clientSecretSet) { + showMessage("OIDC client secret is required for the selected client auth method.", "error"); + oidcClientSecretInput?.focus(); + return; + } + } + + const res = await fetch("/api/settings/oidc", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + showMessage(data.error || "Failed to save OIDC settings.", "error"); + return; + } + + oidcConfig = data.oidc || payload; + applyOidcForm(); + showMessage("OIDC settings saved. Environment variables still take priority after restart."); +} + async function runCertbotAutoSetup() { if (!requireUiPermission("system:tls", "TLS settings permission required.")) { return; @@ -1448,6 +1636,7 @@ async function importSettings(event) { await loadSecurityPolicy(); await loadTlsSettings(); + await loadOidcSettings(); await loadAppearanceSettings(); } catch (error) { showExportImportMessage(`Import failed: ${String(error?.message || error)}`, "error"); @@ -2429,6 +2618,7 @@ async function init() { securityForm.addEventListener("submit", saveSecurityPolicy); tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); + if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings); if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings); if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings); if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference); diff --git a/Overlord-Server/public/login.html b/Overlord-Server/public/login.html index d160579..bac245a 100644 --- a/Overlord-Server/public/login.html +++ b/Overlord-Server/public/login.html @@ -23,11 +23,12 @@
Sign in to your control plane
+Sign in to your control plane