Files
overlord/Overlord-Server/src/server/oidc.ts
T
2026-07-08 20:34:25 -05:00

490 lines
16 KiB
TypeScript

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