OIDC + SSO

This commit is contained in:
KingKDot
2026-07-08 20:34:25 -05:00
parent 97e9bf8f3d
commit faac5b6029
20 changed files with 1885 additions and 11 deletions
+31
View File
@@ -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
# ============================================
+59
View File
@@ -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) => {
+8
View File
@@ -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;
+190
View File
@@ -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);
+9 -4
View File
@@ -23,11 +23,12 @@
</div>
<div class="login-card">
<div class="login-crown-wrapper">
<i class="fa-solid fa-crown login-crown"></i>
<img id="login-logo" class="login-logo" alt="Overlord logo" style="display: none" />
<i id="login-icon" class="fa-solid fa-crown login-crown"></i>
</div>
<div class="brand login-brand">Overlord</div>
<h2 class="login-title">Welcome back</h2>
<p class="muted login-subtitle">Sign in to your control plane</p>
<div id="login-brand" class="brand login-brand">Overlord</div>
<h2 id="login-title" class="login-title">Welcome back</h2>
<p id="login-subtitle" class="muted login-subtitle">Sign in to your control plane</p>
<form id="login-form">
<div class="form-group">
<label class="label">Username</label>
@@ -79,6 +80,10 @@
</button>
<div id="error" class="error" aria-live="polite"></div>
</form>
<a id="oidc-login" href="/api/oidc/login" class="button secondary full login-btn" style="display: none; margin-top: 0.75rem">
<span id="oidc-login-label">Single sign-on</span>
<i class="fa-solid fa-right-to-bracket btn-icon"></i>
</a>
<div id="register-link" class="login-footer" style="display: none">
Don't have an account?
<a href="/register.html">Create one</a>
+132
View File
@@ -94,6 +94,7 @@
<a data-target="section-permissions-overview" class="settings-nav-link" data-permission="users:manage" style="--accent: 167 139 250"><i class="fa-solid fa-user-shield w-4 text-violet-400"></i><span>Access Overview</span></a>
<a data-target="section-security" class="settings-nav-link" data-permission="system:security" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
<a data-target="section-tls" class="settings-nav-link" data-permission="system:tls" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
<a data-target="section-oidc" class="settings-nav-link" data-permission="system:oidc" style="--accent: 20 184 166"><i class="fa-solid fa-right-to-bracket w-4 text-teal-400"></i><span>OIDC / SSO</span></a>
<a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:chat" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
<a data-target="registration-section" class="settings-nav-link" data-permission="system:registration" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
<a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:thumbnails" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
@@ -828,6 +829,137 @@
</form>
</section>
<!-- OIDC / SSO -->
<section id="section-oidc" data-permission="system:oidc" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-teal-500/10 border border-teal-500/30">
<i class="fa-solid fa-right-to-bracket text-teal-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">OIDC / SSO</h2>
<p class="text-sm text-slate-400">Configure generic OIDC login for self-hosted identity providers.</p>
</div>
</div>
<form id="oidc-form" class="space-y-4">
<div id="oidc-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Your permissions do not allow OIDC setting changes.
</div>
<label class="flex items-center gap-2 text-sm">
<input id="oidc-enabled" type="checkbox" class="h-4 w-4 accent-teal-500" />
<span>Enable OIDC login button</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Login button label</span>
<input id="oidc-label" type="text" placeholder="Sign in with SSO" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Default role for new users</span>
<select id="oidc-default-role" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500">
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<div class="grid grid-cols-1 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Issuer URL <span class="text-rose-400" aria-hidden="true">*</span><span class="sr-only">required</span></span>
<input id="oidc-issuer" type="url" placeholder="https://auth.example.com/application/o/overlord/" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Redirect URI <span class="text-rose-400" aria-hidden="true">*</span><span class="sr-only">required</span></span>
<input id="oidc-redirect-uri" type="url" placeholder="https://overlord.example.com/api/oidc/callback" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Client ID <span class="text-rose-400" aria-hidden="true">*</span><span class="sr-only">required</span></span>
<input id="oidc-client-id" type="text" autocomplete="off" placeholder="overlord" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Client secret <span class="text-rose-400" aria-hidden="true">*</span><span class="sr-only">required unless client auth method is none</span></span>
<input id="oidc-client-secret" type="password" autocomplete="new-password" placeholder="Leave blank to keep existing secret" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
<p id="oidc-secret-hint" class="text-xs text-slate-500 mt-1">No client secret is currently saved.</p>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Scopes <span class="text-rose-400" aria-hidden="true">*</span><span class="sr-only">required</span></span>
<input id="oidc-scopes" type="text" placeholder="openid, profile, email" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Client auth method</span>
<select id="oidc-client-auth-method" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500">
<option value="client_secret_post">Client secret POST</option>
<option value="client_secret_basic">Client secret basic</option>
<option value="none">None / PKCE only</option>
</select>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="flex items-start gap-2 rounded-lg border border-slate-800 bg-slate-900 px-3 py-3 text-sm">
<input id="oidc-auto-provision" type="checkbox" class="mt-1 h-4 w-4 accent-teal-500" />
<span>
<span class="block text-slate-200">Auto-provision allowed users</span>
<span class="block text-xs text-slate-500">Create local users after a successful provider login.</span>
</span>
</label>
<label class="flex items-start gap-2 rounded-lg border border-slate-800 bg-slate-900 px-3 py-3 text-sm">
<input id="oidc-allow-email-link" type="checkbox" class="mt-1 h-4 w-4 accent-teal-500" />
<span>
<span class="block text-slate-200">Allow email linking</span>
<span class="block text-xs text-slate-500">Link to existing users only when you trust verified email claims.</span>
</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Allowed email domains</span>
<input id="oidc-allowed-domains" type="text" placeholder="example.com, example.org" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Allowed email addresses</span>
<input id="oidc-allowed-emails" type="text" placeholder="admin@example.com" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
</div>
<div class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 space-y-3">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-500">Group Role Mapping</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Group claim</span>
<input id="oidc-group-claim" type="text" placeholder="groups" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Admin groups</span>
<input id="oidc-admin-groups" type="text" placeholder="overlord-admins" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Operator groups</span>
<input id="oidc-operator-groups" type="text" placeholder="overlord-operators" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Viewer groups</span>
<input id="oidc-viewer-groups" type="text" placeholder="overlord-viewers" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-teal-500" />
</label>
</div>
</div>
<button type="submit" id="oidc-save-btn" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-teal-700 hover:bg-teal-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-floppy-disk"></i>Save OIDC Settings
</button>
</form>
</section>
<!-- Team Chat -->
<section id="chat-settings-section" data-permission="system:chat" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3">
+13
View File
@@ -31,6 +31,19 @@ describe("auth token extraction", () => {
});
});
describe("login branding", () => {
test("returns default public login branding", async () => {
const url = new URL("https://localhost/api/login/branding");
const res = await handleAuthRoutes(new Request(url), url, mockServer);
expect(res?.status).toBe(200);
const body = (await res!.json()) as any;
expect(body.productName).toBe("Overlord");
expect(body.title).toBe("Welcome back");
expect(body.logoUrl).toBe("");
});
});
describe("auth MFA login", () => {
test("MFA-enabled user gets a challenge and can login with TOTP", async () => {
const username = `mfa_user_${Date.now().toString(36)}`;
+346 -3
View File
@@ -12,6 +12,25 @@ export interface Config {
jwtSecret: string;
agentToken: string;
};
oidc: {
enabled: boolean;
label: string;
issuer: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string[];
clientAuthMethod: "client_secret_post" | "client_secret_basic" | "none";
autoProvision: boolean;
allowEmailLink: boolean;
defaultRole: "admin" | "operator" | "viewer";
allowedEmails: string[];
allowedDomains: string[];
groupClaim: string;
adminGroups: string[];
operatorGroups: string[];
viewerGroups: string[];
};
server: {
port: number;
host: string;
@@ -64,6 +83,14 @@ export interface Config {
};
appearance: {
customCSS: string;
loginBranding: {
productName: string;
title: string;
subtitle: string;
iconClass: string;
logoUrl: string;
logoAlt: string;
};
};
plugins: {
trustedKeys: string[];
@@ -105,6 +132,25 @@ const DEFAULT_CONFIG: Config = {
jwtSecret: "",
agentToken: "",
},
oidc: {
enabled: false,
label: "Single sign-on",
issuer: "",
clientId: "",
clientSecret: "",
redirectUri: "",
scopes: ["openid", "profile", "email"],
clientAuthMethod: "client_secret_post",
autoProvision: true,
allowEmailLink: false,
defaultRole: "viewer",
allowedEmails: [],
allowedDomains: [],
groupClaim: "groups",
adminGroups: [],
operatorGroups: [],
viewerGroups: [],
},
server: {
port: 5173,
host: "0.0.0.0",
@@ -157,6 +203,14 @@ const DEFAULT_CONFIG: Config = {
},
appearance: {
customCSS: "",
loginBranding: {
productName: "Overlord",
title: "Welcome back",
subtitle: "Sign in to your control plane",
iconClass: "fa-solid fa-crown",
logoUrl: "",
logoAlt: "Overlord logo",
},
},
plugins: {
trustedKeys: [],
@@ -198,6 +252,73 @@ function envBoolOverride(name: string): boolean | undefined {
return undefined;
}
function splitList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((v) => String(v).trim()).filter(Boolean);
}
if (typeof value === "string") {
return value.split(",").map((v) => v.trim()).filter(Boolean);
}
return [];
}
function pickRole(value: unknown, fallback: Config["oidc"]["defaultRole"]): Config["oidc"]["defaultRole"] {
return value === "admin" || value === "operator" || value === "viewer"
? value
: fallback;
}
function pickClientAuthMethod(value: unknown): Config["oidc"]["clientAuthMethod"] {
return value === "client_secret_basic" || value === "none"
? value
: "client_secret_post";
}
function cleanText(value: unknown, fallback: string, maxLength: number): string {
const raw = typeof value === "string" ? value : "";
const cleaned = raw
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
return (cleaned || fallback).slice(0, maxLength);
}
function cleanIconClass(value: unknown, fallback: string): string {
const raw = cleanText(value, fallback, 120);
const cleaned = raw.replace(/[^A-Za-z0-9_:\-\s]/g, "").replace(/\s+/g, " ").trim();
return cleaned || fallback;
}
function cleanLogoUrl(value: unknown): string {
const raw = cleanText(value, "", 2048);
if (!raw) return "";
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
try {
const parsed = new URL(raw);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return parsed.toString();
}
} catch {}
return "";
}
function cleanLoginBranding(
value: Partial<Config["appearance"]["loginBranding"]> | undefined,
fallback: Config["appearance"]["loginBranding"],
): Config["appearance"]["loginBranding"] {
const productName = cleanText(value?.productName, fallback.productName, 80);
return {
productName,
title: cleanText(value?.title, fallback.title, 120),
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
iconClass: cleanIconClass(value?.iconClass, fallback.iconClass),
logoUrl: cleanLogoUrl(value?.logoUrl),
logoAlt: cleanText(value?.logoAlt, `${productName} logo`, 120),
};
}
type SaveSecrets = {
auth?: {
jwtSecret?: string;
@@ -393,6 +514,82 @@ export function loadConfig(): Config {
jwtSecret: finalJwtSecret,
agentToken: finalAgentToken,
},
oidc: {
enabled:
envBoolOverride("OVERLORD_OIDC_ENABLED") ??
(fileConfig.oidc?.enabled !== undefined
? Boolean(fileConfig.oidc.enabled)
: DEFAULT_CONFIG.oidc.enabled),
label:
process.env.OVERLORD_OIDC_LABEL ||
fileConfig.oidc?.label ||
DEFAULT_CONFIG.oidc.label,
issuer:
process.env.OVERLORD_OIDC_ISSUER ||
fileConfig.oidc?.issuer ||
DEFAULT_CONFIG.oidc.issuer,
clientId:
process.env.OVERLORD_OIDC_CLIENT_ID ||
fileConfig.oidc?.clientId ||
DEFAULT_CONFIG.oidc.clientId,
clientSecret:
process.env.OVERLORD_OIDC_CLIENT_SECRET ||
fileConfig.oidc?.clientSecret ||
DEFAULT_CONFIG.oidc.clientSecret,
redirectUri:
process.env.OVERLORD_OIDC_REDIRECT_URI ||
fileConfig.oidc?.redirectUri ||
DEFAULT_CONFIG.oidc.redirectUri,
scopes: (() => {
const envScopes = splitList(process.env.OVERLORD_OIDC_SCOPES);
if (envScopes.length > 0) return envScopes;
const fileScopes = splitList(fileConfig.oidc?.scopes);
return fileScopes.length > 0 ? fileScopes : [...DEFAULT_CONFIG.oidc.scopes];
})(),
clientAuthMethod: pickClientAuthMethod(
process.env.OVERLORD_OIDC_CLIENT_AUTH_METHOD ||
fileConfig.oidc?.clientAuthMethod,
),
autoProvision:
envBoolOverride("OVERLORD_OIDC_AUTO_PROVISION") ??
(fileConfig.oidc?.autoProvision !== undefined
? Boolean(fileConfig.oidc.autoProvision)
: DEFAULT_CONFIG.oidc.autoProvision),
allowEmailLink:
envBoolOverride("OVERLORD_OIDC_ALLOW_EMAIL_LINK") ??
(fileConfig.oidc?.allowEmailLink !== undefined
? Boolean(fileConfig.oidc.allowEmailLink)
: DEFAULT_CONFIG.oidc.allowEmailLink),
defaultRole: pickRole(
process.env.OVERLORD_OIDC_DEFAULT_ROLE ||
fileConfig.oidc?.defaultRole,
DEFAULT_CONFIG.oidc.defaultRole,
),
allowedEmails: (() => {
const envEmails = splitList(process.env.OVERLORD_OIDC_ALLOWED_EMAILS);
return envEmails.length > 0 ? envEmails : splitList(fileConfig.oidc?.allowedEmails);
})(),
allowedDomains: (() => {
const envDomains = splitList(process.env.OVERLORD_OIDC_ALLOWED_DOMAINS);
return envDomains.length > 0 ? envDomains : splitList(fileConfig.oidc?.allowedDomains);
})(),
groupClaim:
process.env.OVERLORD_OIDC_GROUP_CLAIM ||
fileConfig.oidc?.groupClaim ||
DEFAULT_CONFIG.oidc.groupClaim,
adminGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_ADMIN_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.adminGroups);
})(),
operatorGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_OPERATOR_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.operatorGroups);
})(),
viewerGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_VIEWER_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.viewerGroups);
})(),
},
server: {
port:
Number(process.env.PORT) ||
@@ -558,6 +755,30 @@ export function loadConfig(): Config {
},
appearance: {
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
loginBranding: cleanLoginBranding(
{
...(fileConfig.appearance?.loginBranding || {}),
productName:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.productName,
title:
process.env.OVERLORD_LOGIN_TITLE ||
fileConfig.appearance?.loginBranding?.title,
subtitle:
process.env.OVERLORD_LOGIN_SUBTITLE ||
fileConfig.appearance?.loginBranding?.subtitle,
iconClass:
process.env.OVERLORD_LOGIN_ICON_CLASS ||
fileConfig.appearance?.loginBranding?.iconClass,
logoUrl:
process.env.OVERLORD_LOGIN_LOGO_URL ||
fileConfig.appearance?.loginBranding?.logoUrl,
logoAlt:
process.env.OVERLORD_LOGIN_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.logoAlt,
},
DEFAULT_CONFIG.appearance.loginBranding,
),
},
plugins: {
trustedKeys: (() => {
@@ -828,9 +1049,87 @@ export async function updateTlsConfig(
return next;
}
export async function updateAppearanceConfig(customCSS: string): Promise<Config["appearance"]> {
export async function updateOidcConfig(
updates: Partial<Config["oidc"]>,
): Promise<Config["oidc"]> {
const current = getConfig();
const next = { customCSS: String(customCSS || "").slice(0, 51200) };
const next: Config["oidc"] = {
...current.oidc,
...updates,
enabled:
updates.enabled !== undefined
? Boolean(updates.enabled)
: current.oidc.enabled,
label: String(updates.label ?? current.oidc.label ?? DEFAULT_CONFIG.oidc.label).trim() || DEFAULT_CONFIG.oidc.label,
issuer: String(updates.issuer ?? current.oidc.issuer ?? "").trim(),
clientId: String(updates.clientId ?? current.oidc.clientId ?? "").trim(),
clientSecret:
typeof updates.clientSecret === "string" && updates.clientSecret.length > 0
? updates.clientSecret.trim()
: current.oidc.clientSecret,
redirectUri: String(updates.redirectUri ?? current.oidc.redirectUri ?? "").trim(),
scopes: (() => {
const scopes = updates.scopes !== undefined ? splitList(updates.scopes) : splitList(current.oidc.scopes);
return scopes.length > 0 ? scopes : [...DEFAULT_CONFIG.oidc.scopes];
})(),
clientAuthMethod: pickClientAuthMethod(updates.clientAuthMethod ?? current.oidc.clientAuthMethod),
autoProvision:
updates.autoProvision !== undefined
? Boolean(updates.autoProvision)
: current.oidc.autoProvision,
allowEmailLink:
updates.allowEmailLink !== undefined
? Boolean(updates.allowEmailLink)
: current.oidc.allowEmailLink,
defaultRole: pickRole(updates.defaultRole ?? current.oidc.defaultRole, current.oidc.defaultRole),
allowedEmails:
updates.allowedEmails !== undefined
? splitList(updates.allowedEmails).map((email) => email.toLowerCase())
: splitList(current.oidc.allowedEmails),
allowedDomains:
updates.allowedDomains !== undefined
? splitList(updates.allowedDomains).map((domain) => domain.toLowerCase())
: splitList(current.oidc.allowedDomains),
groupClaim: String(updates.groupClaim ?? current.oidc.groupClaim ?? DEFAULT_CONFIG.oidc.groupClaim).trim() || DEFAULT_CONFIG.oidc.groupClaim,
adminGroups:
updates.adminGroups !== undefined
? splitList(updates.adminGroups)
: splitList(current.oidc.adminGroups),
operatorGroups:
updates.operatorGroups !== undefined
? splitList(updates.operatorGroups)
: splitList(current.oidc.operatorGroups),
viewerGroups:
updates.viewerGroups !== undefined
? splitList(updates.viewerGroups)
: splitList(current.oidc.viewerGroups),
};
configCache = {
...current,
oidc: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.oidc = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateAppearanceConfig(
customCSS: string,
loginBranding?: Partial<Config["appearance"]["loginBranding"]>,
): Promise<Config["appearance"]> {
const current = getConfig();
const next: Config["appearance"] = {
...current.appearance,
customCSS: String(customCSS || "").slice(0, 51200),
loginBranding: cleanLoginBranding(
loginBranding ? { ...current.appearance.loginBranding, ...loginBranding } : current.appearance.loginBranding,
DEFAULT_CONFIG.appearance.loginBranding,
),
};
configCache = {
...current,
@@ -1088,6 +1387,10 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
jwtSecret: config.auth.jwtSecret,
agentToken: config.auth.agentToken,
},
oidc: {
...config.oidc,
clientSecret: "",
},
notifications: config.notifications,
security: config.security,
tls: config.tls,
@@ -1116,6 +1419,8 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
security: process.env.OVERLORD_SESSION_TTL_HOURS || process.env.OVERLORD_LOGIN_MAX_ATTEMPTS,
tls: process.env.OVERLORD_TLS_CERT || process.env.OVERLORD_TLS_CERTBOT_ENABLED,
auth: process.env.JWT_SECRET || process.env.OVERLORD_AGENT_TOKEN,
oidc: process.env.OVERLORD_OIDC_ENABLED || process.env.OVERLORD_OIDC_ISSUER || process.env.OVERLORD_OIDC_CLIENT_ID,
appearance: process.env.OVERLORD_LOGIN_BRAND_NAME || process.env.OVERLORD_LOGIN_LOGO_URL || process.env.OVERLORD_LOGIN_TITLE,
};
if (data.notifications && typeof data.notifications === "object") {
@@ -1150,8 +1455,15 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
if (data.appearance && typeof data.appearance === "object") {
const css = typeof data.appearance.customCSS === "string" ? data.appearance.customCSS : "";
if (css.length <= 51200) {
await updateAppearanceConfig(css);
const loginBranding =
data.appearance.loginBranding && typeof data.appearance.loginBranding === "object"
? data.appearance.loginBranding
: undefined;
await updateAppearanceConfig(css, loginBranding);
applied.push("appearance");
if (envOverrides.appearance) {
warnings.push("Some login branding settings may be overridden by OVERLORD_LOGIN_* environment variables after restart.");
}
} else {
warnings.push("Custom CSS exceeds 50 KB limit and was skipped.");
}
@@ -1172,6 +1484,37 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
applied.push("registration");
}
if (data.oidc && typeof data.oidc === "object") {
const current = getConfig();
const next: Config["oidc"] = {
...current.oidc,
...data.oidc,
clientSecret:
typeof data.oidc.clientSecret === "string" && data.oidc.clientSecret
? data.oidc.clientSecret
: current.oidc.clientSecret,
enabled: Boolean(data.oidc.enabled),
scopes: splitList(data.oidc.scopes).length > 0 ? splitList(data.oidc.scopes) : current.oidc.scopes,
clientAuthMethod: pickClientAuthMethod(data.oidc.clientAuthMethod),
autoProvision: data.oidc.autoProvision !== undefined ? Boolean(data.oidc.autoProvision) : current.oidc.autoProvision,
allowEmailLink: data.oidc.allowEmailLink !== undefined ? Boolean(data.oidc.allowEmailLink) : current.oidc.allowEmailLink,
defaultRole: pickRole(data.oidc.defaultRole, current.oidc.defaultRole),
allowedEmails: splitList(data.oidc.allowedEmails),
allowedDomains: splitList(data.oidc.allowedDomains),
adminGroups: splitList(data.oidc.adminGroups),
operatorGroups: splitList(data.oidc.operatorGroups),
viewerGroups: splitList(data.oidc.viewerGroups),
};
configCache = { ...current, oidc: next };
const fileConfig = readFileConfigForUpdate();
fileConfig.oidc = next;
await writePersistentFileConfig(fileConfig);
applied.push("oidc");
if (envOverrides.oidc) {
warnings.push("Some OIDC settings may be overridden by OVERLORD_OIDC_* environment variables after restart.");
}
}
if (data.buildRateLimit && typeof data.buildRateLimit === "object") {
await updateBuildRateLimitConfig(data.buildRateLimit);
applied.push("buildRateLimit");
+1
View File
@@ -5,6 +5,7 @@ import { db, dbPath } from "./connection";
import "./schema";
export * from "./repositories/chat";
export * from "./repositories/oidc";
export * from "./repositories/revoked-tokens";
export * from "./repositories/sessions";
export * from "./repositories/shared-files";
+106
View File
@@ -0,0 +1,106 @@
import { db } from "../connection";
import "../schema";
export type OidcAuthState = {
state: string;
nonce: string;
codeVerifier: string;
returnTo: string | null;
createdAt: number;
expiresAt: number;
};
export type OidcIdentityRecord = {
issuer: string;
subject: string;
userId: number;
email: string | null;
username: string | null;
createdAt: number;
lastLogin: number;
};
export function saveOidcAuthState(record: OidcAuthState): void {
db.run(
`INSERT INTO oidc_auth_states (state, nonce, code_verifier, return_to, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
record.state,
record.nonce,
record.codeVerifier,
record.returnTo,
record.createdAt,
record.expiresAt,
);
}
export function takeOidcAuthState(state: string): OidcAuthState | null {
const row = db.query<any>(`SELECT * FROM oidc_auth_states WHERE state=?`).get(state);
if (!row) return null;
db.run(`DELETE FROM oidc_auth_states WHERE state=?`, state);
return {
state: row.state,
nonce: row.nonce,
codeVerifier: row.code_verifier,
returnTo: row.return_to,
createdAt: row.created_at,
expiresAt: row.expires_at,
};
}
export function pruneExpiredOidcAuthStates(now = Date.now()): number {
const result = db.run(`DELETE FROM oidc_auth_states WHERE expires_at <= ?`, now);
return result.changes;
}
export function getOidcIdentity(issuer: string, subject: string): OidcIdentityRecord | null {
const row = db.query<any>(
`SELECT * FROM oidc_identities WHERE issuer=? AND subject=?`,
).get(issuer, subject);
return row ? mapIdentity(row) : null;
}
export function getOidcIdentityByEmail(issuer: string, email: string): OidcIdentityRecord | null {
const row = db.query<any>(
`SELECT * FROM oidc_identities WHERE issuer=? AND LOWER(email)=LOWER(?) LIMIT 1`,
).get(issuer, email);
return row ? mapIdentity(row) : null;
}
export function upsertOidcIdentity(input: {
issuer: string;
subject: string;
userId: number;
email?: string | null;
username?: string | null;
now?: number;
}): void {
const now = input.now ?? Date.now();
db.run(
`INSERT INTO oidc_identities (issuer, subject, user_id, email, username, created_at, last_login)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(issuer, subject) DO UPDATE SET
user_id=excluded.user_id,
email=excluded.email,
username=excluded.username,
last_login=excluded.last_login`,
input.issuer,
input.subject,
input.userId,
input.email || null,
input.username || null,
now,
now,
);
}
function mapIdentity(row: any): OidcIdentityRecord {
return {
issuer: row.issuer,
subject: row.subject,
userId: row.user_id,
email: row.email || null,
username: row.username || null,
createdAt: row.created_at,
lastLogin: row.last_login,
};
}
+28
View File
@@ -302,6 +302,34 @@ db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);`);
db.run(`
CREATE TABLE IF NOT EXISTS oidc_auth_states (
state TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
return_to TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_oidc_auth_states_expires_at ON oidc_auth_states(expires_at);`);
db.run(`
CREATE TABLE IF NOT EXISTS oidc_identities (
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
user_id INTEGER NOT NULL,
email TEXT,
username TEXT,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL,
PRIMARY KEY (issuer, subject),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_oidc_identities_user_id ON oidc_identities(user_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_oidc_identities_email ON oidc_identities(email);`);
db.run(`
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
+2
View File
@@ -34,6 +34,7 @@ import { handleClientRoutes } from "./server/routes/client-routes";
import { handleMiscRoutes } from "./server/routes/misc-routes";
import { handleKeylogArchiveRoutes } from "./server/routes/keylog-archive-routes";
import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
import { handleOidcRoutes } from "./server/routes/oidc-routes";
import { handlePageRoutes } from "./server/routes/page-routes";
import { handlePluginRoutes } from "./server/routes/plugin-routes";
import { handleFileShareRoutes } from "./server/routes/file-share-routes";
@@ -712,6 +713,7 @@ async function startServer() {
requestIP: (srv as any).requestIP,
}),
(req, url, srv) => handleAuthRoutes(req, url, srv as any),
(req, url, srv) => handleOidcRoutes(req, url, srv as any),
(req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig),
(req, url) => handleAutoScriptsRoutes(req, url),
(req, url) => handleSavedScriptsRoutes(req, url),
+4
View File
@@ -95,6 +95,10 @@ const PERMISSIONS = {
description: "Change TLS and certificate settings",
roles: ["admin"],
},
"system:oidc": {
description: "Change OIDC and SSO login settings",
roles: ["admin"],
},
"system:registration": {
description: "Change user registration policy settings",
roles: ["admin"],
+489
View File
@@ -0,0 +1,489 @@
import { importJWK, jwtVerify, type JWK, type JWTPayload } from "jose";
import { getConfig, type Config } from "../config";
import {
getOidcIdentity,
getOidcIdentityByEmail,
pruneExpiredOidcAuthStates,
saveOidcAuthState,
takeOidcAuthState,
upsertOidcIdentity,
} from "../db";
import { logger } from "../logger";
import {
createExternalUser,
getUserById,
getUserByUsername,
updateLastLogin,
type User,
type UserRole,
} from "../users";
import { shouldUseSecureAuthCookie } from "./routes/auth-cookie";
type OidcDiscovery = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
};
type OidcClaims = JWTPayload & {
sub: string;
email?: string;
email_verified?: boolean;
preferred_username?: string;
name?: string;
groups?: unknown;
[key: string]: unknown;
};
const discoveryCache = new Map<string, { expiresAt: number; metadata: OidcDiscovery }>();
const jwksCache = new Map<string, { expiresAt: number; keys: JWK[] }>();
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
const STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_COOKIE = "overlord_oidc_state";
export function getOidcPublicStatus(): { enabled: boolean; label: string; loginUrl: string | null } {
const oidc = getConfig().oidc;
const configured = Boolean(oidc.enabled && oidc.issuer && oidc.clientId);
return {
enabled: configured,
label: oidc.label || "Single sign-on",
loginUrl: configured ? "/api/oidc/login" : null,
};
}
export async function createOidcLoginRedirect(req: Request, url: URL): Promise<Response> {
const oidc = getConfig().oidc;
if (!isOidcConfigured(oidc)) {
return Response.json({ error: "OIDC is not configured" }, { status: 404 });
}
pruneExpiredOidcAuthStates();
const metadata = await getDiscovery(oidc);
const state = randomBase64Url(32);
const nonce = randomBase64Url(32);
const codeVerifier = randomBase64Url(48);
const codeChallenge = await pkceChallenge(codeVerifier);
const redirectUri = getRedirectUri(oidc, url);
const returnTo = normalizeReturnTo(url.searchParams.get("returnTo") || req.headers.get("Referer"));
const now = Date.now();
saveOidcAuthState({
state,
nonce,
codeVerifier,
returnTo,
createdAt: now,
expiresAt: now + STATE_TTL_MS,
});
const authUrl = new URL(metadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oidc.clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", normalizeScopes(oidc.scopes).join(" "));
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("nonce", nonce);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
const headers = new Headers({ Location: authUrl.toString() });
headers.append("Set-Cookie", makeOidcStateCookie(state, req));
return new Response(null, { status: 302, headers });
}
export async function completeOidcLogin(req: Request, url: URL): Promise<{ user: User; returnTo: string }> {
const oidc = getConfig().oidc;
if (!isOidcConfigured(oidc)) {
throw new Error("OIDC is not configured");
}
const error = url.searchParams.get("error");
if (error) {
const description = url.searchParams.get("error_description");
throw new Error(description || error);
}
const code = url.searchParams.get("code") || "";
const state = url.searchParams.get("state") || "";
if (!code || !state) {
throw new Error("Missing OIDC callback parameters");
}
if (getOidcStateCookie(req) !== state) {
throw new Error("OIDC state cookie mismatch. Please try again.");
}
const savedState = takeOidcAuthState(state);
if (!savedState || savedState.expiresAt <= Date.now()) {
throw new Error("OIDC login expired. Please try again.");
}
const metadata = await getDiscovery(oidc);
const redirectUri = getRedirectUri(oidc, url);
const tokenSet = await exchangeCodeForTokens(oidc, metadata, {
code,
codeVerifier: savedState.codeVerifier,
redirectUri,
});
const idToken = typeof tokenSet.id_token === "string" ? tokenSet.id_token : "";
if (!idToken) {
throw new Error("OIDC provider did not return an ID token");
}
const claims = await verifyIdToken(oidc, metadata, idToken, savedState.nonce);
const mergedClaims = await maybeFetchUserinfo(metadata, tokenSet.access_token, claims);
const user = await resolveOidcUser(oidc, metadata.issuer, mergedClaims);
return { user, returnTo: savedState.returnTo || "/" };
}
function isOidcConfigured(oidc: Config["oidc"]): boolean {
return Boolean(oidc.enabled && oidc.issuer && oidc.clientId);
}
async function getDiscovery(oidc: Config["oidc"]): Promise<OidcDiscovery> {
const issuer = normalizeIssuerUrl(oidc.issuer);
const cached = discoveryCache.get(issuer);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
const res = await fetch(`${issuer}/.well-known/openid-configuration`, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`OIDC discovery failed (${res.status})`);
const metadata = await res.json() as OidcDiscovery;
if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.jwks_uri) {
throw new Error("OIDC discovery document is missing required endpoints");
}
const discoveredIssuer = normalizeIssuerUrl(metadata.issuer || "");
if (discoveredIssuer !== issuer) {
throw new Error("OIDC discovery issuer does not match the configured issuer");
}
const normalized = {
...metadata,
issuer: discoveredIssuer,
authorization_endpoint: requireHttpsUrl(metadata.authorization_endpoint, "authorization_endpoint"),
token_endpoint: requireHttpsUrl(metadata.token_endpoint, "token_endpoint"),
jwks_uri: requireHttpsUrl(metadata.jwks_uri, "jwks_uri"),
userinfo_endpoint: metadata.userinfo_endpoint
? requireHttpsUrl(metadata.userinfo_endpoint, "userinfo_endpoint")
: undefined,
};
discoveryCache.set(issuer, {
expiresAt: Date.now() + DISCOVERY_TTL_MS,
metadata: normalized,
});
return normalized;
}
async function exchangeCodeForTokens(
oidc: Config["oidc"],
metadata: OidcDiscovery,
input: { code: string; codeVerifier: string; redirectUri: string },
): Promise<Record<string, any>> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code: input.code,
redirect_uri: input.redirectUri,
client_id: oidc.clientId,
code_verifier: input.codeVerifier,
});
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
};
if (oidc.clientAuthMethod === "client_secret_post" && oidc.clientSecret) {
body.set("client_secret", oidc.clientSecret);
} else if (oidc.clientAuthMethod === "client_secret_basic" && oidc.clientSecret) {
headers.Authorization = `Basic ${Buffer.from(`${oidc.clientId}:${oidc.clientSecret}`).toString("base64")}`;
}
const res = await fetch(metadata.token_endpoint, {
method: "POST",
headers,
body,
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = typeof payload.error_description === "string"
? payload.error_description
: typeof payload.error === "string"
? payload.error
: `HTTP ${res.status}`;
throw new Error(`OIDC token exchange failed: ${detail}`);
}
return payload;
}
async function verifyIdToken(
oidc: Config["oidc"],
metadata: OidcDiscovery,
idToken: string,
nonce: string,
): Promise<OidcClaims> {
const { payload } = await jwtVerify(idToken, async (protectedHeader) => {
const key = await getJwkForHeader(metadata, protectedHeader.kid, protectedHeader.alg);
return importJWK(key, protectedHeader.alg);
}, {
issuer: metadata.issuer,
audience: oidc.clientId,
});
if (payload.nonce !== nonce) {
throw new Error("OIDC nonce mismatch");
}
if (typeof payload.sub !== "string" || !payload.sub) {
throw new Error("OIDC ID token is missing subject");
}
return payload as OidcClaims;
}
async function getJwkForHeader(metadata: OidcDiscovery, kid?: string, alg?: string): Promise<JWK> {
const keys = await getJwks(metadata.jwks_uri);
const key = keys.find((candidate) =>
(!kid || candidate.kid === kid) &&
(!alg || !candidate.alg || candidate.alg === alg) &&
(!candidate.use || candidate.use === "sig"),
);
if (!key) {
throw new Error("OIDC signing key was not found in JWKS");
}
return key;
}
async function getJwks(jwksUri: string): Promise<JWK[]> {
const cached = jwksCache.get(jwksUri);
if (cached && cached.expiresAt > Date.now()) return cached.keys;
const res = await fetch(jwksUri, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`OIDC JWKS request failed (${res.status})`);
const body = await res.json() as { keys?: JWK[] };
const keys = Array.isArray(body.keys) ? body.keys : [];
if (!keys.length) throw new Error("OIDC JWKS did not contain signing keys");
jwksCache.set(jwksUri, { expiresAt: Date.now() + 10 * 60 * 1000, keys });
return keys;
}
async function maybeFetchUserinfo(
metadata: OidcDiscovery,
accessToken: unknown,
claims: OidcClaims,
): Promise<OidcClaims> {
if (!metadata.userinfo_endpoint || typeof accessToken !== "string" || !accessToken) {
return claims;
}
try {
const res = await fetch(metadata.userinfo_endpoint, {
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return claims;
const userinfo = await res.json();
return { ...claims, ...userinfo, sub: claims.sub };
} catch (err) {
logger.warn("[oidc] userinfo request failed", err);
return claims;
}
}
export async function resolveOidcUser(oidc: Config["oidc"], issuer: string, claims: OidcClaims): Promise<User> {
const email = typeof claims.email === "string" ? claims.email.trim().toLowerCase() : "";
const usernameClaim = typeof claims.preferred_username === "string"
? claims.preferred_username
: typeof claims.name === "string"
? claims.name
: "";
enforceAllowedIdentity(oidc, email);
const existingIdentity = getOidcIdentity(issuer, claims.sub);
if (existingIdentity) {
const existingUser = getUserById(existingIdentity.userId);
if (existingUser) {
updateLastLogin(existingUser.id);
upsertOidcIdentity({
issuer,
subject: claims.sub,
userId: existingUser.id,
email,
username: usernameClaim,
});
return existingUser;
}
}
if (oidc.allowEmailLink && email && claims.email_verified === true) {
const existingEmailIdentity = getOidcIdentityByEmail(issuer, email);
if (existingEmailIdentity) {
const user = getUserById(existingEmailIdentity.userId);
if (user) {
updateLastLogin(user.id);
upsertOidcIdentity({ issuer, subject: claims.sub, userId: user.id, email, username: usernameClaim });
return user;
}
}
}
if (!oidc.autoProvision) {
throw new Error("No linked Overlord account exists for this OIDC identity");
}
const username = pickAvailableUsername(usernameClaim || email.split("@")[0] || `oidc_${claims.sub.slice(0, 12)}`);
const role = roleFromClaims(oidc, claims);
const created = await createExternalUser(username, role, "oidc", "oidc");
if (!created.success || !created.userId) {
throw new Error(created.error || "Failed to provision OIDC user");
}
upsertOidcIdentity({ issuer, subject: claims.sub, userId: created.userId, email, username: usernameClaim });
updateLastLogin(created.userId);
const user = getUserById(created.userId);
if (!user) throw new Error("Provisioned user could not be loaded");
return user;
}
function enforceAllowedIdentity(oidc: Config["oidc"], email: string): void {
const allowedEmails = oidc.allowedEmails.map((v) => v.toLowerCase());
const allowedDomains = oidc.allowedDomains.map((v) => v.toLowerCase().replace(/^@/, ""));
if (allowedEmails.length > 0 && (!email || !allowedEmails.includes(email))) {
throw new Error("This OIDC account is not allowed to access Overlord");
}
if (allowedDomains.length > 0) {
const domain = email.includes("@") ? email.split("@").pop()!.toLowerCase() : "";
if (!domain || !allowedDomains.includes(domain)) {
throw new Error("This OIDC email domain is not allowed to access Overlord");
}
}
}
function roleFromClaims(oidc: Config["oidc"], claims: OidcClaims): UserRole {
const groups = getClaimStrings(claims[oidc.groupClaim || "groups"]);
const hasGroup = (configured: string[]) => configured.some((group) => groups.has(group));
if (hasGroup(oidc.adminGroups)) return "admin";
if (hasGroup(oidc.operatorGroups)) return "operator";
if (hasGroup(oidc.viewerGroups)) return "viewer";
return oidc.defaultRole;
}
function getClaimStrings(value: unknown): Set<string> {
if (Array.isArray(value)) return new Set(value.map((v) => String(v)));
if (typeof value === "string" && value.trim()) return new Set([value.trim()]);
return new Set();
}
function pickAvailableUsername(seed: string): string {
const base = sanitizeUsername(seed) || "oidc_user";
if (!getUserByUsername(base)) return base;
const trimmed = base.slice(0, 27);
for (let i = 1; i < 1000; i++) {
const candidate = `${trimmed}_${i}`;
if (!getUserByUsername(candidate)) return candidate;
}
return `oidc_${randomBase64Url(8)}`.slice(0, 32);
}
function sanitizeUsername(value: string): string {
const cleaned = value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 32);
return cleaned.length >= 3 ? cleaned : "";
}
function normalizeScopes(scopes: string[]): string[] {
const result = Array.from(new Set(["openid", ...scopes.map((s) => String(s).trim()).filter(Boolean)]));
return result;
}
function getRedirectUri(oidc: Config["oidc"], url: URL): string {
return oidc.redirectUri || new URL("/api/oidc/callback", url.origin).toString();
}
export function makeOidcStateCookie(state: string, req: Request): string {
const securePart = shouldUseSecureAuthCookie(req) ? "Secure; " : "";
return `${OIDC_STATE_COOKIE}=${state}; HttpOnly; ${securePart}SameSite=Lax; Path=/api/oidc/callback; Max-Age=${Math.ceil(STATE_TTL_MS / 1000)}`;
}
export function makeOidcStateCookieClear(req: Request): string {
const securePart = shouldUseSecureAuthCookie(req) ? "Secure; " : "";
return `${OIDC_STATE_COOKIE}=; HttpOnly; ${securePart}SameSite=Lax; Path=/api/oidc/callback; Max-Age=0`;
}
function getOidcStateCookie(req: Request): string | null {
const cookieHeader = req.headers.get("Cookie");
if (!cookieHeader) return null;
const cookies = cookieHeader.split(/;\s*/);
for (const cookie of cookies) {
const [name, ...valueParts] = cookie.split("=");
if (name === OIDC_STATE_COOKIE) {
return valueParts.join("=") || null;
}
}
return null;
}
function normalizeIssuerUrl(value: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("OIDC issuer must be a valid HTTPS URL");
}
if (parsed.protocol !== "https:") {
throw new Error("OIDC issuer must use HTTPS");
}
if (parsed.username || parsed.password) {
throw new Error("OIDC issuer must not include credentials");
}
return parsed.toString().replace(/\/+$/, "");
}
function requireHttpsUrl(value: string, field: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`OIDC ${field} must be a valid HTTPS URL`);
}
if (parsed.protocol !== "https:") {
throw new Error(`OIDC ${field} must use HTTPS`);
}
if (parsed.username || parsed.password) {
throw new Error(`OIDC ${field} must not include credentials`);
}
return parsed.toString();
}
function normalizeReturnTo(value: string | null): string | null {
if (!value) return null;
try {
const url = new URL(value, "https://placeholder.local");
const relative = `${url.pathname}${url.search}${url.hash}`;
if (!relative.startsWith("/") || relative.startsWith("//")) return null;
if (relative.startsWith("/api/")) return null;
return relative;
} catch {
return null;
}
}
function randomBase64Url(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Buffer.from(buf).toString("base64url");
}
async function pkceChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return Buffer.from(digest).toString("base64url");
}
@@ -8,6 +8,7 @@ import {
revokeToken,
} from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { getConfig } from "../../config";
import { buildTotpUri, generateMfaSecret, verifyTotpCode } from "../../mfa";
import { createQrSvg } from "../../qr";
import {
@@ -50,6 +51,10 @@ export async function handleAuthRoutes(
url: URL,
server: RequestIpProvider,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/login/branding") {
return Response.json(getConfig().appearance.loginBranding);
}
if (req.method === "POST" && url.pathname === "/api/login") {
const ip = server.requestIP(req)?.address || "unknown";
@@ -1,7 +1,7 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import {
getClientMetricsSummary,
getClientMetricsSummaryForUser,
@@ -39,6 +39,15 @@ type MiscRouteDeps = {
tlsSource?: "certbot" | "configured" | "self-signed";
};
function maskOidcSettings() {
const oidc = getConfig().oidc;
return {
...oidc,
clientSecret: "",
clientSecretSet: Boolean(oidc.clientSecret),
};
}
function clampProfileDuration(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 5000;
@@ -686,6 +695,63 @@ export async function handleMiscRoutes(
}
}
if (url.pathname === "/api/settings/oidc") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:oidc");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
return Response.json({ oidc: maskOidcSettings() }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateOidcConfig({
enabled: Boolean(body?.enabled),
label: typeof body?.label === "string" ? body.label : undefined,
issuer: typeof body?.issuer === "string" ? body.issuer : undefined,
clientId: typeof body?.clientId === "string" ? body.clientId : undefined,
clientSecret: typeof body?.clientSecret === "string" && body.clientSecret.length > 0 ? body.clientSecret : undefined,
redirectUri: typeof body?.redirectUri === "string" ? body.redirectUri : undefined,
scopes: body?.scopes,
clientAuthMethod: body?.clientAuthMethod,
autoProvision: Boolean(body?.autoProvision),
allowEmailLink: Boolean(body?.allowEmailLink),
defaultRole: body?.defaultRole,
allowedEmails: body?.allowedEmails,
allowedDomains: body?.allowedDomains,
groupClaim: typeof body?.groupClaim === "string" ? body.groupClaim : undefined,
adminGroups: body?.adminGroups,
operatorGroups: body?.operatorGroups,
viewerGroups: body?.viewerGroups,
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Updated OIDC settings (enabled=${updated.enabled})`,
success: true,
});
return Response.json({ ok: true, oidc: maskOidcSettings() }, { headers: deps.CORS_HEADERS });
}
}
if (req.method === "POST" && url.pathname === "/api/settings/tls/certbot/setup") {
const user = await authenticateRequest(req);
if (!user) {
@@ -1135,8 +1201,12 @@ export async function handleMiscRoutes(
}
if (req.method === "GET") {
const appearance = getConfig().appearance;
return Response.json(
{ customCSS: getConfig().appearance?.customCSS || "" },
{
customCSS: appearance?.customCSS || "",
loginBranding: appearance.loginBranding,
},
{ headers: deps.CORS_HEADERS },
);
}
@@ -1153,8 +1223,12 @@ export async function handleMiscRoutes(
if (customCSS.length > 51200) {
return Response.json({ error: "CSS exceeds 50 KB limit" }, { status: 400 });
}
const loginBranding =
body?.loginBranding && typeof body.loginBranding === "object"
? body.loginBranding
: undefined;
const updated = await updateAppearanceConfig(customCSS);
const updated = await updateAppearanceConfig(customCSS, loginBranding);
logAudit({
timestamp: Date.now(),
@@ -1165,7 +1239,10 @@ export async function handleMiscRoutes(
success: true,
});
return Response.json({ ok: true, customCSS: updated.customCSS }, { headers: deps.CORS_HEADERS });
return Response.json(
{ ok: true, customCSS: updated.customCSS, loginBranding: updated.loginBranding },
{ headers: deps.CORS_HEADERS },
);
}
}
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { getConfig, updateOidcConfig, updateSecurityConfig, type Config } from "../../config";
import { createUser } from "../../users";
import { createOidcLoginRedirect, resolveOidcUser } from "../oidc";
import { getOidcLocalMfaBlockReason, handleOidcRoutes } from "./oidc-routes";
const server = {
requestIP: () => ({ address: "127.0.0.1" }),
};
let originalOidc: Config["oidc"];
let originalSecurity: Config["security"];
const originalFetch = globalThis.fetch;
beforeAll(() => {
originalOidc = { ...getConfig().oidc, scopes: [...getConfig().oidc.scopes] };
originalSecurity = { ...getConfig().security };
});
afterAll(async () => {
globalThis.fetch = originalFetch;
await updateOidcConfig(originalOidc);
await updateSecurityConfig(originalSecurity);
});
describe("OIDC routes", () => {
test("status reports disabled when OIDC is not configured", async () => {
const req = new Request("https://localhost/api/oidc/status");
const url = new URL(req.url);
const res = await handleOidcRoutes(req, url, server);
expect(res?.status).toBe(200);
const body = await res!.json();
expect(body.enabled).toBe(false);
expect(body.loginUrl).toBeNull();
});
test("login redirect stores a browser-bound OIDC state cookie", async () => {
const issuer = `https://oidc-state-${Date.now()}.example.test`;
await configureOidc(issuer);
mockDiscovery(issuer);
const req = new Request("https://localhost/api/oidc/login?returnTo=/settings.html");
const res = await createOidcLoginRedirect(req, new URL(req.url));
expect(res.status).toBe(302);
expect(res.headers.get("Set-Cookie")).toContain("overlord_oidc_state=");
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
expect(res.headers.get("Set-Cookie")).toContain("SameSite=Lax");
const redirect = new URL(res.headers.get("Location")!);
expect(redirect.searchParams.get("state")).toBeTruthy();
expect(redirect.searchParams.get("code_challenge_method")).toBe("S256");
});
test("callback rejects state values that are not bound to the browser cookie", async () => {
const issuer = `https://oidc-csrf-${Date.now()}.example.test`;
await configureOidc(issuer);
mockDiscovery(issuer);
const loginReq = new Request("https://localhost/api/oidc/login");
const loginRes = await createOidcLoginRedirect(loginReq, new URL(loginReq.url));
const state = new URL(loginRes.headers.get("Location")!).searchParams.get("state")!;
const callbackReq = new Request(`https://localhost/api/oidc/callback?code=fake-code&state=${state}`);
const callbackRes = await handleOidcRoutes(callbackReq, new URL(callbackReq.url), server);
expect(callbackRes?.status).toBe(302);
const location = new URL(callbackRes!.headers.get("Location")!);
expect(location.pathname).toBe("/login.html");
expect(location.searchParams.get("oidc_error")).toContain("state cookie mismatch");
expect(callbackRes!.headers.get("Set-Cookie")).toContain("overlord_oidc_state=;");
});
test("login redirect rejects non-HTTPS issuers before discovery", async () => {
await configureOidc("http://oidc-insecure.example.test");
setMockFetch(async () => {
throw new Error("fetch should not be called for insecure issuer");
});
const req = new Request("https://localhost/api/oidc/login");
await expect(createOidcLoginRedirect(req, new URL(req.url))).rejects.toThrow("HTTPS");
});
test("email linking does not fall back to local usernames", async () => {
const username = `oidc_link_${Date.now().toString(36)}`;
const created = await createUser(username, "Aa1!VeryLongTestPassword_2026", "operator", "test");
expect(created.success).toBe(true);
const oidc: Config["oidc"] = {
...getConfig().oidc,
enabled: true,
issuer: "https://issuer.example.test",
clientId: "client",
allowEmailLink: true,
autoProvision: false,
allowedEmails: [],
allowedDomains: [],
};
await expect(resolveOidcUser(oidc, "https://issuer.example.test", {
sub: `subject-${Date.now()}`,
email: `${username}@example.test`,
email_verified: true,
preferred_username: "not-local",
} as any)).rejects.toThrow("No linked Overlord account exists");
});
test("OIDC session issuance is blocked when local MFA is enabled or required", async () => {
await updateSecurityConfig({
...getConfig().security,
mfaRequiredForAdmins: true,
mfaRequiredForNonAdmins: false,
});
expect(getOidcLocalMfaBlockReason({
role: "operator",
mfa_enabled: 1,
mfa_secret: "secret",
})).toContain("Local MFA is enabled");
expect(getOidcLocalMfaBlockReason({
role: "admin",
mfa_enabled: 0,
mfa_secret: null,
})).toContain("Local MFA is required");
expect(getOidcLocalMfaBlockReason({
role: "operator",
mfa_enabled: 0,
mfa_secret: null,
})).toBeNull();
});
});
async function configureOidc(issuer: string): Promise<void> {
await updateOidcConfig({
enabled: true,
issuer,
clientId: "overlord-test",
clientSecret: "secret",
redirectUri: "https://localhost/api/oidc/callback",
scopes: ["openid", "profile", "email"],
clientAuthMethod: "client_secret_post",
autoProvision: false,
allowEmailLink: false,
defaultRole: "viewer",
allowedEmails: [],
allowedDomains: [],
groupClaim: "groups",
adminGroups: [],
operatorGroups: [],
viewerGroups: [],
});
}
function mockDiscovery(issuer: string): void {
setMockFetch(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === `${issuer}/.well-known/openid-configuration`) {
return Response.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
});
}
throw new Error(`Unexpected OIDC fetch: ${url}`);
});
}
function setMockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>): void {
globalThis.fetch = Object.assign(handler, {
preconnect: originalFetch.preconnect,
}) as typeof fetch;
}
@@ -0,0 +1,98 @@
import { generateToken, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { isMfaRequiredForUser, type User } from "../../users";
import {
completeOidcLogin,
createOidcLoginRedirect,
getOidcPublicStatus,
makeOidcStateCookieClear,
} from "../oidc";
import { makeAuthCookie } from "./auth-cookie";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
};
export async function handleOidcRoutes(
req: Request,
url: URL,
server: RequestIpProvider,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/oidc/status") {
return Response.json(getOidcPublicStatus());
}
if (req.method === "GET" && url.pathname === "/api/oidc/login") {
try {
return await createOidcLoginRedirect(req, url);
} catch (error) {
logger.error("[oidc] login redirect failed", error);
return redirectToLoginError(url, "OIDC login is not available");
}
}
if (req.method === "GET" && url.pathname === "/api/oidc/callback") {
const ip = server.requestIP(req)?.address || "unknown";
try {
const { user, returnTo } = await completeOidcLogin(req, url);
const mfaBlockReason = getOidcLocalMfaBlockReason(user);
if (mfaBlockReason) throw new Error(mfaBlockReason);
const userAgent = req.headers.get("User-Agent") || null;
const token = await generateToken(user, { ip, userAgent: userAgent || undefined });
const headers = new Headers({
"Location": new URL(returnTo, url.origin).toString(),
});
headers.append("Set-Cookie", makeAuthCookie(token, getSessionTtlSeconds(), req));
headers.append("Set-Cookie", makeOidcStateCookieClear(req));
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.LOGIN,
success: true,
details: "OIDC login",
});
return new Response(null, {
status: 302,
headers,
});
} catch (error) {
logger.warn("[oidc] callback failed", error);
logAudit({
timestamp: Date.now(),
username: "unknown",
ip,
action: AuditAction.LOGIN_FAILED,
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
return redirectToLoginError(url, error instanceof Error ? error.message : "OIDC login failed", req);
}
}
return null;
}
export function getOidcLocalMfaBlockReason(
user: Pick<User, "role" | "mfa_enabled" | "mfa_secret">,
): string | null {
if (user.mfa_enabled && user.mfa_secret) {
return "Local MFA is enabled for this account. Use password login to complete MFA.";
}
if (isMfaRequiredForUser(user)) {
return "Local MFA is required for this account and cannot be completed through OIDC.";
}
return null;
}
function redirectToLoginError(url: URL, message: string, req?: Request): Response {
const loginUrl = new URL("/login.html", url.origin);
loginUrl.searchParams.set("oidc_error", message);
const headers = new Headers({ Location: loginUrl.toString() });
if (req) headers.append("Set-Cookie", makeOidcStateCookieClear(req));
return new Response(null, { status: 302, headers });
}
+57
View File
@@ -713,6 +713,63 @@ export async function createUser(
}
}
export async function createExternalUser(
username: string,
role: UserRole,
createdBy: string,
registeredVia: string,
): Promise<{ success: boolean; error?: string; userId?: number }> {
if (!username || username.length < 3 || username.length > 32) {
return {
success: false,
error: "Username must be between 3 and 32 characters",
};
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return {
success: false,
error: "Username can only contain letters, numbers, hyphens, and underscores",
};
}
const existing = getUserByUsername(username);
if (existing) {
return { success: false, error: "Username already exists" };
}
try {
const passwordHash = await Bun.password.hash(crypto.randomUUID() + crypto.randomUUID(), {
algorithm: "bcrypt",
cost: 10,
});
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, plugin_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
)
.run(
username,
passwordHash,
role,
Date.now(),
createdBy,
role === "admin" ? "all" : "none",
role === "admin" ? "all" : "none",
role === "admin" || role === "operator" ? 1 : 0,
role === "admin" ? 1 : 0,
registeredVia,
);
invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
logger.error("[users] Create external user error:", err);
return { success: false, error: err.message || "Failed to create external user" };
}
}
export async function updateUserPassword(
userId: number,
newPassword: string,
+49
View File
@@ -21,6 +21,8 @@ Docker is the easiest way to run it.
- [No Docker (.bat / .sh)](#no-docker-bat--sh)
- [Production Package Scripts](#production-package-scripts)
- [WebRTC Streaming](#webrtc-streaming)
- [OIDC / SSO Login](#oidc--sso-login)
- [Login Branding](#login-branding)
- [Docker Notes (TLS, reverse proxy, cache)](#docker-notes-tls-reverse-proxy-cache)
---
@@ -402,6 +404,53 @@ The compose file passes a minimal set of `MTX_*` environment variables to MediaM
---
## OIDC / SSO Login
Overlord supports generic OIDC login for homelab identity providers such as Authentik, Authelia, Keycloak, Zitadel, and Dex. Local username/password login stays enabled as a fallback.
Configure your OIDC provider with this redirect URI:
```text
https://YOUR_OVERLORD_HOST/api/oidc/callback
```
Then set the relevant environment variables:
```env
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
```
New OIDC users are linked by the provider's `issuer + sub` identity. Email/username linking to an existing local account is disabled by default; enable `OVERLORD_OIDC_ALLOW_EMAIL_LINK=true` only if you trust your provider's verified email claims.
---
## Login Branding
Self-hosted and enterprise deployments can brand the login screen with environment variables:
```env
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
```
`OVERLORD_LOGIN_LOGO_URL` accepts absolute `http(s)` URLs or root-relative paths. If no logo URL is set, the login screen uses `OVERLORD_LOGIN_ICON_CLASS`.
---
## Docker Notes (TLS, reverse proxy, cache)
Notes on configs and workarounds.