import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { genericOAuth } from "better-auth/plugins"; import type { PrismaClient } from "@prisma/client"; /** * Required OIDC environment variables when OIDC is enabled */ const REQUIRED_OIDC_ENV_VARS = [ "OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", ] as const; /** * Resolve BetterAuth base URL from explicit auth URL or API URL. * BetterAuth uses this to generate absolute callback/error URLs. */ export function getBetterAuthBaseUrl(): string | undefined { const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL; if (!configured || configured.trim() === "") { if (process.env.NODE_ENV === "production") { throw new Error( "Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL." ); } return undefined; } let parsed: URL; try { parsed = new URL(configured); } catch (urlError: unknown) { const detail = urlError instanceof Error ? urlError.message : String(urlError); throw new Error( `BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.` ); } if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") { throw new Error( `BetterAuth base URL must use https in production. Current value: "${configured}".` ); } return parsed.origin; } /** * Check if OIDC authentication is enabled via environment variable */ export function isOidcEnabled(): boolean { const enabled = process.env.OIDC_ENABLED; return enabled === "true" || enabled === "1"; } /** * Validates OIDC configuration at startup. * Throws an error if OIDC is enabled but required environment variables are missing. * * @throws Error if OIDC is enabled but required vars are missing or empty */ export function validateOidcConfig(): void { if (!isOidcEnabled()) { // OIDC is disabled, no validation needed return; } const missingVars: string[] = []; for (const envVar of REQUIRED_OIDC_ENV_VARS) { const value = process.env[envVar]; if (!value || value.trim() === "") { missingVars.push(envVar); } } if (missingVars.length > 0) { throw new Error( `OIDC authentication is enabled (OIDC_ENABLED=true) but required environment variables are missing or empty: ${missingVars.join(", ")}. ` + `Either set these variables or disable OIDC by setting OIDC_ENABLED=false.` ); } // Additional validation: OIDC_ISSUER should end with a trailing slash for proper discovery URL const issuer = process.env.OIDC_ISSUER; if (issuer && !issuer.endsWith("/")) { throw new Error( `OIDC_ISSUER must end with a trailing slash (/). Current value: "${issuer}". ` + `The discovery URL is constructed by appending ".well-known/openid-configuration" to the issuer.` ); } // Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path validateRedirectUri(); } /** * Validates the OIDC_REDIRECT_URI environment variable. * - Must be a parseable URL * - Path must start with /auth/oauth2/callback * - Warns (but does not throw) if using localhost in production * * @throws Error if URL is invalid or path does not start with /auth/oauth2/callback */ function validateRedirectUri(): void { const redirectUri = process.env.OIDC_REDIRECT_URI; if (!redirectUri || redirectUri.trim() === "") { // Already caught by REQUIRED_OIDC_ENV_VARS check above return; } let parsed: URL; try { parsed = new URL(redirectUri); } catch (urlError: unknown) { const detail = urlError instanceof Error ? urlError.message : String(urlError); throw new Error( `OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` + `Parse error: ${detail}. ` + `Example: "https://api.example.com/auth/oauth2/callback/authentik".` ); } if (!parsed.pathname.startsWith("/auth/oauth2/callback")) { throw new Error( `OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` + `Example: "https://api.example.com/auth/oauth2/callback/authentik".` ); } if ( process.env.NODE_ENV === "production" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") ) { console.warn( `[AUTH WARNING] OIDC_REDIRECT_URI uses localhost ("${redirectUri}") in production. ` + `This is likely a misconfiguration. Use a public domain for production deployments.` ); } } /** * Get OIDC plugins configuration. * Returns empty array if OIDC is disabled, otherwise returns configured OAuth plugin. */ function getOidcPlugins(): ReturnType[] { if (!isOidcEnabled()) { return []; } const clientId = process.env.OIDC_CLIENT_ID; const clientSecret = process.env.OIDC_CLIENT_SECRET; const issuer = process.env.OIDC_ISSUER; const redirectUri = process.env.OIDC_REDIRECT_URI; if (!clientId) { throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set."); } if (!clientSecret) { throw new Error("OIDC_CLIENT_SECRET is required when OIDC is enabled but was not set."); } if (!issuer) { throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set."); } if (!redirectUri) { throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set."); } return [ genericOAuth({ config: [ { providerId: "authentik", clientId, clientSecret, discoveryUrl: `${issuer}.well-known/openid-configuration`, redirectURI: redirectUri, pkce: true, scopes: ["openid", "profile", "email"], }, ], }), ]; } /** * Build the list of trusted origins from environment variables. * * Sources (in order): * - NEXT_PUBLIC_APP_URL — primary frontend URL * - NEXT_PUBLIC_API_URL — API's own origin * - TRUSTED_ORIGINS — comma-separated additional origins * - localhost fallbacks — only when NODE_ENV !== "production" * * The returned list is deduplicated and empty strings are filtered out. */ export function getTrustedOrigins(): string[] { const origins: string[] = []; // Environment-driven origins if (process.env.NEXT_PUBLIC_APP_URL) { origins.push(process.env.NEXT_PUBLIC_APP_URL); } if (process.env.NEXT_PUBLIC_API_URL) { origins.push(process.env.NEXT_PUBLIC_API_URL); } // Comma-separated additional origins (validated) if (process.env.TRUSTED_ORIGINS) { const rawOrigins = process.env.TRUSTED_ORIGINS.split(",") .map((o) => o.trim()) .filter((o) => o !== ""); for (const origin of rawOrigins) { try { const parsed = new URL(origin); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { console.warn(`[AUTH] Ignoring non-HTTP origin in TRUSTED_ORIGINS: "${origin}"`); continue; } origins.push(origin); } catch (urlError: unknown) { const detail = urlError instanceof Error ? urlError.message : String(urlError); console.warn(`[AUTH] Ignoring invalid URL in TRUSTED_ORIGINS: "${origin}" (${detail})`); } } } // Localhost fallbacks for development only if (process.env.NODE_ENV !== "production") { origins.push("http://localhost:3000", "http://localhost:3001"); } // Deduplicate and filter empty strings return [...new Set(origins)].filter((o) => o !== ""); } export function createAuth(prisma: PrismaClient) { // Validate OIDC configuration at startup - fail fast if misconfigured validateOidcConfig(); const baseURL = getBetterAuthBaseUrl(); return betterAuth({ baseURL, basePath: "/auth", database: prismaAdapter(prisma, { provider: "postgresql", }), emailAndPassword: { enabled: true, }, plugins: [...getOidcPlugins()], logger: { disabled: false, level: "error", }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request }, advanced: { database: { // BetterAuth's default ID generator emits opaque strings; our auth tables use UUID PKs. generateId: "uuid", }, defaultCookieAttributes: { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" as const, ...(process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}), }, }, trustedOrigins: getTrustedOrigins(), }); } export type Auth = ReturnType;