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