diff --git a/.env.example b/.env.example index 7132b24..0c31214 100644 --- a/.env.example +++ b/.env.example @@ -131,11 +131,13 @@ OTEL_SERVICE_NAME=mosaic-gateway # AUTHENTIK_CLIENT_SECRET= # --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) --- +# WORKOS_ISSUER=https://your-company.authkit.app # WORKOS_CLIENT_ID=client_... # WORKOS_CLIENT_SECRET=sk_live_... -# WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback/workos # --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) --- +# KEYCLOAK_ISSUER=https://auth.example.com/realms/master +# Legacy alternative if you prefer to compose the issuer from separate vars: # KEYCLOAK_URL=https://auth.example.com # KEYCLOAK_REALM=master # KEYCLOAK_CLIENT_ID=mosaic diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 836e062..9dfd8d4 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,18 +1,17 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { signIn } from '@/lib/auth-client'; - -const workosEnabled = process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true'; -const keycloakEnabled = process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true'; -const hasSsoProviders = workosEnabled || keycloakEnabled; +import { getEnabledSsoProviders } from '@/lib/sso-providers'; export default function LoginPage(): React.ReactElement { const router = useRouter(); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const ssoProviders = getEnabledSsoProviders(); + const hasSsoProviders = ssoProviders.length > 0; async function handleSubmit(e: React.FormEvent): Promise { e.preventDefault(); @@ -34,16 +33,6 @@ export default function LoginPage(): React.ReactElement { router.push('/chat'); } - async function handleSsoSignIn(providerId: string): Promise { - setError(null); - setLoading(true); - const result = await signIn.oauth2({ providerId, callbackURL: '/chat' }); - if (result?.error) { - setError(result.error.message ?? 'SSO sign in failed'); - setLoading(false); - } - } - return (

Sign in

