OIDC + SSO
This commit is contained in:
@@ -40,6 +40,37 @@
|
|||||||
# Leave empty to auto-generate and persist to save.json
|
# Leave empty to auto-generate and persist to save.json
|
||||||
# OVERLORD_AGENT_TOKEN=
|
# 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
|
# ADVANCED: Custom TLS certificates
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -4,6 +4,52 @@ const pass = document.getElementById("pass");
|
|||||||
const mfaGroup = document.getElementById("mfa-group");
|
const mfaGroup = document.getElementById("mfa-group");
|
||||||
const mfaCode = document.getElementById("mfa-code");
|
const mfaCode = document.getElementById("mfa-code");
|
||||||
const errEl = document.getElementById("error");
|
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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -16,6 +62,19 @@ const errEl = document.getElementById("error");
|
|||||||
} catch {}
|
} 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") {
|
if (typeof anime !== "undefined") {
|
||||||
const particles = document.querySelectorAll(".particle");
|
const particles = document.querySelectorAll(".particle");
|
||||||
particles.forEach((particle, i) => {
|
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));
|
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 {
|
.login-brand {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
|
|||||||
@@ -82,6 +82,28 @@ const tlsCertbotCertFileInput = document.getElementById("tls-certbot-cert-file")
|
|||||||
const tlsCertbotKeyFileInput = document.getElementById("tls-certbot-key-file");
|
const tlsCertbotKeyFileInput = document.getElementById("tls-certbot-key-file");
|
||||||
const tlsCertbotCaFileInput = document.getElementById("tls-certbot-ca-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 appearanceForm = document.getElementById("appearance-form");
|
||||||
const appearancePermissionNote = document.getElementById("appearance-permission-note");
|
const appearancePermissionNote = document.getElementById("appearance-permission-note");
|
||||||
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
|
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
|
||||||
@@ -99,6 +121,7 @@ const wipeOfflineMessage = document.getElementById("wipe-offline-message");
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let securityConfig = null;
|
let securityConfig = null;
|
||||||
let tlsConfig = null;
|
let tlsConfig = null;
|
||||||
|
let oidcConfig = null;
|
||||||
|
|
||||||
function showMessage(text, type = "ok") {
|
function showMessage(text, type = "ok") {
|
||||||
if (!messageEl) return;
|
if (!messageEl) return;
|
||||||
@@ -212,6 +235,7 @@ async function renderPermissionsOverview() {
|
|||||||
perms: [
|
perms: [
|
||||||
"system:security",
|
"system:security",
|
||||||
"system:tls",
|
"system:tls",
|
||||||
|
"system:oidc",
|
||||||
"system:registration",
|
"system:registration",
|
||||||
"system:notifications",
|
"system:notifications",
|
||||||
"system:chat",
|
"system:chat",
|
||||||
@@ -425,6 +449,94 @@ async function loadTlsSettings() {
|
|||||||
setTlsFormDisabled(false);
|
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) {
|
function updateNavLayoutButtons(mode, sidebarBtn, topbarBtn) {
|
||||||
const active = ["bg-indigo-600/80", "border-indigo-500", "text-white"];
|
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"];
|
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.");
|
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() {
|
async function runCertbotAutoSetup() {
|
||||||
if (!requireUiPermission("system:tls", "TLS settings permission required.")) {
|
if (!requireUiPermission("system:tls", "TLS settings permission required.")) {
|
||||||
return;
|
return;
|
||||||
@@ -1448,6 +1636,7 @@ async function importSettings(event) {
|
|||||||
|
|
||||||
await loadSecurityPolicy();
|
await loadSecurityPolicy();
|
||||||
await loadTlsSettings();
|
await loadTlsSettings();
|
||||||
|
await loadOidcSettings();
|
||||||
await loadAppearanceSettings();
|
await loadAppearanceSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showExportImportMessage(`Import failed: ${String(error?.message || error)}`, "error");
|
showExportImportMessage(`Import failed: ${String(error?.message || error)}`, "error");
|
||||||
@@ -2429,6 +2618,7 @@ async function init() {
|
|||||||
securityForm.addEventListener("submit", saveSecurityPolicy);
|
securityForm.addEventListener("submit", saveSecurityPolicy);
|
||||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||||
|
if (oidcForm) oidcForm.addEventListener("submit", saveOidcSettings);
|
||||||
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
|
||||||
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
|
if (chatSettingsForm) chatSettingsForm.addEventListener("submit", saveChatSettings);
|
||||||
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
|
if (inputArchiveUserForm) inputArchiveUserForm.addEventListener("submit", saveInputArchivePreference);
|
||||||
|
|||||||
@@ -23,11 +23,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-crown-wrapper">
|
<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>
|
||||||
<div class="brand login-brand">Overlord</div>
|
<div id="login-brand" class="brand login-brand">Overlord</div>
|
||||||
<h2 class="login-title">Welcome back</h2>
|
<h2 id="login-title" class="login-title">Welcome back</h2>
|
||||||
<p class="muted login-subtitle">Sign in to your control plane</p>
|
<p id="login-subtitle" class="muted login-subtitle">Sign in to your control plane</p>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label">Username</label>
|
<label class="label">Username</label>
|
||||||
@@ -79,6 +80,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<div id="error" class="error" aria-live="polite"></div>
|
<div id="error" class="error" aria-live="polite"></div>
|
||||||
</form>
|
</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">
|
<div id="register-link" class="login-footer" style="display: none">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a href="/register.html">Create one</a>
|
<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-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-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-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="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="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>
|
<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>
|
</form>
|
||||||
</section>
|
</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 -->
|
<!-- 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">
|
<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">
|
<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", () => {
|
describe("auth MFA login", () => {
|
||||||
test("MFA-enabled user gets a challenge and can login with TOTP", async () => {
|
test("MFA-enabled user gets a challenge and can login with TOTP", async () => {
|
||||||
const username = `mfa_user_${Date.now().toString(36)}`;
|
const username = `mfa_user_${Date.now().toString(36)}`;
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ export interface Config {
|
|||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
agentToken: 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: {
|
server: {
|
||||||
port: number;
|
port: number;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -64,6 +83,14 @@ export interface Config {
|
|||||||
};
|
};
|
||||||
appearance: {
|
appearance: {
|
||||||
customCSS: string;
|
customCSS: string;
|
||||||
|
loginBranding: {
|
||||||
|
productName: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
iconClass: string;
|
||||||
|
logoUrl: string;
|
||||||
|
logoAlt: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
plugins: {
|
plugins: {
|
||||||
trustedKeys: string[];
|
trustedKeys: string[];
|
||||||
@@ -105,6 +132,25 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
jwtSecret: "",
|
jwtSecret: "",
|
||||||
agentToken: "",
|
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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
@@ -157,6 +203,14 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
customCSS: "",
|
customCSS: "",
|
||||||
|
loginBranding: {
|
||||||
|
productName: "Overlord",
|
||||||
|
title: "Welcome back",
|
||||||
|
subtitle: "Sign in to your control plane",
|
||||||
|
iconClass: "fa-solid fa-crown",
|
||||||
|
logoUrl: "",
|
||||||
|
logoAlt: "Overlord logo",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
trustedKeys: [],
|
trustedKeys: [],
|
||||||
@@ -198,6 +252,73 @@ function envBoolOverride(name: string): boolean | undefined {
|
|||||||
return 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 = {
|
type SaveSecrets = {
|
||||||
auth?: {
|
auth?: {
|
||||||
jwtSecret?: string;
|
jwtSecret?: string;
|
||||||
@@ -393,6 +514,82 @@ export function loadConfig(): Config {
|
|||||||
jwtSecret: finalJwtSecret,
|
jwtSecret: finalJwtSecret,
|
||||||
agentToken: finalAgentToken,
|
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: {
|
server: {
|
||||||
port:
|
port:
|
||||||
Number(process.env.PORT) ||
|
Number(process.env.PORT) ||
|
||||||
@@ -558,6 +755,30 @@ export function loadConfig(): Config {
|
|||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
|
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: {
|
plugins: {
|
||||||
trustedKeys: (() => {
|
trustedKeys: (() => {
|
||||||
@@ -828,9 +1049,87 @@ export async function updateTlsConfig(
|
|||||||
return next;
|
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 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 = {
|
configCache = {
|
||||||
...current,
|
...current,
|
||||||
@@ -1088,6 +1387,10 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
|||||||
jwtSecret: config.auth.jwtSecret,
|
jwtSecret: config.auth.jwtSecret,
|
||||||
agentToken: config.auth.agentToken,
|
agentToken: config.auth.agentToken,
|
||||||
},
|
},
|
||||||
|
oidc: {
|
||||||
|
...config.oidc,
|
||||||
|
clientSecret: "",
|
||||||
|
},
|
||||||
notifications: config.notifications,
|
notifications: config.notifications,
|
||||||
security: config.security,
|
security: config.security,
|
||||||
tls: config.tls,
|
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,
|
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,
|
tls: process.env.OVERLORD_TLS_CERT || process.env.OVERLORD_TLS_CERTBOT_ENABLED,
|
||||||
auth: process.env.JWT_SECRET || process.env.OVERLORD_AGENT_TOKEN,
|
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") {
|
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") {
|
if (data.appearance && typeof data.appearance === "object") {
|
||||||
const css = typeof data.appearance.customCSS === "string" ? data.appearance.customCSS : "";
|
const css = typeof data.appearance.customCSS === "string" ? data.appearance.customCSS : "";
|
||||||
if (css.length <= 51200) {
|
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");
|
applied.push("appearance");
|
||||||
|
if (envOverrides.appearance) {
|
||||||
|
warnings.push("Some login branding settings may be overridden by OVERLORD_LOGIN_* environment variables after restart.");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
warnings.push("Custom CSS exceeds 50 KB limit and was skipped.");
|
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");
|
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") {
|
if (data.buildRateLimit && typeof data.buildRateLimit === "object") {
|
||||||
await updateBuildRateLimitConfig(data.buildRateLimit);
|
await updateBuildRateLimitConfig(data.buildRateLimit);
|
||||||
applied.push("buildRateLimit");
|
applied.push("buildRateLimit");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db, dbPath } from "./connection";
|
|||||||
import "./schema";
|
import "./schema";
|
||||||
|
|
||||||
export * from "./repositories/chat";
|
export * from "./repositories/chat";
|
||||||
|
export * from "./repositories/oidc";
|
||||||
export * from "./repositories/revoked-tokens";
|
export * from "./repositories/revoked-tokens";
|
||||||
export * from "./repositories/sessions";
|
export * from "./repositories/sessions";
|
||||||
export * from "./repositories/shared-files";
|
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_token_hash ON sessions(token_hash);`);
|
||||||
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);`);
|
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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS builds (
|
CREATE TABLE IF NOT EXISTS builds (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { handleClientRoutes } from "./server/routes/client-routes";
|
|||||||
import { handleMiscRoutes } from "./server/routes/misc-routes";
|
import { handleMiscRoutes } from "./server/routes/misc-routes";
|
||||||
import { handleKeylogArchiveRoutes } from "./server/routes/keylog-archive-routes";
|
import { handleKeylogArchiveRoutes } from "./server/routes/keylog-archive-routes";
|
||||||
import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
|
import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
|
||||||
|
import { handleOidcRoutes } from "./server/routes/oidc-routes";
|
||||||
import { handlePageRoutes } from "./server/routes/page-routes";
|
import { handlePageRoutes } from "./server/routes/page-routes";
|
||||||
import { handlePluginRoutes } from "./server/routes/plugin-routes";
|
import { handlePluginRoutes } from "./server/routes/plugin-routes";
|
||||||
import { handleFileShareRoutes } from "./server/routes/file-share-routes";
|
import { handleFileShareRoutes } from "./server/routes/file-share-routes";
|
||||||
@@ -712,6 +713,7 @@ async function startServer() {
|
|||||||
requestIP: (srv as any).requestIP,
|
requestIP: (srv as any).requestIP,
|
||||||
}),
|
}),
|
||||||
(req, url, srv) => handleAuthRoutes(req, url, srv as any),
|
(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, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig),
|
||||||
(req, url) => handleAutoScriptsRoutes(req, url),
|
(req, url) => handleAutoScriptsRoutes(req, url),
|
||||||
(req, url) => handleSavedScriptsRoutes(req, url),
|
(req, url) => handleSavedScriptsRoutes(req, url),
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ const PERMISSIONS = {
|
|||||||
description: "Change TLS and certificate settings",
|
description: "Change TLS and certificate settings",
|
||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
},
|
},
|
||||||
|
"system:oidc": {
|
||||||
|
description: "Change OIDC and SSO login settings",
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
"system:registration": {
|
"system:registration": {
|
||||||
description: "Change user registration policy settings",
|
description: "Change user registration policy settings",
|
||||||
roles: ["admin"],
|
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,
|
revokeToken,
|
||||||
} from "../../auth";
|
} from "../../auth";
|
||||||
import { AuditAction, logAudit } from "../../auditLog";
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
|
import { getConfig } from "../../config";
|
||||||
import { buildTotpUri, generateMfaSecret, verifyTotpCode } from "../../mfa";
|
import { buildTotpUri, generateMfaSecret, verifyTotpCode } from "../../mfa";
|
||||||
import { createQrSvg } from "../../qr";
|
import { createQrSvg } from "../../qr";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,10 @@ export async function handleAuthRoutes(
|
|||||||
url: URL,
|
url: URL,
|
||||||
server: RequestIpProvider,
|
server: RequestIpProvider,
|
||||||
): Promise<Response | null> {
|
): 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") {
|
if (req.method === "POST" && url.pathname === "/api/login") {
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
|
||||||
import { logger } from "../../logger";
|
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 {
|
import {
|
||||||
getClientMetricsSummary,
|
getClientMetricsSummary,
|
||||||
getClientMetricsSummaryForUser,
|
getClientMetricsSummaryForUser,
|
||||||
@@ -39,6 +39,15 @@ type MiscRouteDeps = {
|
|||||||
tlsSource?: "certbot" | "configured" | "self-signed";
|
tlsSource?: "certbot" | "configured" | "self-signed";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function maskOidcSettings() {
|
||||||
|
const oidc = getConfig().oidc;
|
||||||
|
return {
|
||||||
|
...oidc,
|
||||||
|
clientSecret: "",
|
||||||
|
clientSecretSet: Boolean(oidc.clientSecret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function clampProfileDuration(value: unknown): number {
|
function clampProfileDuration(value: unknown): number {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) return 5000;
|
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") {
|
if (req.method === "POST" && url.pathname === "/api/settings/tls/certbot/setup") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1135,8 +1201,12 @@ export async function handleMiscRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
|
const appearance = getConfig().appearance;
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ customCSS: getConfig().appearance?.customCSS || "" },
|
{
|
||||||
|
customCSS: appearance?.customCSS || "",
|
||||||
|
loginBranding: appearance.loginBranding,
|
||||||
|
},
|
||||||
{ headers: deps.CORS_HEADERS },
|
{ headers: deps.CORS_HEADERS },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1153,8 +1223,12 @@ export async function handleMiscRoutes(
|
|||||||
if (customCSS.length > 51200) {
|
if (customCSS.length > 51200) {
|
||||||
return Response.json({ error: "CSS exceeds 50 KB limit" }, { status: 400 });
|
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({
|
logAudit({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -1165,7 +1239,10 @@ export async function handleMiscRoutes(
|
|||||||
success: true,
|
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(
|
export async function updateUserPassword(
|
||||||
userId: number,
|
userId: number,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Docker is the easiest way to run it.
|
|||||||
- [No Docker (.bat / .sh)](#no-docker-bat--sh)
|
- [No Docker (.bat / .sh)](#no-docker-bat--sh)
|
||||||
- [Production Package Scripts](#production-package-scripts)
|
- [Production Package Scripts](#production-package-scripts)
|
||||||
- [WebRTC Streaming](#webrtc-streaming)
|
- [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)
|
- [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)
|
## Docker Notes (TLS, reverse proxy, cache)
|
||||||
|
|
||||||
Notes on configs and workarounds.
|
Notes on configs and workarounds.
|
||||||
|
|||||||
Reference in New Issue
Block a user