import crypto from "node:crypto"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { getSettings } from "@/lib/localDb"; export const OIDC_COOKIE_NAMES = { state: "oidc_state", nonce: "oidc_nonce", verifier: "oidc_code_verifier", }; const DEFAULT_SCOPES = "openid profile email"; const DEFAULT_LOGIN_LABEL = "Sign in with OIDC"; function trimTrailingSlashes(value) { return (value || "").trim().replace(/\/+$/, ""); } function normalizeScopes(value) { return (value || DEFAULT_SCOPES).trim() || DEFAULT_SCOPES; } export function getPublicOrigin(request) { const configuredBaseUrl = process.env.BASE_URL || process.env.NEXT_PUBLIC_BASE_URL || ""; if (configuredBaseUrl) { return trimTrailingSlashes(configuredBaseUrl); } const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || ""; const forwardedHost = request?.headers?.get?.("x-forwarded-host") || ""; const host = forwardedHost || request?.headers?.get?.("host") || ""; if (host) { const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, ""); return `${protocol}://${host}`.replace(/\/+$/, ""); } return trimTrailingSlashes(new URL(request.url).origin); } export function isOidcConfigured(settings) { return !!( trimTrailingSlashes(settings?.oidcIssuerUrl) && (settings?.oidcClientId || "").trim() && (settings?.oidcClientSecret || "").trim() ); } export async function getOidcRuntimeConfig() { const settings = await getSettings(); if (!["oidc", "both"].includes(settings.authMode) || !isOidcConfigured(settings)) return null; const issuerUrl = trimTrailingSlashes(settings.oidcIssuerUrl); return { issuerUrl, clientId: settings.oidcClientId.trim(), clientSecret: settings.oidcClientSecret.trim(), scopes: normalizeScopes(settings.oidcScopes), loginLabel: (settings.oidcLoginLabel || DEFAULT_LOGIN_LABEL).trim() || DEFAULT_LOGIN_LABEL, }; } export async function fetchOidcDiscovery(issuerUrl) { const discoveryUrl = `${trimTrailingSlashes(issuerUrl)}/.well-known/openid-configuration`; const res = await fetch(discoveryUrl, { cache: "no-store" }); if (!res.ok) { throw new Error(`Failed to load OIDC discovery document from ${discoveryUrl}`); } return await res.json(); } export function createPkcePair() { const verifier = crypto.randomBytes(32).toString("base64url"); const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } export function createOidcState() { return crypto.randomBytes(16).toString("base64url"); } export function createOidcNonce() { return crypto.randomBytes(16).toString("base64url"); } export function buildOidcAuthorizationUrl({ authorizationEndpoint, clientId, redirectUri, scopes = DEFAULT_SCOPES, state, nonce, codeChallenge, }) { const url = new URL(authorizationEndpoint); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", clientId); url.searchParams.set("redirect_uri", redirectUri); url.searchParams.set("scope", normalizeScopes(scopes)); url.searchParams.set("state", state); url.searchParams.set("nonce", nonce); url.searchParams.set("code_challenge", codeChallenge); url.searchParams.set("code_challenge_method", "S256"); return url.toString(); } export async function exchangeOidcCode({ tokenEndpoint, clientId, clientSecret, code, redirectUri, codeVerifier, }) { const body = new URLSearchParams({ grant_type: "authorization_code", client_id: clientId, code, redirect_uri: redirectUri, code_verifier: codeVerifier, }); if (clientSecret) { body.set("client_secret", clientSecret); } const res = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const data = await res.json().catch(() => ({})); if (!res.ok) { const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`; throw new Error(message); } return data; } export async function probeOidcClientSecret({ tokenEndpoint, clientId, clientSecret, redirectUri, }) { if (!clientSecret) { return { tested: false, valid: null, message: "No client secret was provided, so secret validation was skipped.", }; } const body = new URLSearchParams({ grant_type: "authorization_code", client_id: clientId, client_secret: clientSecret, code: "__oidc_test_invalid_code__", redirect_uri: redirectUri, code_verifier: "__oidc_test_invalid_verifier__", }); const res = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const data = await res.json().catch(() => ({})); const error = (data?.error || "").toLowerCase(); const errorDescription = data?.error_description || data?.error || ""; if (res.ok) { return { tested: true, valid: true, message: "Client secret was accepted by the token endpoint.", raw: data, }; } if (error === "invalid_client" || error === "unauthorized_client" || /client.*(invalid|failed|mismatch)/i.test(errorDescription)) { return { tested: true, valid: false, message: errorDescription || "Client secret is not valid.", raw: data, }; } if (error === "invalid_grant" || error === "invalid_code" || /grant|code/i.test(errorDescription)) { return { tested: true, valid: true, message: "Client secret was accepted; the token exchange failed only because the test authorization code is invalid.", raw: data, }; } return { tested: true, valid: null, message: errorDescription || `Token endpoint responded with ${res.status}`, raw: data, }; } export async function verifyOidcIdToken({ idToken, issuer, audience, jwksUri, nonce, }) { const jwks = createRemoteJWKSet(new URL(jwksUri)); const { payload } = await jwtVerify(idToken, jwks, { issuer, audience, nonce, }); return payload; } export function pickOidcDisplayName(payload = {}) { return payload.preferred_username || payload.email || payload.name || payload.given_name || payload.sub || "OIDC user"; } export function pickOidcEmail(payload = {}) { return payload.email || ""; }