From dd108b9ab4e0651b1da6b30578b43a0b25c4a4e1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 21 Mar 2026 12:57:07 +0000 Subject: [PATCH] feat(auth): add WorkOS and Keycloak SSO providers (rebased) (#220) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- 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 | 72 ++++-- .../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/sso.ts | 20 ++ docs/guides/admin-guide.md | 23 +- packages/auth/src/auth.ts | 118 +-------- packages/auth/src/index.ts | 11 + packages/auth/src/sso.spec.ts | 62 +++++ packages/auth/src/sso.ts | 241 ++++++++++++++++++ 16 files changed, 724 insertions(+), 176 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 9dfd8d4..a1a1c32 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,17 +1,27 @@ 'use client'; -import Link from 'next/link'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { signIn } from '@/lib/auth-client'; -import { getEnabledSsoProviders } from '@/lib/sso-providers'; +import Link from 'next/link'; +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 = getEnabledSsoProviders(); - const hasSsoProviders = ssoProviders.length > 0; + 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(); @@ -33,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

@@ -47,26 +78,7 @@ export default function LoginPage(): React.ReactElement {
)} - {hasSsoProviders && ( -
- {ssoProviders.map((provider) => ( - - {provider.buttonLabel} - - ))} -
-
- or -
-
-
- )} - -
+