From 368b20e4eabb4f849f8f44f40899e4d43f84f03d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 19 Mar 2026 21:03:46 -0500 Subject: [PATCH] feat(auth): add WorkOS and Keycloak SSO discovery --- apps/gateway/src/auth/auth.module.ts | 2 + apps/gateway/src/auth/sso.controller.spec.ts | 40 ++++ apps/gateway/src/auth/sso.controller.ts | 10 + apps/gateway/src/main.ts | 10 +- apps/web/src/app/(auth)/login/page.tsx | 46 +++- .../web/src/app/(dashboard)/settings/page.tsx | 78 ++++--- .../auth/sso-provider-buttons.spec.tsx | 45 ++++ .../components/auth/sso-provider-buttons.tsx | 55 +++++ .../settings/sso-provider-section.spec.tsx | 46 ++++ .../settings/sso-provider-section.tsx | 67 ++++++ apps/web/src/lib/auth-client.ts | 4 +- apps/web/src/lib/sso.ts | 20 ++ docs/guides/admin-guide.md | 23 +- packages/auth/src/auth.ts | 37 +-- packages/auth/src/index.ts | 11 + packages/auth/src/sso.spec.ts | 62 +++++ packages/auth/src/sso.ts | 212 ++++++++++++++++++ 17 files changed, 694 insertions(+), 74 deletions(-) create mode 100644 apps/gateway/src/auth/sso.controller.spec.ts create mode 100644 apps/gateway/src/auth/sso.controller.ts create mode 100644 apps/web/src/components/auth/sso-provider-buttons.spec.tsx create mode 100644 apps/web/src/components/auth/sso-provider-buttons.tsx create mode 100644 apps/web/src/components/settings/sso-provider-section.spec.tsx create mode 100644 apps/web/src/components/settings/sso-provider-section.tsx create mode 100644 apps/web/src/lib/sso.ts create mode 100644 packages/auth/src/sso.spec.ts create mode 100644 packages/auth/src/sso.ts diff --git a/apps/gateway/src/auth/auth.module.ts b/apps/gateway/src/auth/auth.module.ts index b2ed4de..09e7cd8 100644 --- a/apps/gateway/src/auth/auth.module.ts +++ b/apps/gateway/src/auth/auth.module.ts @@ -3,9 +3,11 @@ import { createAuth, type Auth } from '@mosaic/auth'; import type { Db } from '@mosaic/db'; import { DB } from '../database/database.module.js'; import { AUTH } from './auth.tokens.js'; +import { SsoController } from './sso.controller.js'; @Global() @Module({ + controllers: [SsoController], providers: [ { provide: AUTH, diff --git a/apps/gateway/src/auth/sso.controller.spec.ts b/apps/gateway/src/auth/sso.controller.spec.ts new file mode 100644 index 0000000..a0f7109 --- /dev/null +++ b/apps/gateway/src/auth/sso.controller.spec.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SsoController } from './sso.controller.js'; + +describe('SsoController', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('lists configured OIDC providers', () => { + vi.stubEnv('WORKOS_CLIENT_ID', 'workos-client'); + vi.stubEnv('WORKOS_CLIENT_SECRET', 'workos-secret'); + vi.stubEnv('WORKOS_ISSUER', 'https://auth.workos.com/sso/client_123'); + + const controller = new SsoController(); + const providers = controller.list(); + + expect(providers.find((provider) => provider.id === 'workos')).toMatchObject({ + configured: true, + loginMode: 'oidc', + callbackPath: '/api/auth/oauth2/callback/workos', + teamSync: { enabled: true, claim: 'organization_id' }, + }); + }); + + it('prefers SAML fallback for Keycloak when only the SAML login URL is configured', () => { + vi.stubEnv('KEYCLOAK_SAML_LOGIN_URL', 'https://sso.example.com/realms/mosaic/protocol/saml'); + + const controller = new SsoController(); + const providers = controller.list(); + + expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({ + configured: true, + loginMode: 'saml', + samlFallback: { + configured: true, + loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml', + }, + }); + }); +}); diff --git a/apps/gateway/src/auth/sso.controller.ts b/apps/gateway/src/auth/sso.controller.ts new file mode 100644 index 0000000..6f9ef58 --- /dev/null +++ b/apps/gateway/src/auth/sso.controller.ts @@ -0,0 +1,10 @@ +import { Controller, Get } from '@nestjs/common'; +import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaic/auth'; + +@Controller('api/sso/providers') +export class SsoController { + @Get() + list(): SsoProviderDiscovery[] { + return buildSsoDiscovery(); + } +} diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index b5b4932..d41e3da 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -11,6 +11,7 @@ import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe } from '@nestjs/common'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import helmet from '@fastify/helmet'; +import { listSsoStartupWarnings } from '@mosaic/auth'; import { AppModule } from './app.module.js'; import { mountAuthHandler } from './auth/auth.controller.js'; import { mountMcpHandler } from './mcp/mcp.controller.js'; @@ -23,13 +24,8 @@ async function bootstrap(): Promise { throw new Error('BETTER_AUTH_SECRET is required'); } - if ( - process.env['AUTHENTIK_CLIENT_ID'] && - (!process.env['AUTHENTIK_CLIENT_SECRET'] || !process.env['AUTHENTIK_ISSUER']) - ) { - console.warn( - '[warn] AUTHENTIK_CLIENT_ID is set but AUTHENTIK_CLIENT_SECRET or AUTHENTIK_ISSUER is missing — Authentik SSO will not work', - ); + for (const warning of listSsoStartupWarnings()) { + logger.warn(warning); } const app = await NestFactory.create( diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index b01d04f..a1a1c32 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,14 +1,27 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { signIn } from '@/lib/auth-client'; +import { api } from '@/lib/api'; +import { authClient, signIn } from '@/lib/auth-client'; +import type { SsoProviderDiscovery } from '@/lib/sso'; +import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons'; export default function LoginPage(): React.ReactElement { const router = useRouter(); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [ssoProviders, setSsoProviders] = useState([]); + const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState< + SsoProviderDiscovery['id'] | null + >(null); + + useEffect(() => { + api('/api/sso/providers') + .catch(() => [] as SsoProviderDiscovery[]) + .then((providers) => setSsoProviders(providers.filter((provider) => provider.configured))); + }, []); async function handleSubmit(e: React.FormEvent): Promise { e.preventDefault(); @@ -30,6 +43,27 @@ export default function LoginPage(): React.ReactElement { router.push('/chat'); } + async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise { + setError(null); + setSsoLoadingProviderId(providerId); + + try { + const result = await authClient.signIn.oauth2({ + providerId, + callbackURL: '/chat', + newUserCallbackURL: '/chat', + }); + + if (result.error) { + setError(result.error.message ?? `Sign in with ${providerId} failed`); + setSsoLoadingProviderId(null); + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`); + setSsoLoadingProviderId(null); + } + } + return (

Sign in

@@ -86,6 +120,14 @@ export default function LoginPage(): React.ReactElement { + { + void handleSsoSignIn(providerId); + }} + /> +

Don't have an account?{' '} diff --git a/apps/web/src/app/(dashboard)/settings/page.tsx b/apps/web/src/app/(dashboard)/settings/page.tsx index a6342f6..da13427 100644 --- a/apps/web/src/app/(dashboard)/settings/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/page.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@/lib/api'; import { authClient, useSession } from '@/lib/auth-client'; +import type { SsoProviderDiscovery } from '@/lib/sso'; +import { SsoProviderSection } from '@/components/settings/sso-provider-section'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -424,7 +426,9 @@ function NotificationsTab(): React.ReactElement { function ProvidersTab(): React.ReactElement { const [providers, setProviders] = useState([]); + const [ssoProviders, setSsoProviders] = useState([]); const [loading, setLoading] = useState(true); + const [ssoLoading, setSsoLoading] = useState(true); const [testStatuses, setTestStatuses] = useState>({}); useEffect(() => { @@ -434,6 +438,13 @@ function ProvidersTab(): React.ReactElement { .finally(() => setLoading(false)); }, []); + useEffect(() => { + api('/api/sso/providers') + .catch(() => [] as SsoProviderDiscovery[]) + .then((providers) => setSsoProviders(providers)) + .finally(() => setSsoLoading(false)); + }, []); + const testConnection = useCallback(async (providerId: string): Promise => { setTestStatuses((prev) => ({ ...prev, @@ -464,35 +475,44 @@ function ProvidersTab(): React.ReactElement { .find((m) => providers.find((p) => p.id === m.provider)?.available); return ( -

-

LLM Providers

- {loading ? ( -

Loading providers...

- ) : providers.length === 0 ? ( -
-

- No providers configured. Set{' '} - OLLAMA_BASE_URL{' '} - or{' '} - - MOSAIC_CUSTOM_PROVIDERS - {' '} - to add providers. -

-
- ) : ( -
- {providers.map((provider) => ( - void testConnection(provider.id)} - /> - ))} -
- )} +
+
+

SSO Providers

+ +
+ +
+

LLM Providers

+ {loading ? ( +

Loading providers...

+ ) : providers.length === 0 ? ( +
+

+ No providers configured. Set{' '} + + OLLAMA_BASE_URL + {' '} + or{' '} + + MOSAIC_CUSTOM_PROVIDERS + {' '} + to add providers. +

+
+ ) : ( +
+ {providers.map((provider) => ( + void testConnection(provider.id)} + /> + ))} +
+ )} +
); } diff --git a/apps/web/src/components/auth/sso-provider-buttons.spec.tsx b/apps/web/src/components/auth/sso-provider-buttons.spec.tsx new file mode 100644 index 0000000..a95ee8e --- /dev/null +++ b/apps/web/src/components/auth/sso-provider-buttons.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SsoProviderButtons } from './sso-provider-buttons.js'; + +describe('SsoProviderButtons', () => { + it('renders OIDC sign-in buttons and SAML fallback links', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Continue with WorkOS'); + expect(html).toContain('Continue with Keycloak (SAML)'); + expect(html).toContain('https://sso.example.com/realms/mosaic/protocol/saml'); + }); +}); diff --git a/apps/web/src/components/auth/sso-provider-buttons.tsx b/apps/web/src/components/auth/sso-provider-buttons.tsx new file mode 100644 index 0000000..f0a7998 --- /dev/null +++ b/apps/web/src/components/auth/sso-provider-buttons.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { SsoProviderDiscovery } from '@/lib/sso'; + +interface SsoProviderButtonsProps { + providers: SsoProviderDiscovery[]; + loadingProviderId?: string | null; + onOidcSignIn: (providerId: SsoProviderDiscovery['id']) => void; +} + +export function SsoProviderButtons({ + providers, + loadingProviderId = null, + onOidcSignIn, +}: SsoProviderButtonsProps): React.ReactElement | null { + const visibleProviders = providers.filter((provider) => provider.configured); + + if (visibleProviders.length === 0) { + return null; + } + + return ( +
+

Single sign-on

+
+ {visibleProviders.map((provider) => { + if (provider.loginMode === 'saml' && provider.samlFallback.loginUrl) { + return ( + + Continue with {provider.name} (SAML) + + ); + } + + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/settings/sso-provider-section.spec.tsx b/apps/web/src/components/settings/sso-provider-section.spec.tsx new file mode 100644 index 0000000..4385962 --- /dev/null +++ b/apps/web/src/components/settings/sso-provider-section.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SsoProviderSection } from './sso-provider-section.js'; + +describe('SsoProviderSection', () => { + it('renders configured providers with callback, sync, and fallback details', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('WorkOS'); + expect(html).toContain('/api/auth/oauth2/callback/workos'); + expect(html).toContain('Team sync claim: organization_id'); + expect(html).toContain('SAML fallback: https://sso.example.com/realms/mosaic/protocol/saml'); + }); +}); diff --git a/apps/web/src/components/settings/sso-provider-section.tsx b/apps/web/src/components/settings/sso-provider-section.tsx new file mode 100644 index 0000000..7d5e923 --- /dev/null +++ b/apps/web/src/components/settings/sso-provider-section.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import type { SsoProviderDiscovery } from '@/lib/sso'; + +interface SsoProviderSectionProps { + providers: SsoProviderDiscovery[]; + loading: boolean; +} + +export function SsoProviderSection({ + providers, + loading, +}: SsoProviderSectionProps): React.ReactElement { + if (loading) { + return

Loading SSO providers...

; + } + + const configuredProviders = providers.filter((provider) => provider.configured); + + if (providers.length === 0 || configuredProviders.length === 0) { + return ( +
+

+ No SSO providers configured. Set WorkOS or Keycloak environment variables to enable SSO. +

+
+ ); + } + + return ( +
+ {configuredProviders.map((provider) => ( +
+
+
+

{provider.name}

+

+ {provider.protocols.join(' + ').toUpperCase()} + {provider.loginMode ? ` • primary ${provider.loginMode.toUpperCase()}` : ''} +

+
+ + Enabled + +
+ +
+ {provider.callbackPath &&

Callback: {provider.callbackPath}

} + {provider.teamSync.enabled && provider.teamSync.claim && ( +

Team sync claim: {provider.teamSync.claim}

+ )} + {provider.samlFallback.configured && provider.samlFallback.loginUrl && ( +

SAML fallback: {provider.samlFallback.loginUrl}

+ )} + {provider.warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 398c251..ae07f03 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,9 +1,9 @@ import { createAuthClient } from 'better-auth/react'; -import { adminClient } from 'better-auth/client/plugins'; +import { adminClient, genericOAuthClient } from 'better-auth/client/plugins'; export const authClient = createAuthClient({ baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000', - plugins: [adminClient()], + plugins: [adminClient(), genericOAuthClient()], }); export const { useSession, signIn, signUp, signOut } = authClient; diff --git a/apps/web/src/lib/sso.ts b/apps/web/src/lib/sso.ts new file mode 100644 index 0000000..009fc2b --- /dev/null +++ b/apps/web/src/lib/sso.ts @@ -0,0 +1,20 @@ +export type SsoProtocol = 'oidc' | 'saml'; +export type SsoLoginMode = 'oidc' | 'saml' | null; + +export interface SsoProviderDiscovery { + id: 'authentik' | 'workos' | 'keycloak'; + name: string; + protocols: SsoProtocol[]; + configured: boolean; + loginMode: SsoLoginMode; + callbackPath: string | null; + teamSync: { + enabled: boolean; + claim: string | null; + }; + samlFallback: { + configured: boolean; + loginUrl: string | null; + }; + warnings: string[]; +} diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index b4745db..85e9421 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -237,14 +237,23 @@ external clients. Authentication requires a valid BetterAuth session (cookie or ### SSO (Optional) -| Variable | Description | -| ------------------------- | ------------------------------ | -| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID | -| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret | -| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL | +| Variable | Description | +| --------------------------- | ---------------------------------------------------------------------------- | +| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID | +| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret | +| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL | +| `AUTHENTIK_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `groups`) | +| `WORKOS_CLIENT_ID` | WorkOS OAuth client ID | +| `WORKOS_CLIENT_SECRET` | WorkOS OAuth client secret | +| `WORKOS_ISSUER` | WorkOS OIDC issuer URL | +| `WORKOS_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `organization_id`) | +| `KEYCLOAK_CLIENT_ID` | Keycloak OAuth client ID | +| `KEYCLOAK_CLIENT_SECRET` | Keycloak OAuth client secret | +| `KEYCLOAK_ISSUER` | Keycloak realm issuer URL | +| `KEYCLOAK_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `groups`) | +| `KEYCLOAK_SAML_LOGIN_URL` | Optional SAML login URL used when OIDC is unavailable | -All three Authentik variables must be set together. If only `AUTHENTIK_CLIENT_ID` -is set, a warning is logged and SSO is disabled. +Each OIDC provider requires its client ID, client secret, and issuer URL together. If only part of a provider configuration is set, gateway startup logs a warning and that provider is skipped. Keycloak can fall back to SAML when `KEYCLOAK_SAML_LOGIN_URL` is configured. ### Agent diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 0d97718..375f096 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -2,6 +2,7 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { admin, genericOAuth } from 'better-auth/plugins'; import type { Db } from '@mosaic/db'; +import { buildGenericOidcProviderConfigs } from './sso.js'; export interface AuthConfig { db: Db; @@ -11,33 +12,15 @@ export interface AuthConfig { export function createAuth(config: AuthConfig) { const { db, baseURL, secret } = config; - const authentikIssuer = process.env['AUTHENTIK_ISSUER']; - const authentikClientId = process.env['AUTHENTIK_CLIENT_ID']; - const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET']; - const plugins = authentikClientId - ? [ - genericOAuth({ - config: [ - { - providerId: 'authentik', - clientId: authentikClientId, - clientSecret: authentikClientSecret ?? '', - 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, - scopes: ['openid', 'email', 'profile'], - }, - ], - }), - ] - : undefined; + const oidcConfigs = buildGenericOidcProviderConfigs(); + const plugins = + oidcConfigs.length > 0 + ? [ + genericOAuth({ + config: oidcConfigs, + }), + ] + : undefined; const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000'; const trustedOrigins = corsOrigin.split(',').map((o) => o.trim()); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 57816ef..77867f4 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1 +1,12 @@ export { createAuth, type Auth, type AuthConfig } from './auth.js'; +export { + buildGenericOidcProviderConfigs, + buildSsoDiscovery, + listSsoStartupWarnings, + type GenericOidcProviderConfig, + type SsoLoginMode, + type SsoProtocol, + type SsoProviderDiscovery, + type SsoTeamSyncConfig, + type SupportedSsoProviderId, +} from './sso.js'; diff --git a/packages/auth/src/sso.spec.ts b/packages/auth/src/sso.spec.ts new file mode 100644 index 0000000..d28ae06 --- /dev/null +++ b/packages/auth/src/sso.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { + buildGenericOidcProviderConfigs, + buildSsoDiscovery, + listSsoStartupWarnings, +} from './sso.js'; + +describe('SSO provider config helpers', () => { + it('builds OIDC configs for Authentik, WorkOS, and Keycloak when fully configured', () => { + const configs = buildGenericOidcProviderConfigs({ + AUTHENTIK_CLIENT_ID: 'authentik-client', + AUTHENTIK_CLIENT_SECRET: 'authentik-secret', + AUTHENTIK_ISSUER: 'https://authentik.example.com', + WORKOS_CLIENT_ID: 'workos-client', + WORKOS_CLIENT_SECRET: 'workos-secret', + WORKOS_ISSUER: 'https://auth.workos.com/sso/client_123', + KEYCLOAK_CLIENT_ID: 'keycloak-client', + KEYCLOAK_CLIENT_SECRET: 'keycloak-secret', + KEYCLOAK_ISSUER: 'https://sso.example.com/realms/mosaic', + }); + + expect(configs.map((config) => config.providerId)).toEqual(['authentik', 'workos', 'keycloak']); + expect(configs.find((config) => config.providerId === 'workos')).toMatchObject({ + discoveryUrl: 'https://auth.workos.com/sso/client_123/.well-known/openid-configuration', + pkce: true, + requireIssuerValidation: true, + }); + expect(configs.find((config) => config.providerId === 'keycloak')).toMatchObject({ + discoveryUrl: 'https://sso.example.com/realms/mosaic/.well-known/openid-configuration', + pkce: true, + }); + }); + + it('exposes Keycloak SAML fallback when OIDC is not configured', () => { + const providers = buildSsoDiscovery({ + KEYCLOAK_SAML_LOGIN_URL: 'https://sso.example.com/realms/mosaic/protocol/saml', + }); + + expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({ + configured: true, + loginMode: 'saml', + samlFallback: { + configured: true, + loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml', + }, + }); + }); + + it('reports partial provider configuration as startup warnings', () => { + const warnings = listSsoStartupWarnings({ + WORKOS_CLIENT_ID: 'workos-client', + KEYCLOAK_CLIENT_ID: 'keycloak-client', + }); + + expect(warnings).toContain( + 'workos OIDC is partially configured. Missing: WORKOS_CLIENT_SECRET, WORKOS_ISSUER', + ); + expect(warnings).toContain( + 'keycloak OIDC is partially configured. Missing: KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER', + ); + }); +}); diff --git a/packages/auth/src/sso.ts b/packages/auth/src/sso.ts new file mode 100644 index 0000000..00fb307 --- /dev/null +++ b/packages/auth/src/sso.ts @@ -0,0 +1,212 @@ +export type SupportedSsoProviderId = 'authentik' | 'workos' | 'keycloak'; +export type SsoProtocol = 'oidc' | 'saml'; +export type SsoLoginMode = 'oidc' | 'saml' | null; + +type EnvMap = Record; + +export interface GenericOidcProviderConfig { + providerId: SupportedSsoProviderId; + clientId: string; + clientSecret: string; + discoveryUrl?: string; + issuer?: string; + authorizationUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + scopes: string[]; + pkce?: boolean; + requireIssuerValidation?: boolean; +} + +export interface SsoTeamSyncConfig { + enabled: boolean; + claim: string | null; +} + +export interface SsoProviderDiscovery { + id: SupportedSsoProviderId; + name: string; + protocols: SsoProtocol[]; + configured: boolean; + loginMode: SsoLoginMode; + callbackPath: string | null; + teamSync: SsoTeamSyncConfig; + samlFallback: { + configured: boolean; + loginUrl: string | null; + }; + warnings: string[]; +} + +const DEFAULT_SCOPES = ['openid', 'email', 'profile']; + +function readEnv(env: EnvMap, key: string): string | undefined { + const value = env[key]?.trim(); + return value ? value : undefined; +} + +function toDiscoveryUrl(issuer: string): string { + return `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; +} + +function getTeamSyncClaim(env: EnvMap, envKey: string, fallbackClaim?: string): SsoTeamSyncConfig { + const claim = readEnv(env, envKey) ?? fallbackClaim ?? null; + return { + enabled: claim !== null, + claim, + }; +} + +function buildAuthentikConfig(env: EnvMap): GenericOidcProviderConfig | null { + const issuer = readEnv(env, 'AUTHENTIK_ISSUER'); + const clientId = readEnv(env, 'AUTHENTIK_CLIENT_ID'); + const clientSecret = readEnv(env, 'AUTHENTIK_CLIENT_SECRET'); + + if (!issuer || !clientId || !clientSecret) { + return null; + } + + const baseIssuer = issuer.replace(/\/$/, ''); + + return { + providerId: 'authentik', + issuer: baseIssuer, + clientId, + clientSecret, + discoveryUrl: toDiscoveryUrl(baseIssuer), + authorizationUrl: `${baseIssuer}/application/o/authorize/`, + tokenUrl: `${baseIssuer}/application/o/token/`, + userInfoUrl: `${baseIssuer}/application/o/userinfo/`, + scopes: DEFAULT_SCOPES, + }; +} + +function buildWorkosConfig(env: EnvMap): GenericOidcProviderConfig | null { + const issuer = readEnv(env, 'WORKOS_ISSUER'); + const clientId = readEnv(env, 'WORKOS_CLIENT_ID'); + const clientSecret = readEnv(env, 'WORKOS_CLIENT_SECRET'); + + if (!issuer || !clientId || !clientSecret) { + return null; + } + + const normalizedIssuer = issuer.replace(/\/$/, ''); + + return { + providerId: 'workos', + issuer: normalizedIssuer, + clientId, + clientSecret, + discoveryUrl: toDiscoveryUrl(normalizedIssuer), + scopes: DEFAULT_SCOPES, + pkce: true, + requireIssuerValidation: true, + }; +} + +function buildKeycloakConfig(env: EnvMap): GenericOidcProviderConfig | null { + const issuer = readEnv(env, 'KEYCLOAK_ISSUER'); + const clientId = readEnv(env, 'KEYCLOAK_CLIENT_ID'); + const clientSecret = readEnv(env, 'KEYCLOAK_CLIENT_SECRET'); + + if (!issuer || !clientId || !clientSecret) { + return null; + } + + const normalizedIssuer = issuer.replace(/\/$/, ''); + + return { + providerId: 'keycloak', + issuer: normalizedIssuer, + clientId, + clientSecret, + discoveryUrl: toDiscoveryUrl(normalizedIssuer), + scopes: DEFAULT_SCOPES, + pkce: true, + requireIssuerValidation: true, + }; +} + +function collectWarnings(env: EnvMap, provider: SupportedSsoProviderId): string[] { + const prefix = provider.toUpperCase(); + const oidcFields = [ + `${prefix}_CLIENT_ID`, + `${prefix}_CLIENT_SECRET`, + `${prefix}_ISSUER`, + ] as const; + const presentOidcFields = oidcFields.filter((field) => readEnv(env, field)); + const warnings: string[] = []; + + if (presentOidcFields.length > 0 && presentOidcFields.length < oidcFields.length) { + const missing = oidcFields.filter((field) => !readEnv(env, field)); + warnings.push(`${provider} OIDC is partially configured. Missing: ${missing.join(', ')}`); + } + + return warnings; +} + +export function buildGenericOidcProviderConfigs( + env: EnvMap = process.env, +): GenericOidcProviderConfig[] { + return [buildAuthentikConfig(env), buildWorkosConfig(env), buildKeycloakConfig(env)].filter( + (config): config is GenericOidcProviderConfig => config !== null, + ); +} + +export function listSsoStartupWarnings(env: EnvMap = process.env): string[] { + return ['authentik', 'workos', 'keycloak'].flatMap((provider) => + collectWarnings(env, provider as SupportedSsoProviderId), + ); +} + +export function buildSsoDiscovery(env: EnvMap = process.env): SsoProviderDiscovery[] { + const oidcConfigs = new Map( + buildGenericOidcProviderConfigs(env).map((config) => [config.providerId, config]), + ); + const keycloakSamlLoginUrl = readEnv(env, 'KEYCLOAK_SAML_LOGIN_URL') ?? null; + + return [ + { + id: 'authentik', + name: 'Authentik', + protocols: ['oidc'], + configured: oidcConfigs.has('authentik'), + loginMode: oidcConfigs.has('authentik') ? 'oidc' : null, + callbackPath: oidcConfigs.has('authentik') ? '/api/auth/oauth2/callback/authentik' : null, + teamSync: getTeamSyncClaim(env, 'AUTHENTIK_TEAM_SYNC_CLAIM', 'groups'), + samlFallback: { + configured: false, + loginUrl: null, + }, + warnings: collectWarnings(env, 'authentik'), + }, + { + id: 'workos', + name: 'WorkOS', + protocols: ['oidc'], + configured: oidcConfigs.has('workos'), + loginMode: oidcConfigs.has('workos') ? 'oidc' : null, + callbackPath: oidcConfigs.has('workos') ? '/api/auth/oauth2/callback/workos' : null, + teamSync: getTeamSyncClaim(env, 'WORKOS_TEAM_SYNC_CLAIM', 'organization_id'), + samlFallback: { + configured: false, + loginUrl: null, + }, + warnings: collectWarnings(env, 'workos'), + }, + { + id: 'keycloak', + name: 'Keycloak', + protocols: ['oidc', 'saml'], + configured: oidcConfigs.has('keycloak') || keycloakSamlLoginUrl !== null, + loginMode: oidcConfigs.has('keycloak') ? 'oidc' : keycloakSamlLoginUrl ? 'saml' : null, + callbackPath: oidcConfigs.has('keycloak') ? '/api/auth/oauth2/callback/keycloak' : null, + teamSync: getTeamSyncClaim(env, 'KEYCLOAK_TEAM_SYNC_CLAIM', 'groups'), + samlFallback: { + configured: keycloakSamlLoginUrl !== null, + loginUrl: keycloakSamlLoginUrl, + }, + warnings: collectWarnings(env, 'keycloak'), + }, + ]; +}