490 lines
16 KiB
TypeScript
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");
|
|
}
|