OIDC + SSO
This commit is contained in:
@@ -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
|
||||
# ============================================
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user