feat(auth): add WorkOS + Keycloak SSO providers (P8-001)

- Refactor auth.ts to build OAuth providers array dynamically; extract
  buildOAuthProviders() for unit-testability
- Add WorkOS provider (WORKOS_CLIENT_ID/SECRET/REDIRECT_URI env vars)
- Add Keycloak provider with realm-scoped OIDC discovery
  (KEYCLOAK_URL/REALM/CLIENT_ID/CLIENT_SECRET env vars)
- Add genericOAuthClient plugin to web auth-client for signIn.oauth2()
- Add WorkOS + Keycloak SSO buttons to login page (NEXT_PUBLIC_*_ENABLED
  feature flags control visibility)
- Update .env.example with SSO provider stanzas
- Add 8 unit tests covering all provider inclusion/exclusion paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 21:17:11 -05:00
parent 25f880416a
commit 254da35300
5 changed files with 235 additions and 31 deletions

View File

@@ -123,7 +123,24 @@ OTEL_SERVICE_NAME=mosaic-gateway
# TELEGRAM_GATEWAY_URL=http://localhost:4000
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# 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_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
# NEXT_PUBLIC_WORKOS_ENABLED=true
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true

View File

@@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
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;
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
@@ -30,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
router.push('/chat');
}
async function handleSsoSignIn(providerId: string): Promise<void> {
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 (
<div>
<h1 className="text-2xl font-semibold">Sign in</h1>
@@ -44,7 +58,37 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
{hasSsoProviders && (
<div className="mt-6 space-y-3">
{workosEnabled && (
<button
type="button"
disabled={loading}
onClick={() => handleSsoSignIn('workos')}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
Continue with WorkOS
</button>
)}
{keycloakEnabled && (
<button
type="button"
disabled={loading}
onClick={() => handleSsoSignIn('keycloak')}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
Continue with Keycloak
</button>
)}
<div className="relative flex items-center">
<div className="flex-1 border-t border-surface-border" />
<span className="mx-3 text-xs text-text-muted">or</span>
<div className="flex-1 border-t border-surface-border" />
</div>
</div>
)}
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email

View File

@@ -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;

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { buildOAuthProviders } from './auth.js';
describe('buildOAuthProviders', () => {
const originalEnv = process.env;
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['KEYCLOAK_CLIENT_ID'];
delete process.env['KEYCLOAK_CLIENT_SECRET'];
delete process.env['KEYCLOAK_URL'];
delete process.env['KEYCLOAK_REALM'];
});
afterEach(() => {
process.env = originalEnv;
});
it('returns empty array when no SSO env vars are set', () => {
const providers = buildOAuthProviders();
expect(providers).toHaveLength(0);
});
describe('WorkOS', () => {
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
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?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeUndefined();
});
});
describe('Keycloak', () => {
it('includes keycloak provider when KEYCLOAK_CLIENT_ID 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_REALM'] = 'myrealm';
const providers = buildOAuthProviders();
const keycloak = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeDefined();
expect(keycloak?.clientId).toBe('mosaic');
expect(keycloak?.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', () => {
const providers = buildOAuthProviders();
const keycloak = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeUndefined();
});
});
describe('Authentik', () => {
it('includes authentik provider when AUTHENTIK_CLIENT_ID is 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';
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeDefined();
expect(authentik?.clientId).toBe('authentik-client');
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', () => {
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeUndefined();
});
});
it('registers all three providers when all env vars are set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
process.env['WORKOS_CLIENT_ID'] = 'w-id';
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
process.env['KEYCLOAK_URL'] = 'https://kc.example.com';
process.env['KEYCLOAK_REALM'] = 'test';
const providers = buildOAuthProviders();
expect(providers).toHaveLength(3);
const ids = providers.map((p) => p.providerId);
expect(ids).toContain('authentik');
expect(ids).toContain('workos');
expect(ids).toContain('keycloak');
});
});

View File

@@ -1,6 +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 type { Db } from '@mosaic/db';
export interface AuthConfig {
@@ -9,35 +10,62 @@ export interface AuthConfig {
secret?: string;
}
export function createAuth(config: AuthConfig) {
const { db, baseURL, secret } = config;
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
export function buildOAuthProviders(): GenericOAuthConfig[] {
const providers: GenericOAuthConfig[] = [];
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
const plugins = authentikClientId
? [
genericOAuth({
config: [
{
if (authentikClientId) {
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
providers.push({
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: authentikClientSecret ?? '',
clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
discoveryUrl: authentikIssuer
? `${authentikIssuer}/.well-known/openid-configuration`
: undefined,
authorizationUrl: authentikIssuer
? `${authentikIssuer}/application/o/authorize/`
: undefined,
authorizationUrl: authentikIssuer ? `${authentikIssuer}/application/o/authorize/` : undefined,
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
userInfoUrl: authentikIssuer
? `${authentikIssuer}/application/o/userinfo/`
: undefined,
userInfoUrl: authentikIssuer ? `${authentikIssuer}/application/o/userinfo/` : undefined,
scopes: ['openid', 'email', 'profile'],
},
],
}),
]
: undefined;
});
}
const workosClientId = process.env['WORKOS_CLIENT_ID'];
if (workosClientId) {
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',
scopes: ['openid', 'email', 'profile'],
});
}
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'],
});
}
return providers;
}
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());