Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
282 lines
8.6 KiB
TypeScript
282 lines
8.6 KiB
TypeScript
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<typeof genericOAuth>[] {
|
|
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<typeof createAuth>;
|