OIDC + SSO
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user