import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { admin } from 'better-auth/plugins'; import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth'; import type { Db } from '@mosaic/db'; export interface AuthConfig { db: Db; baseURL?: string; secret?: string; } /** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */ export function buildOAuthProviders(): GenericOAuthConfig[] { const providers: GenericOAuthConfig[] = []; const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']); const authentikClientId = process.env['AUTHENTIK_CLIENT_ID']; const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET']; assertOptionalProviderConfig('Authentik SSO', { required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'], values: [authentikIssuer, authentikClientId, authentikClientSecret], }); if (authentikIssuer && authentikClientId && authentikClientSecret) { providers.push({ providerId: 'authentik', clientId: authentikClientId, clientSecret: authentikClientSecret, issuer: authentikIssuer, discoveryUrl: buildDiscoveryUrl(authentikIssuer), scopes: ['openid', 'email', 'profile'], requireIssuerValidation: true, }); } const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']); const workosClientId = process.env['WORKOS_CLIENT_ID']; const workosClientSecret = process.env['WORKOS_CLIENT_SECRET']; assertOptionalProviderConfig('WorkOS SSO', { required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'], values: [workosIssuer, workosClientId, workosClientSecret], }); if (workosIssuer && workosClientId && workosClientSecret) { providers.push({ providerId: 'workos', clientId: workosClientId, clientSecret: workosClientSecret, issuer: workosIssuer, discoveryUrl: buildDiscoveryUrl(workosIssuer), scopes: ['openid', 'email', 'profile'], requireIssuerValidation: true, }); } const keycloakIssuer = resolveKeycloakIssuer(); const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID']; const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET']; assertOptionalProviderConfig('Keycloak SSO', { required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'], values: [keycloakClientId, keycloakClientSecret, keycloakIssuer], }); if (keycloakIssuer && keycloakClientId && keycloakClientSecret) { providers.push( keycloak({ clientId: keycloakClientId, clientSecret: keycloakClientSecret, issuer: keycloakIssuer, scopes: ['openid', 'email', 'profile'], }), ); } return providers; } function resolveKeycloakIssuer(): string | undefined { const issuer = normalizeIssuer(process.env['KEYCLOAK_ISSUER']); if (issuer) { return issuer; } const baseUrl = normalizeIssuer(process.env['KEYCLOAK_URL']); const realm = process.env['KEYCLOAK_REALM']?.trim(); if (!baseUrl || !realm) { return undefined; } return `${baseUrl}/realms/${realm}`; } function buildDiscoveryUrl(issuer: string): string { return `${issuer}/.well-known/openid-configuration`; } function normalizeIssuer(value: string | undefined): string | undefined { return value?.trim().replace(/\/+$/, '') || undefined; } function assertOptionalProviderConfig( providerName: string, config: { required: string[]; values: Array }, ): void { const hasAnyValue = config.values.some((value) => Boolean(value?.trim())); const hasAllValues = config.values.every((value) => Boolean(value?.trim())); if (!hasAnyValue || hasAllValues) { return; } throw new Error( `@mosaic/auth: ${providerName} requires ${config.required.join(', ')}. Set them in your config or via the listed environment variables.`, ); } export function createAuth(config: AuthConfig) { const { db, baseURL, secret } = config; const oauthProviders = buildOAuthProviders(); const plugins = oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined; const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000'; const trustedOrigins = corsOrigin.split(',').map((o) => o.trim()); return betterAuth({ database: drizzleAdapter(db, { provider: 'pg', usePlural: true, }), baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000', secret: secret ?? process.env['BETTER_AUTH_SECRET'], basePath: '/api/auth', trustedOrigins, emailAndPassword: { enabled: true, }, user: { additionalFields: { role: { type: 'string', required: false, defaultValue: 'member', input: false, }, }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // refresh daily }, plugins: [...(plugins ?? []), admin({ defaultRole: 'member', adminRoles: ['admin'] })], }); } export type Auth = ReturnType;