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
@@ -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 });
}