@@ -60,26 +49,15 @@ export default function LoginPage(): React.ReactElement { {hasSsoProviders && (
- {workosEnabled && ( - - )} - {keycloakEnabled && ( - - )} + {provider.buttonLabel} + + ))}
or diff --git a/apps/web/src/app/auth/provider/[provider]/page.tsx b/apps/web/src/app/auth/provider/[provider]/page.tsx new file mode 100644 index 0000000..b4eebce --- /dev/null +++ b/apps/web/src/app/auth/provider/[provider]/page.tsx @@ -0,0 +1,77 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { signIn } from '@/lib/auth-client'; +import { getSsoProvider } from '@/lib/sso-providers'; + +export default function AuthProviderRedirectPage(): React.ReactElement { + const params = useParams<{ provider: string }>(); + const searchParams = useSearchParams(); + const providerId = typeof params.provider === 'string' ? params.provider : ''; + const provider = getSsoProvider(providerId); + const callbackURL = searchParams.get('callbackURL') ?? '/chat'; + const [error, setError] = useState(null); + + useEffect(() => { + const currentProvider = provider; + + if (!currentProvider) { + setError('Unknown SSO provider.'); + return; + } + + if (!currentProvider.enabled) { + setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`); + return; + } + + const activeProvider = currentProvider; + let cancelled = false; + + async function redirectToProvider(): Promise { + const result = await signIn.oauth2({ + providerId: activeProvider.id, + callbackURL, + }); + + if (!cancelled && result?.error) { + setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`); + } + } + + void redirectToProvider(); + + return () => { + cancelled = true; + }; + }, [callbackURL, provider]); + + return ( +
+

Single sign-on

+

+ {provider + ? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...` + : 'Preparing your sign-in request...'} +

+ + {error ? ( +
+

{error}

+ + Return to login + +
+ ) : ( +
+ If the redirect does not start automatically, return to the login page and try again. +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/sso-providers.test.ts b/apps/web/src/lib/sso-providers.test.ts new file mode 100644 index 0000000..076d32b --- /dev/null +++ b/apps/web/src/lib/sso-providers.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getEnabledSsoProviders, getSsoProvider } from './sso-providers'; + +describe('sso-providers', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns the enabled providers in login button order', () => { + vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true'); + vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true'); + + expect(getEnabledSsoProviders()).toEqual([ + { + id: 'workos', + buttonLabel: 'Continue with WorkOS', + description: 'Enterprise SSO via WorkOS', + enabled: true, + href: '/auth/provider/workos', + }, + { + id: 'keycloak', + buttonLabel: 'Continue with Keycloak', + description: 'Enterprise SSO via Keycloak', + enabled: true, + href: '/auth/provider/keycloak', + }, + ]); + }); + + it('marks disabled providers without exposing them in the enabled list', () => { + vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true'); + vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false'); + + expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']); + expect(getSsoProvider('keycloak')).toEqual({ + id: 'keycloak', + buttonLabel: 'Continue with Keycloak', + description: 'Enterprise SSO via Keycloak', + enabled: false, + href: '/auth/provider/keycloak', + }); + }); + + it('returns null for unknown providers', () => { + expect(getSsoProvider('authentik')).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/sso-providers.ts b/apps/web/src/lib/sso-providers.ts new file mode 100644 index 0000000..a9f4202 --- /dev/null +++ b/apps/web/src/lib/sso-providers.ts @@ -0,0 +1,53 @@ +export type SsoProviderId = 'workos' | 'keycloak'; + +export interface SsoProvider { + id: SsoProviderId; + buttonLabel: string; + description: string; + enabled: boolean; + href: string; +} + +const PROVIDER_METADATA: Record> = { + workos: { + id: 'workos', + buttonLabel: 'Continue with WorkOS', + description: 'Enterprise SSO via WorkOS', + }, + keycloak: { + id: 'keycloak', + buttonLabel: 'Continue with Keycloak', + description: 'Enterprise SSO via Keycloak', + }, +}; + +export function getEnabledSsoProviders(): SsoProvider[] { + return (Object.keys(PROVIDER_METADATA) as SsoProviderId[]) + .map((providerId) => getSsoProvider(providerId)) + .filter((provider): provider is SsoProvider => provider?.enabled === true); +} + +export function getSsoProvider(providerId: string): SsoProvider | null { + if (!isSsoProviderId(providerId)) { + return null; + } + + return { + ...PROVIDER_METADATA[providerId], + enabled: isSsoProviderEnabled(providerId), + href: `/auth/provider/${providerId}`, + }; +} + +function isSsoProviderId(value: string): value is SsoProviderId { + return value === 'workos' || value === 'keycloak'; +} + +function isSsoProviderEnabled(providerId: SsoProviderId): boolean { + switch (providerId) { + case 'workos': + return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true'; + case 'keycloak': + return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true'; + } +} diff --git a/docs/SSO-PROVIDERS.md b/docs/SSO-PROVIDERS.md new file mode 100644 index 0000000..14c8cc5 --- /dev/null +++ b/docs/SSO-PROVIDERS.md @@ -0,0 +1,111 @@ +# SSO Providers + +Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at: + +```text +{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId} +``` + +For the providers in this document: + +- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik` +- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos` +- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak` + +## Required environment variables + +### Authentik + +```bash +AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic +AUTHENTIK_CLIENT_ID=... +AUTHENTIK_CLIENT_SECRET=... +``` + +### WorkOS + +```bash +WORKOS_ISSUER=https://your-company.authkit.app +WORKOS_CLIENT_ID=client_... +WORKOS_CLIENT_SECRET=... +NEXT_PUBLIC_WORKOS_ENABLED=true +``` + +`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer. + +### Keycloak + +```bash +KEYCLOAK_ISSUER=https://auth.example.com/realms/master +KEYCLOAK_CLIENT_ID=mosaic +KEYCLOAK_CLIENT_SECRET=... +NEXT_PUBLIC_KEYCLOAK_ENABLED=true +``` + +If you prefer, you can keep the issuer split as: + +```bash +KEYCLOAK_URL=https://auth.example.com +KEYCLOAK_REALM=master +``` + +The auth package will derive `KEYCLOAK_ISSUER` from those two values. + +## WorkOS setup + +1. In WorkOS, create or select the application that will back Mosaic login. +2. Configure an AuthKit domain or custom authentication domain for the application. +3. Add the redirect URI: + +```text +{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos +``` + +4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`. +5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2. +6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use. +7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered. + +## Keycloak setup + +1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic. +2. Create a confidential OIDC client named `mosaic` or your preferred client ID. +3. Set the valid redirect URI to: + +```text +{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak +``` + +4. Set the web origin to the public Mosaic web URL. +5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`. +6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`. +7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered. + +### Local Keycloak smoke test + +If you want to test locally with Docker: + +```bash +docker run --rm --name mosaic-keycloak \ + -p 8080:8080 \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:26.1 start-dev +``` + +Then configure: + +```bash +KEYCLOAK_ISSUER=http://localhost:8080/realms/master +KEYCLOAK_CLIENT_ID=mosaic +KEYCLOAK_CLIENT_SECRET=... +NEXT_PUBLIC_KEYCLOAK_ENABLED=true +``` + +## Web flow + +The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider. + +## Failure mode + +Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaic/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider. diff --git a/packages/auth/src/auth.test.ts b/packages/auth/src/auth.test.ts index 31b76a8..f752e91 100644 --- a/packages/auth/src/auth.test.ts +++ b/packages/auth/src/auth.test.ts @@ -6,14 +6,15 @@ describe('buildOAuthProviders', () => { beforeEach(() => { process.env = { ...originalEnv }; - // Clear all SSO-related env vars before each test delete process.env['AUTHENTIK_CLIENT_ID']; delete process.env['AUTHENTIK_CLIENT_SECRET']; delete process.env['AUTHENTIK_ISSUER']; delete process.env['WORKOS_CLIENT_ID']; delete process.env['WORKOS_CLIENT_SECRET']; + delete process.env['WORKOS_ISSUER']; delete process.env['KEYCLOAK_CLIENT_ID']; delete process.env['KEYCLOAK_CLIENT_SECRET']; + delete process.env['KEYCLOAK_ISSUER']; delete process.env['KEYCLOAK_URL']; delete process.env['KEYCLOAK_REALM']; }); @@ -28,22 +29,32 @@ describe('buildOAuthProviders', () => { }); describe('WorkOS', () => { - it('includes workos provider when WORKOS_CLIENT_ID is set', () => { + it('includes workos provider when all required env vars are set', () => { process.env['WORKOS_CLIENT_ID'] = 'client_test123'; process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test'; + process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/'; const providers = buildOAuthProviders(); const workos = providers.find((p) => p.providerId === 'workos'); expect(workos).toBeDefined(); expect(workos?.clientId).toBe('client_test123'); - expect(workos?.authorizationUrl).toBe('https://api.workos.com/sso/authorize'); - expect(workos?.tokenUrl).toBe('https://api.workos.com/sso/token'); - expect(workos?.userInfoUrl).toBe('https://api.workos.com/sso/profile'); + expect(workos?.issuer).toBe('https://example.authkit.app'); + expect(workos?.discoveryUrl).toBe( + 'https://example.authkit.app/.well-known/openid-configuration', + ); expect(workos?.scopes).toEqual(['openid', 'email', 'profile']); }); - it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => { + it('throws when WorkOS is partially configured', () => { + process.env['WORKOS_CLIENT_ID'] = 'client_test123'; + + expect(() => buildOAuthProviders()).toThrow( + '@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.', + ); + }); + + it('excludes workos provider when WorkOS is not configured', () => { const providers = buildOAuthProviders(); const workos = providers.find((p) => p.providerId === 'workos'); expect(workos).toBeUndefined(); @@ -51,47 +62,78 @@ describe('buildOAuthProviders', () => { }); describe('Keycloak', () => { - it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => { + it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => { process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic'; process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123'; - process.env['KEYCLOAK_URL'] = 'https://auth.example.com'; + process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/'; + + const providers = buildOAuthProviders(); + const keycloakProvider = providers.find((p) => p.providerId === 'keycloak'); + + expect(keycloakProvider).toBeDefined(); + expect(keycloakProvider?.clientId).toBe('mosaic'); + expect(keycloakProvider?.discoveryUrl).toBe( + 'https://auth.example.com/realms/myrealm/.well-known/openid-configuration', + ); + expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']); + }); + + it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => { + process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic'; + process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123'; + process.env['KEYCLOAK_URL'] = 'https://auth.example.com/'; process.env['KEYCLOAK_REALM'] = 'myrealm'; const providers = buildOAuthProviders(); - const keycloak = providers.find((p) => p.providerId === 'keycloak'); + const keycloakProvider = providers.find((p) => p.providerId === 'keycloak'); - expect(keycloak).toBeDefined(); - expect(keycloak?.clientId).toBe('mosaic'); - expect(keycloak?.discoveryUrl).toBe( + expect(keycloakProvider?.discoveryUrl).toBe( 'https://auth.example.com/realms/myrealm/.well-known/openid-configuration', ); - expect(keycloak?.scopes).toEqual(['openid', 'email', 'profile']); }); - it('excludes keycloak provider when KEYCLOAK_CLIENT_ID is not set', () => { + it('throws when Keycloak is partially configured', () => { + process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic'; + process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123'; + + expect(() => buildOAuthProviders()).toThrow( + '@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.', + ); + }); + + it('excludes keycloak provider when Keycloak is not configured', () => { const providers = buildOAuthProviders(); - const keycloak = providers.find((p) => p.providerId === 'keycloak'); - expect(keycloak).toBeUndefined(); + const keycloakProvider = providers.find((p) => p.providerId === 'keycloak'); + expect(keycloakProvider).toBeUndefined(); }); }); describe('Authentik', () => { - it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => { + it('includes authentik provider when all required env vars are set', () => { process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client'; process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret'; - process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic'; + process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/'; const providers = buildOAuthProviders(); const authentik = providers.find((p) => p.providerId === 'authentik'); expect(authentik).toBeDefined(); expect(authentik?.clientId).toBe('authentik-client'); + expect(authentik?.issuer).toBe('https://auth.example.com/application/o/mosaic'); expect(authentik?.discoveryUrl).toBe( 'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration', ); }); - it('excludes authentik provider when AUTHENTIK_CLIENT_ID is not set', () => { + it('throws when Authentik is partially configured', () => { + process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client'; + + expect(() => buildOAuthProviders()).toThrow( + '@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.', + ); + }); + + it('excludes authentik provider when Authentik is not configured', () => { const providers = buildOAuthProviders(); const authentik = providers.find((p) => p.providerId === 'authentik'); expect(authentik).toBeUndefined(); @@ -100,10 +142,14 @@ describe('buildOAuthProviders', () => { it('registers all three providers when all env vars are set', () => { process.env['AUTHENTIK_CLIENT_ID'] = 'a-id'; + process.env['AUTHENTIK_CLIENT_SECRET'] = 'a-secret'; + process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic'; process.env['WORKOS_CLIENT_ID'] = 'w-id'; + process.env['WORKOS_CLIENT_SECRET'] = 'w-secret'; + process.env['WORKOS_ISSUER'] = 'https://example.authkit.app'; process.env['KEYCLOAK_CLIENT_ID'] = 'k-id'; - process.env['KEYCLOAK_URL'] = 'https://kc.example.com'; - process.env['KEYCLOAK_REALM'] = 'test'; + process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret'; + process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test'; const providers = buildOAuthProviders(); expect(providers).toHaveLength(3); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 65ae69d..f425564 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,7 +1,7 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { admin, genericOAuth } from 'better-auth/plugins'; -import type { GenericOAuthConfig } from 'better-auth/plugins'; +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 { @@ -14,52 +14,108 @@ export interface AuthConfig { export function buildOAuthProviders(): GenericOAuthConfig[] { const providers: GenericOAuthConfig[] = []; + const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']); const authentikClientId = process.env['AUTHENTIK_CLIENT_ID']; - if (authentikClientId) { - const authentikIssuer = process.env['AUTHENTIK_ISSUER']; + 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: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '', - discoveryUrl: authentikIssuer - ? `${authentikIssuer}/.well-known/openid-configuration` - : undefined, - authorizationUrl: authentikIssuer ? `${authentikIssuer}/application/o/authorize/` : undefined, - tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined, - userInfoUrl: authentikIssuer ? `${authentikIssuer}/application/o/userinfo/` : undefined, + 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']; - if (workosClientId) { + 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: process.env['WORKOS_CLIENT_SECRET'] ?? '', - authorizationUrl: 'https://api.workos.com/sso/authorize', - tokenUrl: 'https://api.workos.com/sso/token', - userInfoUrl: 'https://api.workos.com/sso/profile', + clientSecret: workosClientSecret, + issuer: workosIssuer, + discoveryUrl: buildDiscoveryUrl(workosIssuer), scopes: ['openid', 'email', 'profile'], + requireIssuerValidation: true, }); } + const keycloakIssuer = resolveKeycloakIssuer(); const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID']; - if (keycloakClientId) { - const keycloakUrl = process.env['KEYCLOAK_URL'] ?? ''; - const keycloakRealm = process.env['KEYCLOAK_REALM'] ?? ''; - providers.push({ - providerId: 'keycloak', - clientId: keycloakClientId, - clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '', - discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`, - scopes: ['openid', 'email', 'profile'], - }); + 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;