OIDC + SSO

This commit is contained in:
KingKDot
2026-07-08 20:34:25 -05:00
parent 97e9bf8f3d
commit faac5b6029
20 changed files with 1885 additions and 11 deletions
+13
View File
@@ -31,6 +31,19 @@ describe("auth token extraction", () => {
});
});
describe("login branding", () => {
test("returns default public login branding", async () => {
const url = new URL("https://localhost/api/login/branding");
const res = await handleAuthRoutes(new Request(url), url, mockServer);
expect(res?.status).toBe(200);
const body = (await res!.json()) as any;
expect(body.productName).toBe("Overlord");
expect(body.title).toBe("Welcome back");
expect(body.logoUrl).toBe("");
});
});
describe("auth MFA login", () => {
test("MFA-enabled user gets a challenge and can login with TOTP", async () => {
const username = `mfa_user_${Date.now().toString(36)}`;
+346 -3
View File
@@ -12,6 +12,25 @@ export interface Config {
jwtSecret: string;
agentToken: string;
};
oidc: {
enabled: boolean;
label: string;
issuer: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string[];
clientAuthMethod: "client_secret_post" | "client_secret_basic" | "none";
autoProvision: boolean;
allowEmailLink: boolean;
defaultRole: "admin" | "operator" | "viewer";
allowedEmails: string[];
allowedDomains: string[];
groupClaim: string;
adminGroups: string[];
operatorGroups: string[];
viewerGroups: string[];
};
server: {
port: number;
host: string;
@@ -64,6 +83,14 @@ export interface Config {
};
appearance: {
customCSS: string;
loginBranding: {
productName: string;
title: string;
subtitle: string;
iconClass: string;
logoUrl: string;
logoAlt: string;
};
};
plugins: {
trustedKeys: string[];
@@ -105,6 +132,25 @@ const DEFAULT_CONFIG: Config = {
jwtSecret: "",
agentToken: "",
},
oidc: {
enabled: false,
label: "Single sign-on",
issuer: "",
clientId: "",
clientSecret: "",
redirectUri: "",
scopes: ["openid", "profile", "email"],
clientAuthMethod: "client_secret_post",
autoProvision: true,
allowEmailLink: false,
defaultRole: "viewer",
allowedEmails: [],
allowedDomains: [],
groupClaim: "groups",
adminGroups: [],
operatorGroups: [],
viewerGroups: [],
},
server: {
port: 5173,
host: "0.0.0.0",
@@ -157,6 +203,14 @@ const DEFAULT_CONFIG: Config = {
},
appearance: {
customCSS: "",
loginBranding: {
productName: "Overlord",
title: "Welcome back",
subtitle: "Sign in to your control plane",
iconClass: "fa-solid fa-crown",
logoUrl: "",
logoAlt: "Overlord logo",
},
},
plugins: {
trustedKeys: [],
@@ -198,6 +252,73 @@ function envBoolOverride(name: string): boolean | undefined {
return undefined;
}
function splitList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((v) => String(v).trim()).filter(Boolean);
}
if (typeof value === "string") {
return value.split(",").map((v) => v.trim()).filter(Boolean);
}
return [];
}
function pickRole(value: unknown, fallback: Config["oidc"]["defaultRole"]): Config["oidc"]["defaultRole"] {
return value === "admin" || value === "operator" || value === "viewer"
? value
: fallback;
}
function pickClientAuthMethod(value: unknown): Config["oidc"]["clientAuthMethod"] {
return value === "client_secret_basic" || value === "none"
? value
: "client_secret_post";
}
function cleanText(value: unknown, fallback: string, maxLength: number): string {
const raw = typeof value === "string" ? value : "";
const cleaned = raw
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
return (cleaned || fallback).slice(0, maxLength);
}
function cleanIconClass(value: unknown, fallback: string): string {
const raw = cleanText(value, fallback, 120);
const cleaned = raw.replace(/[^A-Za-z0-9_:\-\s]/g, "").replace(/\s+/g, " ").trim();
return cleaned || fallback;
}
function cleanLogoUrl(value: unknown): string {
const raw = cleanText(value, "", 2048);
if (!raw) return "";
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
try {
const parsed = new URL(raw);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return parsed.toString();
}
} catch {}
return "";
}
function cleanLoginBranding(
value: Partial<Config["appearance"]["loginBranding"]> | undefined,
fallback: Config["appearance"]["loginBranding"],
): Config["appearance"]["loginBranding"] {
const productName = cleanText(value?.productName, fallback.productName, 80);
return {
productName,
title: cleanText(value?.title, fallback.title, 120),
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
iconClass: cleanIconClass(value?.iconClass, fallback.iconClass),
logoUrl: cleanLogoUrl(value?.logoUrl),
logoAlt: cleanText(value?.logoAlt, `${productName} logo`, 120),
};
}
type SaveSecrets = {
auth?: {
jwtSecret?: string;
@@ -393,6 +514,82 @@ export function loadConfig(): Config {
jwtSecret: finalJwtSecret,
agentToken: finalAgentToken,
},
oidc: {
enabled:
envBoolOverride("OVERLORD_OIDC_ENABLED") ??
(fileConfig.oidc?.enabled !== undefined
? Boolean(fileConfig.oidc.enabled)
: DEFAULT_CONFIG.oidc.enabled),
label:
process.env.OVERLORD_OIDC_LABEL ||
fileConfig.oidc?.label ||
DEFAULT_CONFIG.oidc.label,
issuer:
process.env.OVERLORD_OIDC_ISSUER ||
fileConfig.oidc?.issuer ||
DEFAULT_CONFIG.oidc.issuer,
clientId:
process.env.OVERLORD_OIDC_CLIENT_ID ||
fileConfig.oidc?.clientId ||
DEFAULT_CONFIG.oidc.clientId,
clientSecret:
process.env.OVERLORD_OIDC_CLIENT_SECRET ||
fileConfig.oidc?.clientSecret ||
DEFAULT_CONFIG.oidc.clientSecret,
redirectUri:
process.env.OVERLORD_OIDC_REDIRECT_URI ||
fileConfig.oidc?.redirectUri ||
DEFAULT_CONFIG.oidc.redirectUri,
scopes: (() => {
const envScopes = splitList(process.env.OVERLORD_OIDC_SCOPES);
if (envScopes.length > 0) return envScopes;
const fileScopes = splitList(fileConfig.oidc?.scopes);
return fileScopes.length > 0 ? fileScopes : [...DEFAULT_CONFIG.oidc.scopes];
})(),
clientAuthMethod: pickClientAuthMethod(
process.env.OVERLORD_OIDC_CLIENT_AUTH_METHOD ||
fileConfig.oidc?.clientAuthMethod,
),
autoProvision:
envBoolOverride("OVERLORD_OIDC_AUTO_PROVISION") ??
(fileConfig.oidc?.autoProvision !== undefined
? Boolean(fileConfig.oidc.autoProvision)
: DEFAULT_CONFIG.oidc.autoProvision),
allowEmailLink:
envBoolOverride("OVERLORD_OIDC_ALLOW_EMAIL_LINK") ??
(fileConfig.oidc?.allowEmailLink !== undefined
? Boolean(fileConfig.oidc.allowEmailLink)
: DEFAULT_CONFIG.oidc.allowEmailLink),
defaultRole: pickRole(
process.env.OVERLORD_OIDC_DEFAULT_ROLE ||
fileConfig.oidc?.defaultRole,
DEFAULT_CONFIG.oidc.defaultRole,
),
allowedEmails: (() => {
const envEmails = splitList(process.env.OVERLORD_OIDC_ALLOWED_EMAILS);
return envEmails.length > 0 ? envEmails : splitList(fileConfig.oidc?.allowedEmails);
})(),
allowedDomains: (() => {
const envDomains = splitList(process.env.OVERLORD_OIDC_ALLOWED_DOMAINS);
return envDomains.length > 0 ? envDomains : splitList(fileConfig.oidc?.allowedDomains);
})(),
groupClaim:
process.env.OVERLORD_OIDC_GROUP_CLAIM ||
fileConfig.oidc?.groupClaim ||
DEFAULT_CONFIG.oidc.groupClaim,
adminGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_ADMIN_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.adminGroups);
})(),
operatorGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_OPERATOR_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.operatorGroups);
})(),
viewerGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_VIEWER_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.viewerGroups);
})(),
},
server: {
port:
Number(process.env.PORT) ||
@@ -558,6 +755,30 @@ export function loadConfig(): Config {
},
appearance: {
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
loginBranding: cleanLoginBranding(
{
...(fileConfig.appearance?.loginBranding || {}),
productName:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.productName,
title:
process.env.OVERLORD_LOGIN_TITLE ||
fileConfig.appearance?.loginBranding?.title,
subtitle:
process.env.OVERLORD_LOGIN_SUBTITLE ||
fileConfig.appearance?.loginBranding?.subtitle,
iconClass:
process.env.OVERLORD_LOGIN_ICON_CLASS ||
fileConfig.appearance?.loginBranding?.iconClass,
logoUrl:
process.env.OVERLORD_LOGIN_LOGO_URL ||
fileConfig.appearance?.loginBranding?.logoUrl,
logoAlt:
process.env.OVERLORD_LOGIN_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.logoAlt,
},
DEFAULT_CONFIG.appearance.loginBranding,
),
},
plugins: {
trustedKeys: (() => {
@@ -828,9 +1049,87 @@ export async function updateTlsConfig(
return next;
}
export async function updateAppearanceConfig(customCSS: string): Promise<Config["appearance"]> {
export async function updateOidcConfig(
updates: Partial<Config["oidc"]>,
): Promise<Config["oidc"]> {
const current = getConfig();
const next = { customCSS: String(customCSS || "").slice(0, 51200) };
const next: Config["oidc"] = {
...current.oidc,
...updates,
enabled:
updates.enabled !== undefined
? Boolean(updates.enabled)
: current.oidc.enabled,
label: String(updates.label ?? current.oidc.label ?? DEFAULT_CONFIG.oidc.label).trim() || DEFAULT_CONFIG.oidc.label,
issuer: String(updates.issuer ?? current.oidc.issuer ?? "").trim(),
clientId: String(updates.clientId ?? current.oidc.clientId ?? "").trim(),
clientSecret:
typeof updates.clientSecret === "string" && updates.clientSecret.length > 0
? updates.clientSecret.trim()
: current.oidc.clientSecret,
redirectUri: String(updates.redirectUri ?? current.oidc.redirectUri ?? "").trim(),
scopes: (() => {
const scopes = updates.scopes !== undefined ? splitList(updates.scopes) : splitList(current.oidc.scopes);
return scopes.length > 0 ? scopes : [...DEFAULT_CONFIG.oidc.scopes];
})(),
clientAuthMethod: pickClientAuthMethod(updates.clientAuthMethod ?? current.oidc.clientAuthMethod),
autoProvision:
updates.autoProvision !== undefined
? Boolean(updates.autoProvision)
: current.oidc.autoProvision,
allowEmailLink:
updates.allowEmailLink !== undefined
? Boolean(updates.allowEmailLink)
: current.oidc.allowEmailLink,
defaultRole: pickRole(updates.defaultRole ?? current.oidc.defaultRole, current.oidc.defaultRole),
allowedEmails:
updates.allowedEmails !== undefined
? splitList(updates.allowedEmails).map((email) => email.toLowerCase())
: splitList(current.oidc.allowedEmails),
allowedDomains:
updates.allowedDomains !== undefined
? splitList(updates.allowedDomains).map((domain) => domain.toLowerCase())
: splitList(current.oidc.allowedDomains),
groupClaim: String(updates.groupClaim ?? current.oidc.groupClaim ?? DEFAULT_CONFIG.oidc.groupClaim).trim() || DEFAULT_CONFIG.oidc.groupClaim,
adminGroups:
updates.adminGroups !== undefined
? splitList(updates.adminGroups)
: splitList(current.oidc.adminGroups),
operatorGroups:
updates.operatorGroups !== undefined
? splitList(updates.operatorGroups)
: splitList(current.oidc.operatorGroups),
viewerGroups:
updates.viewerGroups !== undefined
? splitList(updates.viewerGroups)
: splitList(current.oidc.viewerGroups),
};
configCache = {
...current,
oidc: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.oidc = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateAppearanceConfig(
customCSS: string,
loginBranding?: Partial<Config["appearance"]["loginBranding"]>,
): Promise<Config["appearance"]> {
const current = getConfig();
const next: Config["appearance"] = {
...current.appearance,
customCSS: String(customCSS || "").slice(0, 51200),
loginBranding: cleanLoginBranding(
loginBranding ? { ...current.appearance.loginBranding, ...loginBranding } : current.appearance.loginBranding,
DEFAULT_CONFIG.appearance.loginBranding,
),
};
configCache = {
...current,
@@ -1088,6 +1387,10 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
jwtSecret: config.auth.jwtSecret,
agentToken: config.auth.agentToken,
},
oidc: {
...config.oidc,
clientSecret: "",
},
notifications: config.notifications,
security: config.security,
tls: config.tls,
@@ -1116,6 +1419,8 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
security: process.env.OVERLORD_SESSION_TTL_HOURS || process.env.OVERLORD_LOGIN_MAX_ATTEMPTS,
tls: process.env.OVERLORD_TLS_CERT || process.env.OVERLORD_TLS_CERTBOT_ENABLED,
auth: process.env.JWT_SECRET || process.env.OVERLORD_AGENT_TOKEN,
oidc: process.env.OVERLORD_OIDC_ENABLED || process.env.OVERLORD_OIDC_ISSUER || process.env.OVERLORD_OIDC_CLIENT_ID,
appearance: process.env.OVERLORD_LOGIN_BRAND_NAME || process.env.OVERLORD_LOGIN_LOGO_URL || process.env.OVERLORD_LOGIN_TITLE,
};
if (data.notifications && typeof data.notifications === "object") {
@@ -1150,8 +1455,15 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
if (data.appearance && typeof data.appearance === "object") {
const css = typeof data.appearance.customCSS === "string" ? data.appearance.customCSS : "";
if (css.length <= 51200) {
await updateAppearanceConfig(css);
const loginBranding =
data.appearance.loginBranding && typeof data.appearance.loginBranding === "object"
? data.appearance.loginBranding
: undefined;
await updateAppearanceConfig(css, loginBranding);
applied.push("appearance");
if (envOverrides.appearance) {
warnings.push("Some login branding settings may be overridden by OVERLORD_LOGIN_* environment variables after restart.");
}
} else {
warnings.push("Custom CSS exceeds 50 KB limit and was skipped.");
}
@@ -1172,6 +1484,37 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
applied.push("registration");
}
if (data.oidc && typeof data.oidc === "object") {
const current = getConfig();
const next: Config["oidc"] = {
...current.oidc,
...data.oidc,
clientSecret:
typeof data.oidc.clientSecret === "string" && data.oidc.clientSecret
? data.oidc.clientSecret
: current.oidc.clientSecret,
enabled: Boolean(data.oidc.enabled),
scopes: splitList(data.oidc.scopes).length > 0 ? splitList(data.oidc.scopes) : current.oidc.scopes,
clientAuthMethod: pickClientAuthMethod(data.oidc.clientAuthMethod),
autoProvision: data.oidc.autoProvision !== undefined ? Boolean(data.oidc.autoProvision) : current.oidc.autoProvision,
allowEmailLink: data.oidc.allowEmailLink !== undefined ? Boolean(data.oidc.allowEmailLink) : current.oidc.allowEmailLink,
defaultRole: pickRole(data.oidc.defaultRole, current.oidc.defaultRole),
allowedEmails: splitList(data.oidc.allowedEmails),
allowedDomains: splitList(data.oidc.allowedDomains),
adminGroups: splitList(data.oidc.adminGroups),
operatorGroups: splitList(data.oidc.operatorGroups),
viewerGroups: splitList(data.oidc.viewerGroups),
};
configCache = { ...current, oidc: next };
const fileConfig = readFileConfigForUpdate();
fileConfig.oidc = next;
await writePersistentFileConfig(fileConfig);
applied.push("oidc");
if (envOverrides.oidc) {
warnings.push("Some OIDC settings may be overridden by OVERLORD_OIDC_* environment variables after restart.");
}
}
if (data.buildRateLimit && typeof data.buildRateLimit === "object") {
await updateBuildRateLimitConfig(data.buildRateLimit);
applied.push("buildRateLimit");
+1
View File
@@ -5,6 +5,7 @@ import { db, dbPath } from "./connection";
import "./schema";
export * from "./repositories/chat";
export * from "./repositories/oidc";
export * from "./repositories/revoked-tokens";
export * from "./repositories/sessions";
export * from "./repositories/shared-files";
+106
View File
@@ -0,0 +1,106 @@
import { db } from "../connection";
import "../schema";
export type OidcAuthState = {
state: string;
nonce: string;
codeVerifier: string;
returnTo: string | null;
createdAt: number;
expiresAt: number;
};
export type OidcIdentityRecord = {
issuer: string;
subject: string;
userId: number;
email: string | null;
username: string | null;
createdAt: number;
lastLogin: number;
};
export function saveOidcAuthState(record: OidcAuthState): void {
db.run(
`INSERT INTO oidc_auth_states (state, nonce, code_verifier, return_to, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
record.state,
record.nonce,
record.codeVerifier,
record.returnTo,
record.createdAt,
record.expiresAt,
);
}
export function takeOidcAuthState(state: string): OidcAuthState | null {
const row = db.query<any>(`SELECT * FROM oidc_auth_states WHERE state=?`).get(state);
if (!row) return null;
db.run(`DELETE FROM oidc_auth_states WHERE state=?`, state);
return {
state: row.state,
nonce: row.nonce,
codeVerifier: row.code_verifier,
returnTo: row.return_to,
createdAt: row.created_at,
expiresAt: row.expires_at,
};
}
export function pruneExpiredOidcAuthStates(now = Date.now()): number {
const result = db.run(`DELETE FROM oidc_auth_states WHERE expires_at <= ?`, now);
return result.changes;
}
export function getOidcIdentity(issuer: string, subject: string): OidcIdentityRecord | null {
const row = db.query<any>(
`SELECT * FROM oidc_identities WHERE issuer=? AND subject=?`,
).get(issuer, subject);
return row ? mapIdentity(row) : null;
}
export function getOidcIdentityByEmail(issuer: string, email: string): OidcIdentityRecord | null {
const row = db.query<any>(
`SELECT * FROM oidc_identities WHERE issuer=? AND LOWER(email)=LOWER(?) LIMIT 1`,
).get(issuer, email);
return row ? mapIdentity(row) : null;
}
export function upsertOidcIdentity(input: {
issuer: string;
subject: string;
userId: number;
email?: string | null;
username?: string | null;
now?: number;
}): void {
const now = input.now ?? Date.now();
db.run(
`INSERT INTO oidc_identities (issuer, subject, user_id, email, username, created_at, last_login)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(issuer, subject) DO UPDATE SET
user_id=excluded.user_id,
email=excluded.email,
username=excluded.username,
last_login=excluded.last_login`,
input.issuer,
input.subject,
input.userId,
input.email || null,
input.username || null,
now,
now,
);
}
function mapIdentity(row: any): OidcIdentityRecord {
return {
issuer: row.issuer,
subject: row.subject,
userId: row.user_id,
email: row.email || null,
username: row.username || null,
createdAt: row.created_at,
lastLogin: row.last_login,
};
}
+28
View File
@@ -302,6 +302,34 @@ db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);`);
db.run(`
CREATE TABLE IF NOT EXISTS oidc_auth_states (
state TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
return_to TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_oidc_auth_states_expires_at ON oidc_auth_states(expires_at);`);
db.run(`
CREATE TABLE IF NOT EXISTS oidc_identities (
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
user_id INTEGER NOT NULL,
email TEXT,
username TEXT,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL,
PRIMARY KEY (issuer, subject),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_oidc_identities_user_id ON oidc_identities(user_id);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_oidc_identities_email ON oidc_identities(email);`);
db.run(`
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
+2
View File
@@ -34,6 +34,7 @@ import { handleClientRoutes } from "./server/routes/client-routes";
import { handleMiscRoutes } from "./server/routes/misc-routes";
import { handleKeylogArchiveRoutes } from "./server/routes/keylog-archive-routes";
import { handleNotificationsConfigRoutes } from "./server/routes/notifications-config-routes";
import { handleOidcRoutes } from "./server/routes/oidc-routes";
import { handlePageRoutes } from "./server/routes/page-routes";
import { handlePluginRoutes } from "./server/routes/plugin-routes";
import { handleFileShareRoutes } from "./server/routes/file-share-routes";
@@ -712,6 +713,7 @@ async function startServer() {
requestIP: (srv as any).requestIP,
}),
(req, url, srv) => handleAuthRoutes(req, url, srv as any),
(req, url, srv) => handleOidcRoutes(req, url, srv as any),
(req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig),
(req, url) => handleAutoScriptsRoutes(req, url),
(req, url) => handleSavedScriptsRoutes(req, url),
+4
View File
@@ -95,6 +95,10 @@ const PERMISSIONS = {
description: "Change TLS and certificate settings",
roles: ["admin"],
},
"system:oidc": {
description: "Change OIDC and SSO login settings",
roles: ["admin"],
},
"system:registration": {
description: "Change user registration policy settings",
roles: ["admin"],
+489
View File
@@ -0,0 +1,489 @@
import { importJWK, jwtVerify, type JWK, type JWTPayload } from "jose";
import { getConfig, type Config } from "../config";
import {
getOidcIdentity,
getOidcIdentityByEmail,
pruneExpiredOidcAuthStates,
saveOidcAuthState,
takeOidcAuthState,
upsertOidcIdentity,
} from "../db";
import { logger } from "../logger";
import {
createExternalUser,
getUserById,
getUserByUsername,
updateLastLogin,
type User,
type UserRole,
} from "../users";
import { shouldUseSecureAuthCookie } from "./routes/auth-cookie";
type OidcDiscovery = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
};
type OidcClaims = JWTPayload & {
sub: string;
email?: string;
email_verified?: boolean;
preferred_username?: string;
name?: string;
groups?: unknown;
[key: string]: unknown;
};
const discoveryCache = new Map<string, { expiresAt: number; metadata: OidcDiscovery }>();
const jwksCache = new Map<string, { expiresAt: number; keys: JWK[] }>();
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
const STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_COOKIE = "overlord_oidc_state";
export function getOidcPublicStatus(): { enabled: boolean; label: string; loginUrl: string | null } {
const oidc = getConfig().oidc;
const configured = Boolean(oidc.enabled && oidc.issuer && oidc.clientId);
return {
enabled: configured,
label: oidc.label || "Single sign-on",
loginUrl: configured ? "/api/oidc/login" : null,
};
}
export async function createOidcLoginRedirect(req: Request, url: URL): Promise<Response> {
const oidc = getConfig().oidc;
if (!isOidcConfigured(oidc)) {
return Response.json({ error: "OIDC is not configured" }, { status: 404 });
}
pruneExpiredOidcAuthStates();
const metadata = await getDiscovery(oidc);
const state = randomBase64Url(32);
const nonce = randomBase64Url(32);
const codeVerifier = randomBase64Url(48);
const codeChallenge = await pkceChallenge(codeVerifier);
const redirectUri = getRedirectUri(oidc, url);
const returnTo = normalizeReturnTo(url.searchParams.get("returnTo") || req.headers.get("Referer"));
const now = Date.now();
saveOidcAuthState({
state,
nonce,
codeVerifier,
returnTo,
createdAt: now,
expiresAt: now + STATE_TTL_MS,
});
const authUrl = new URL(metadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oidc.clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", normalizeScopes(oidc.scopes).join(" "));
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("nonce", nonce);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
const headers = new Headers({ Location: authUrl.toString() });
headers.append("Set-Cookie", makeOidcStateCookie(state, req));
return new Response(null, { status: 302, headers });
}
export async function completeOidcLogin(req: Request, url: URL): Promise<{ user: User; returnTo: string }> {
const oidc = getConfig().oidc;
if (!isOidcConfigured(oidc)) {
throw new Error("OIDC is not configured");
}
const error = url.searchParams.get("error");
if (error) {
const description = url.searchParams.get("error_description");
throw new Error(description || error);
}
const code = url.searchParams.get("code") || "";
const state = url.searchParams.get("state") || "";
if (!code || !state) {
throw new Error("Missing OIDC callback parameters");
}
if (getOidcStateCookie(req) !== state) {
throw new Error("OIDC state cookie mismatch. Please try again.");
}
const savedState = takeOidcAuthState(state);
if (!savedState || savedState.expiresAt <= Date.now()) {
throw new Error("OIDC login expired. Please try again.");
}
const metadata = await getDiscovery(oidc);
const redirectUri = getRedirectUri(oidc, url);
const tokenSet = await exchangeCodeForTokens(oidc, metadata, {
code,
codeVerifier: savedState.codeVerifier,
redirectUri,
});
const idToken = typeof tokenSet.id_token === "string" ? tokenSet.id_token : "";
if (!idToken) {
throw new Error("OIDC provider did not return an ID token");
}
const claims = await verifyIdToken(oidc, metadata, idToken, savedState.nonce);
const mergedClaims = await maybeFetchUserinfo(metadata, tokenSet.access_token, claims);
const user = await resolveOidcUser(oidc, metadata.issuer, mergedClaims);
return { user, returnTo: savedState.returnTo || "/" };
}
function isOidcConfigured(oidc: Config["oidc"]): boolean {
return Boolean(oidc.enabled && oidc.issuer && oidc.clientId);
}
async function getDiscovery(oidc: Config["oidc"]): Promise<OidcDiscovery> {
const issuer = normalizeIssuerUrl(oidc.issuer);
const cached = discoveryCache.get(issuer);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
const res = await fetch(`${issuer}/.well-known/openid-configuration`, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`OIDC discovery failed (${res.status})`);
const metadata = await res.json() as OidcDiscovery;
if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.jwks_uri) {
throw new Error("OIDC discovery document is missing required endpoints");
}
const discoveredIssuer = normalizeIssuerUrl(metadata.issuer || "");
if (discoveredIssuer !== issuer) {
throw new Error("OIDC discovery issuer does not match the configured issuer");
}
const normalized = {
...metadata,
issuer: discoveredIssuer,
authorization_endpoint: requireHttpsUrl(metadata.authorization_endpoint, "authorization_endpoint"),
token_endpoint: requireHttpsUrl(metadata.token_endpoint, "token_endpoint"),
jwks_uri: requireHttpsUrl(metadata.jwks_uri, "jwks_uri"),
userinfo_endpoint: metadata.userinfo_endpoint
? requireHttpsUrl(metadata.userinfo_endpoint, "userinfo_endpoint")
: undefined,
};
discoveryCache.set(issuer, {
expiresAt: Date.now() + DISCOVERY_TTL_MS,
metadata: normalized,
});
return normalized;
}
async function exchangeCodeForTokens(
oidc: Config["oidc"],
metadata: OidcDiscovery,
input: { code: string; codeVerifier: string; redirectUri: string },
): Promise<Record<string, any>> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code: input.code,
redirect_uri: input.redirectUri,
client_id: oidc.clientId,
code_verifier: input.codeVerifier,
});
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
};
if (oidc.clientAuthMethod === "client_secret_post" && oidc.clientSecret) {
body.set("client_secret", oidc.clientSecret);
} else if (oidc.clientAuthMethod === "client_secret_basic" && oidc.clientSecret) {
headers.Authorization = `Basic ${Buffer.from(`${oidc.clientId}:${oidc.clientSecret}`).toString("base64")}`;
}
const res = await fetch(metadata.token_endpoint, {
method: "POST",
headers,
body,
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = typeof payload.error_description === "string"
? payload.error_description
: typeof payload.error === "string"
? payload.error
: `HTTP ${res.status}`;
throw new Error(`OIDC token exchange failed: ${detail}`);
}
return payload;
}
async function verifyIdToken(
oidc: Config["oidc"],
metadata: OidcDiscovery,
idToken: string,
nonce: string,
): Promise<OidcClaims> {
const { payload } = await jwtVerify(idToken, async (protectedHeader) => {
const key = await getJwkForHeader(metadata, protectedHeader.kid, protectedHeader.alg);
return importJWK(key, protectedHeader.alg);
}, {
issuer: metadata.issuer,
audience: oidc.clientId,
});
if (payload.nonce !== nonce) {
throw new Error("OIDC nonce mismatch");
}
if (typeof payload.sub !== "string" || !payload.sub) {
throw new Error("OIDC ID token is missing subject");
}
return payload as OidcClaims;
}
async function getJwkForHeader(metadata: OidcDiscovery, kid?: string, alg?: string): Promise<JWK> {
const keys = await getJwks(metadata.jwks_uri);
const key = keys.find((candidate) =>
(!kid || candidate.kid === kid) &&
(!alg || !candidate.alg || candidate.alg === alg) &&
(!candidate.use || candidate.use === "sig"),
);
if (!key) {
throw new Error("OIDC signing key was not found in JWKS");
}
return key;
}
async function getJwks(jwksUri: string): Promise<JWK[]> {
const cached = jwksCache.get(jwksUri);
if (cached && cached.expiresAt > Date.now()) return cached.keys;
const res = await fetch(jwksUri, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`OIDC JWKS request failed (${res.status})`);
const body = await res.json() as { keys?: JWK[] };
const keys = Array.isArray(body.keys) ? body.keys : [];
if (!keys.length) throw new Error("OIDC JWKS did not contain signing keys");
jwksCache.set(jwksUri, { expiresAt: Date.now() + 10 * 60 * 1000, keys });
return keys;
}
async function maybeFetchUserinfo(
metadata: OidcDiscovery,
accessToken: unknown,
claims: OidcClaims,
): Promise<OidcClaims> {
if (!metadata.userinfo_endpoint || typeof accessToken !== "string" || !accessToken) {
return claims;
}
try {
const res = await fetch(metadata.userinfo_endpoint, {
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return claims;
const userinfo = await res.json();
return { ...claims, ...userinfo, sub: claims.sub };
} catch (err) {
logger.warn("[oidc] userinfo request failed", err);
return claims;
}
}
export async function resolveOidcUser(oidc: Config["oidc"], issuer: string, claims: OidcClaims): Promise<User> {
const email = typeof claims.email === "string" ? claims.email.trim().toLowerCase() : "";
const usernameClaim = typeof claims.preferred_username === "string"
? claims.preferred_username
: typeof claims.name === "string"
? claims.name
: "";
enforceAllowedIdentity(oidc, email);
const existingIdentity = getOidcIdentity(issuer, claims.sub);
if (existingIdentity) {
const existingUser = getUserById(existingIdentity.userId);
if (existingUser) {
updateLastLogin(existingUser.id);
upsertOidcIdentity({
issuer,
subject: claims.sub,
userId: existingUser.id,
email,
username: usernameClaim,
});
return existingUser;
}
}
if (oidc.allowEmailLink && email && claims.email_verified === true) {
const existingEmailIdentity = getOidcIdentityByEmail(issuer, email);
if (existingEmailIdentity) {
const user = getUserById(existingEmailIdentity.userId);
if (user) {
updateLastLogin(user.id);
upsertOidcIdentity({ issuer, subject: claims.sub, userId: user.id, email, username: usernameClaim });
return user;
}
}
}
if (!oidc.autoProvision) {
throw new Error("No linked Overlord account exists for this OIDC identity");
}
const username = pickAvailableUsername(usernameClaim || email.split("@")[0] || `oidc_${claims.sub.slice(0, 12)}`);
const role = roleFromClaims(oidc, claims);
const created = await createExternalUser(username, role, "oidc", "oidc");
if (!created.success || !created.userId) {
throw new Error(created.error || "Failed to provision OIDC user");
}
upsertOidcIdentity({ issuer, subject: claims.sub, userId: created.userId, email, username: usernameClaim });
updateLastLogin(created.userId);
const user = getUserById(created.userId);
if (!user) throw new Error("Provisioned user could not be loaded");
return user;
}
function enforceAllowedIdentity(oidc: Config["oidc"], email: string): void {
const allowedEmails = oidc.allowedEmails.map((v) => v.toLowerCase());
const allowedDomains = oidc.allowedDomains.map((v) => v.toLowerCase().replace(/^@/, ""));
if (allowedEmails.length > 0 && (!email || !allowedEmails.includes(email))) {
throw new Error("This OIDC account is not allowed to access Overlord");
}
if (allowedDomains.length > 0) {
const domain = email.includes("@") ? email.split("@").pop()!.toLowerCase() : "";
if (!domain || !allowedDomains.includes(domain)) {
throw new Error("This OIDC email domain is not allowed to access Overlord");
}
}
}
function roleFromClaims(oidc: Config["oidc"], claims: OidcClaims): UserRole {
const groups = getClaimStrings(claims[oidc.groupClaim || "groups"]);
const hasGroup = (configured: string[]) => configured.some((group) => groups.has(group));
if (hasGroup(oidc.adminGroups)) return "admin";
if (hasGroup(oidc.operatorGroups)) return "operator";
if (hasGroup(oidc.viewerGroups)) return "viewer";
return oidc.defaultRole;
}
function getClaimStrings(value: unknown): Set<string> {
if (Array.isArray(value)) return new Set(value.map((v) => String(v)));
if (typeof value === "string" && value.trim()) return new Set([value.trim()]);
return new Set();
}
function pickAvailableUsername(seed: string): string {
const base = sanitizeUsername(seed) || "oidc_user";
if (!getUserByUsername(base)) return base;
const trimmed = base.slice(0, 27);
for (let i = 1; i < 1000; i++) {
const candidate = `${trimmed}_${i}`;
if (!getUserByUsername(candidate)) return candidate;
}
return `oidc_${randomBase64Url(8)}`.slice(0, 32);
}
function sanitizeUsername(value: string): string {
const cleaned = value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 32);
return cleaned.length >= 3 ? cleaned : "";
}
function normalizeScopes(scopes: string[]): string[] {
const result = Array.from(new Set(["openid", ...scopes.map((s) => String(s).trim()).filter(Boolean)]));
return result;
}
function getRedirectUri(oidc: Config["oidc"], url: URL): string {
return oidc.redirectUri || new URL("/api/oidc/callback", url.origin).toString();
}
export function makeOidcStateCookie(state: string, req: Request): string {
const securePart = shouldUseSecureAuthCookie(req) ? "Secure; " : "";
return `${OIDC_STATE_COOKIE}=${state}; HttpOnly; ${securePart}SameSite=Lax; Path=/api/oidc/callback; Max-Age=${Math.ceil(STATE_TTL_MS / 1000)}`;
}
export function makeOidcStateCookieClear(req: Request): string {
const securePart = shouldUseSecureAuthCookie(req) ? "Secure; " : "";
return `${OIDC_STATE_COOKIE}=; HttpOnly; ${securePart}SameSite=Lax; Path=/api/oidc/callback; Max-Age=0`;
}
function getOidcStateCookie(req: Request): string | null {
const cookieHeader = req.headers.get("Cookie");
if (!cookieHeader) return null;
const cookies = cookieHeader.split(/;\s*/);
for (const cookie of cookies) {
const [name, ...valueParts] = cookie.split("=");
if (name === OIDC_STATE_COOKIE) {
return valueParts.join("=") || null;
}
}
return null;
}
function normalizeIssuerUrl(value: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("OIDC issuer must be a valid HTTPS URL");
}
if (parsed.protocol !== "https:") {
throw new Error("OIDC issuer must use HTTPS");
}
if (parsed.username || parsed.password) {
throw new Error("OIDC issuer must not include credentials");
}
return parsed.toString().replace(/\/+$/, "");
}
function requireHttpsUrl(value: string, field: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`OIDC ${field} must be a valid HTTPS URL`);
}
if (parsed.protocol !== "https:") {
throw new Error(`OIDC ${field} must use HTTPS`);
}
if (parsed.username || parsed.password) {
throw new Error(`OIDC ${field} must not include credentials`);
}
return parsed.toString();
}
function normalizeReturnTo(value: string | null): string | null {
if (!value) return null;
try {
const url = new URL(value, "https://placeholder.local");
const relative = `${url.pathname}${url.search}${url.hash}`;
if (!relative.startsWith("/") || relative.startsWith("//")) return null;
if (relative.startsWith("/api/")) return null;
return relative;
} catch {
return null;
}
}
function randomBase64Url(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Buffer.from(buf).toString("base64url");
}
async function pkceChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return Buffer.from(digest).toString("base64url");
}
@@ -8,6 +8,7 @@ import {
revokeToken,
} from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { getConfig } from "../../config";
import { buildTotpUri, generateMfaSecret, verifyTotpCode } from "../../mfa";
import { createQrSvg } from "../../qr";
import {
@@ -50,6 +51,10 @@ export async function handleAuthRoutes(
url: URL,
server: RequestIpProvider,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/login/branding") {
return Response.json(getConfig().appearance.loginBranding);
}
if (req.method === "POST" && url.pathname === "/api/login") {
const ip = server.requestIP(req)?.address || "unknown";
@@ -1,7 +1,7 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig } from "../../config";
import {
getClientMetricsSummary,
getClientMetricsSummaryForUser,
@@ -39,6 +39,15 @@ type MiscRouteDeps = {
tlsSource?: "certbot" | "configured" | "self-signed";
};
function maskOidcSettings() {
const oidc = getConfig().oidc;
return {
...oidc,
clientSecret: "",
clientSecretSet: Boolean(oidc.clientSecret),
};
}
function clampProfileDuration(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 5000;
@@ -686,6 +695,63 @@ export async function handleMiscRoutes(
}
}
if (url.pathname === "/api/settings/oidc") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
try {
requirePermission(user, "system:oidc");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
return Response.json({ oidc: maskOidcSettings() }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateOidcConfig({
enabled: Boolean(body?.enabled),
label: typeof body?.label === "string" ? body.label : undefined,
issuer: typeof body?.issuer === "string" ? body.issuer : undefined,
clientId: typeof body?.clientId === "string" ? body.clientId : undefined,
clientSecret: typeof body?.clientSecret === "string" && body.clientSecret.length > 0 ? body.clientSecret : undefined,
redirectUri: typeof body?.redirectUri === "string" ? body.redirectUri : undefined,
scopes: body?.scopes,
clientAuthMethod: body?.clientAuthMethod,
autoProvision: Boolean(body?.autoProvision),
allowEmailLink: Boolean(body?.allowEmailLink),
defaultRole: body?.defaultRole,
allowedEmails: body?.allowedEmails,
allowedDomains: body?.allowedDomains,
groupClaim: typeof body?.groupClaim === "string" ? body.groupClaim : undefined,
adminGroups: body?.adminGroups,
operatorGroups: body?.operatorGroups,
viewerGroups: body?.viewerGroups,
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Updated OIDC settings (enabled=${updated.enabled})`,
success: true,
});
return Response.json({ ok: true, oidc: maskOidcSettings() }, { headers: deps.CORS_HEADERS });
}
}
if (req.method === "POST" && url.pathname === "/api/settings/tls/certbot/setup") {
const user = await authenticateRequest(req);
if (!user) {
@@ -1135,8 +1201,12 @@ export async function handleMiscRoutes(
}
if (req.method === "GET") {
const appearance = getConfig().appearance;
return Response.json(
{ customCSS: getConfig().appearance?.customCSS || "" },
{
customCSS: appearance?.customCSS || "",
loginBranding: appearance.loginBranding,
},
{ headers: deps.CORS_HEADERS },
);
}
@@ -1153,8 +1223,12 @@ export async function handleMiscRoutes(
if (customCSS.length > 51200) {
return Response.json({ error: "CSS exceeds 50 KB limit" }, { status: 400 });
}
const loginBranding =
body?.loginBranding && typeof body.loginBranding === "object"
? body.loginBranding
: undefined;
const updated = await updateAppearanceConfig(customCSS);
const updated = await updateAppearanceConfig(customCSS, loginBranding);
logAudit({
timestamp: Date.now(),
@@ -1165,7 +1239,10 @@ export async function handleMiscRoutes(
success: true,
});
return Response.json({ ok: true, customCSS: updated.customCSS }, { headers: deps.CORS_HEADERS });
return Response.json(
{ ok: true, customCSS: updated.customCSS, loginBranding: updated.loginBranding },
{ headers: deps.CORS_HEADERS },
);
}
}
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { getConfig, updateOidcConfig, updateSecurityConfig, type Config } from "../../config";
import { createUser } from "../../users";
import { createOidcLoginRedirect, resolveOidcUser } from "../oidc";
import { getOidcLocalMfaBlockReason, handleOidcRoutes } from "./oidc-routes";
const server = {
requestIP: () => ({ address: "127.0.0.1" }),
};
let originalOidc: Config["oidc"];
let originalSecurity: Config["security"];
const originalFetch = globalThis.fetch;
beforeAll(() => {
originalOidc = { ...getConfig().oidc, scopes: [...getConfig().oidc.scopes] };
originalSecurity = { ...getConfig().security };
});
afterAll(async () => {
globalThis.fetch = originalFetch;
await updateOidcConfig(originalOidc);
await updateSecurityConfig(originalSecurity);
});
describe("OIDC routes", () => {
test("status reports disabled when OIDC is not configured", async () => {
const req = new Request("https://localhost/api/oidc/status");
const url = new URL(req.url);
const res = await handleOidcRoutes(req, url, server);
expect(res?.status).toBe(200);
const body = await res!.json();
expect(body.enabled).toBe(false);
expect(body.loginUrl).toBeNull();
});
test("login redirect stores a browser-bound OIDC state cookie", async () => {
const issuer = `https://oidc-state-${Date.now()}.example.test`;
await configureOidc(issuer);
mockDiscovery(issuer);
const req = new Request("https://localhost/api/oidc/login?returnTo=/settings.html");
const res = await createOidcLoginRedirect(req, new URL(req.url));
expect(res.status).toBe(302);
expect(res.headers.get("Set-Cookie")).toContain("overlord_oidc_state=");
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
expect(res.headers.get("Set-Cookie")).toContain("SameSite=Lax");
const redirect = new URL(res.headers.get("Location")!);
expect(redirect.searchParams.get("state")).toBeTruthy();
expect(redirect.searchParams.get("code_challenge_method")).toBe("S256");
});
test("callback rejects state values that are not bound to the browser cookie", async () => {
const issuer = `https://oidc-csrf-${Date.now()}.example.test`;
await configureOidc(issuer);
mockDiscovery(issuer);
const loginReq = new Request("https://localhost/api/oidc/login");
const loginRes = await createOidcLoginRedirect(loginReq, new URL(loginReq.url));
const state = new URL(loginRes.headers.get("Location")!).searchParams.get("state")!;
const callbackReq = new Request(`https://localhost/api/oidc/callback?code=fake-code&state=${state}`);
const callbackRes = await handleOidcRoutes(callbackReq, new URL(callbackReq.url), server);
expect(callbackRes?.status).toBe(302);
const location = new URL(callbackRes!.headers.get("Location")!);
expect(location.pathname).toBe("/login.html");
expect(location.searchParams.get("oidc_error")).toContain("state cookie mismatch");
expect(callbackRes!.headers.get("Set-Cookie")).toContain("overlord_oidc_state=;");
});
test("login redirect rejects non-HTTPS issuers before discovery", async () => {
await configureOidc("http://oidc-insecure.example.test");
setMockFetch(async () => {
throw new Error("fetch should not be called for insecure issuer");
});
const req = new Request("https://localhost/api/oidc/login");
await expect(createOidcLoginRedirect(req, new URL(req.url))).rejects.toThrow("HTTPS");
});
test("email linking does not fall back to local usernames", async () => {
const username = `oidc_link_${Date.now().toString(36)}`;
const created = await createUser(username, "Aa1!VeryLongTestPassword_2026", "operator", "test");
expect(created.success).toBe(true);
const oidc: Config["oidc"] = {
...getConfig().oidc,
enabled: true,
issuer: "https://issuer.example.test",
clientId: "client",
allowEmailLink: true,
autoProvision: false,
allowedEmails: [],
allowedDomains: [],
};
await expect(resolveOidcUser(oidc, "https://issuer.example.test", {
sub: `subject-${Date.now()}`,
email: `${username}@example.test`,
email_verified: true,
preferred_username: "not-local",
} as any)).rejects.toThrow("No linked Overlord account exists");
});
test("OIDC session issuance is blocked when local MFA is enabled or required", async () => {
await updateSecurityConfig({
...getConfig().security,
mfaRequiredForAdmins: true,
mfaRequiredForNonAdmins: false,
});
expect(getOidcLocalMfaBlockReason({
role: "operator",
mfa_enabled: 1,
mfa_secret: "secret",
})).toContain("Local MFA is enabled");
expect(getOidcLocalMfaBlockReason({
role: "admin",
mfa_enabled: 0,
mfa_secret: null,
})).toContain("Local MFA is required");
expect(getOidcLocalMfaBlockReason({
role: "operator",
mfa_enabled: 0,
mfa_secret: null,
})).toBeNull();
});
});
async function configureOidc(issuer: string): Promise<void> {
await updateOidcConfig({
enabled: true,
issuer,
clientId: "overlord-test",
clientSecret: "secret",
redirectUri: "https://localhost/api/oidc/callback",
scopes: ["openid", "profile", "email"],
clientAuthMethod: "client_secret_post",
autoProvision: false,
allowEmailLink: false,
defaultRole: "viewer",
allowedEmails: [],
allowedDomains: [],
groupClaim: "groups",
adminGroups: [],
operatorGroups: [],
viewerGroups: [],
});
}
function mockDiscovery(issuer: string): void {
setMockFetch(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === `${issuer}/.well-known/openid-configuration`) {
return Response.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
});
}
throw new Error(`Unexpected OIDC fetch: ${url}`);
});
}
function setMockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>): void {
globalThis.fetch = Object.assign(handler, {
preconnect: originalFetch.preconnect,
}) as typeof fetch;
}
@@ -0,0 +1,98 @@
import { generateToken, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { isMfaRequiredForUser, type User } from "../../users";
import {
completeOidcLogin,
createOidcLoginRedirect,
getOidcPublicStatus,
makeOidcStateCookieClear,
} from "../oidc";
import { makeAuthCookie } from "./auth-cookie";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
};
export async function handleOidcRoutes(
req: Request,
url: URL,
server: RequestIpProvider,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/oidc/status") {
return Response.json(getOidcPublicStatus());
}
if (req.method === "GET" && url.pathname === "/api/oidc/login") {
try {
return await createOidcLoginRedirect(req, url);
} catch (error) {
logger.error("[oidc] login redirect failed", error);
return redirectToLoginError(url, "OIDC login is not available");
}
}
if (req.method === "GET" && url.pathname === "/api/oidc/callback") {
const ip = server.requestIP(req)?.address || "unknown";
try {
const { user, returnTo } = await completeOidcLogin(req, url);
const mfaBlockReason = getOidcLocalMfaBlockReason(user);
if (mfaBlockReason) throw new Error(mfaBlockReason);
const userAgent = req.headers.get("User-Agent") || null;
const token = await generateToken(user, { ip, userAgent: userAgent || undefined });
const headers = new Headers({
"Location": new URL(returnTo, url.origin).toString(),
});
headers.append("Set-Cookie", makeAuthCookie(token, getSessionTtlSeconds(), req));
headers.append("Set-Cookie", makeOidcStateCookieClear(req));
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.LOGIN,
success: true,
details: "OIDC login",
});
return new Response(null, {
status: 302,
headers,
});
} catch (error) {
logger.warn("[oidc] callback failed", error);
logAudit({
timestamp: Date.now(),
username: "unknown",
ip,
action: AuditAction.LOGIN_FAILED,
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
return redirectToLoginError(url, error instanceof Error ? error.message : "OIDC login failed", req);
}
}
return null;
}
export function getOidcLocalMfaBlockReason(
user: Pick<User, "role" | "mfa_enabled" | "mfa_secret">,
): string | null {
if (user.mfa_enabled && user.mfa_secret) {
return "Local MFA is enabled for this account. Use password login to complete MFA.";
}
if (isMfaRequiredForUser(user)) {
return "Local MFA is required for this account and cannot be completed through OIDC.";
}
return null;
}
function redirectToLoginError(url: URL, message: string, req?: Request): Response {
const loginUrl = new URL("/login.html", url.origin);
loginUrl.searchParams.set("oidc_error", message);
const headers = new Headers({ Location: loginUrl.toString() });
if (req) headers.append("Set-Cookie", makeOidcStateCookieClear(req));
return new Response(null, { status: 302, headers });
}
+57
View File
@@ -713,6 +713,63 @@ export async function createUser(
}
}
export async function createExternalUser(
username: string,
role: UserRole,
createdBy: string,
registeredVia: string,
): Promise<{ success: boolean; error?: string; userId?: number }> {
if (!username || username.length < 3 || username.length > 32) {
return {
success: false,
error: "Username must be between 3 and 32 characters",
};
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return {
success: false,
error: "Username can only contain letters, numbers, hyphens, and underscores",
};
}
const existing = getUserByUsername(username);
if (existing) {
return { success: false, error: "Username already exists" };
}
try {
const passwordHash = await Bun.password.hash(crypto.randomUUID() + crypto.randomUUID(), {
algorithm: "bcrypt",
cost: 10,
});
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, plugin_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
)
.run(
username,
passwordHash,
role,
Date.now(),
createdBy,
role === "admin" ? "all" : "none",
role === "admin" ? "all" : "none",
role === "admin" || role === "operator" ? 1 : 0,
role === "admin" ? 1 : 0,
registeredVia,
);
invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
logger.error("[users] Create external user error:", err);
return { success: false, error: err.message || "Failed to create external user" };
}
}
export async function updateUserPassword(
userId: number,
newPassword: string,