feat(auth): add WorkOS and Keycloak SSO providers (rebased) (#220)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #220.
This commit is contained in:
@@ -3,9 +3,11 @@ import { createAuth, type Auth } from '@mosaic/auth';
|
|||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AUTH } from './auth.tokens.js';
|
import { AUTH } from './auth.tokens.js';
|
||||||
|
import { SsoController } from './sso.controller.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [SsoController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: AUTH,
|
provide: AUTH,
|
||||||
|
|||||||
40
apps/gateway/src/auth/sso.controller.spec.ts
Normal file
40
apps/gateway/src/auth/sso.controller.spec.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
10
apps/gateway/src/auth/sso.controller.ts
Normal file
10
apps/gateway/src/auth/sso.controller.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
|
import { listSsoStartupWarnings } from '@mosaic/auth';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
@@ -23,13 +24,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
for (const warning of listSsoStartupWarnings()) {
|
||||||
process.env['AUTHENTIK_CLIENT_ID'] &&
|
logger.warn(warning);
|
||||||
(!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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import Link from 'next/link';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { api } from '@/lib/api';
|
||||||
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
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 {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const ssoProviders = getEnabledSsoProviders();
|
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||||
const hasSsoProviders = ssoProviders.length > 0;
|
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||||
|
SsoProviderDiscovery['id'] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||||
|
.catch(() => [] as SsoProviderDiscovery[])
|
||||||
|
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,6 +43,27 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
router.push('/chat');
|
router.push('/chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
@@ -47,26 +78,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSsoProviders && (
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
{ssoProviders.map((provider) => (
|
|
||||||
<Link
|
|
||||||
key={provider.id}
|
|
||||||
href={provider.href}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{provider.buttonLabel}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<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>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
Email
|
Email
|
||||||
@@ -108,6 +120,14 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<SsoProviderButtons
|
||||||
|
providers={ssoProviders}
|
||||||
|
loadingProviderId={ssoLoadingProviderId}
|
||||||
|
onOidcSignIn={(providerId) => {
|
||||||
|
void handleSsoSignIn(providerId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
<p className="mt-4 text-center text-sm text-text-muted">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
import { authClient, useSession } from '@/lib/auth-client';
|
||||||
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -424,7 +426,9 @@ function NotificationsTab(): React.ReactElement {
|
|||||||
|
|
||||||
function ProvidersTab(): React.ReactElement {
|
function ProvidersTab(): React.ReactElement {
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||||
|
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [ssoLoading, setSsoLoading] = useState(true);
|
||||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -434,6 +438,13 @@ function ProvidersTab(): React.ReactElement {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||||
|
.catch(() => [] as SsoProviderDiscovery[])
|
||||||
|
.then((providers) => setSsoProviders(providers))
|
||||||
|
.finally(() => setSsoLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||||
setTestStatuses((prev) => ({
|
setTestStatuses((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -464,35 +475,44 @@ function ProvidersTab(): React.ReactElement {
|
|||||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-6">
|
||||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
<div className="space-y-4">
|
||||||
{loading ? (
|
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
||||||
) : providers.length === 0 ? (
|
</div>
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">
|
<div className="space-y-4">
|
||||||
No providers configured. Set{' '}
|
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">OLLAMA_BASE_URL</code>{' '}
|
{loading ? (
|
||||||
or{' '}
|
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
) : providers.length === 0 ? (
|
||||||
MOSAIC_CUSTOM_PROVIDERS
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
</code>{' '}
|
<p className="text-sm text-text-muted">
|
||||||
to add providers.
|
No providers configured. Set{' '}
|
||||||
</p>
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||||
</div>
|
OLLAMA_BASE_URL
|
||||||
) : (
|
</code>{' '}
|
||||||
<div className="space-y-4">
|
or{' '}
|
||||||
{providers.map((provider) => (
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||||
<ProviderCard
|
MOSAIC_CUSTOM_PROVIDERS
|
||||||
key={provider.id}
|
</code>{' '}
|
||||||
provider={provider}
|
to add providers.
|
||||||
defaultModel={defaultModel}
|
</p>
|
||||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
</div>
|
||||||
onTest={() => void testConnection(provider.id)}
|
) : (
|
||||||
/>
|
<div className="space-y-4">
|
||||||
))}
|
{providers.map((provider) => (
|
||||||
</div>
|
<ProviderCard
|
||||||
)}
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
defaultModel={defaultModel}
|
||||||
|
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||||
|
onTest={() => void testConnection(provider.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
apps/web/src/components/auth/sso-provider-buttons.spec.tsx
Normal file
45
apps/web/src/components/auth/sso-provider-buttons.spec.tsx
Normal file
@@ -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(
|
||||||
|
<SsoProviderButtons
|
||||||
|
providers={[
|
||||||
|
{
|
||||||
|
id: 'workos',
|
||||||
|
name: 'WorkOS',
|
||||||
|
protocols: ['oidc'],
|
||||||
|
configured: true,
|
||||||
|
loginMode: 'oidc',
|
||||||
|
callbackPath: '/api/auth/oauth2/callback/workos',
|
||||||
|
teamSync: { enabled: true, claim: 'organization_id' },
|
||||||
|
samlFallback: { configured: false, loginUrl: null },
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
name: 'Keycloak',
|
||||||
|
protocols: ['oidc', 'saml'],
|
||||||
|
configured: true,
|
||||||
|
loginMode: 'saml',
|
||||||
|
callbackPath: null,
|
||||||
|
teamSync: { enabled: true, claim: 'groups' },
|
||||||
|
samlFallback: {
|
||||||
|
configured: true,
|
||||||
|
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onOidcSignIn={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('Continue with WorkOS');
|
||||||
|
expect(html).toContain('Continue with Keycloak (SAML)');
|
||||||
|
expect(html).toContain('https://sso.example.com/realms/mosaic/protocol/saml');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/web/src/components/auth/sso-provider-buttons.tsx
Normal file
55
apps/web/src/components/auth/sso-provider-buttons.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="mt-6 space-y-3 border-t border-surface-border pt-6">
|
||||||
|
<p className="text-sm font-medium text-text-secondary">Single sign-on</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleProviders.map((provider) => {
|
||||||
|
if (provider.loginMode === 'saml' && provider.samlFallback.loginUrl) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={provider.id}
|
||||||
|
href={provider.samlFallback.loginUrl}
|
||||||
|
className="flex w-full items-center justify-center rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:border-accent/50 hover:text-accent"
|
||||||
|
>
|
||||||
|
Continue with {provider.name} (SAML)
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
disabled={loadingProviderId === provider.id}
|
||||||
|
onClick={() => onOidcSignIn(provider.id)}
|
||||||
|
className="flex w-full items-center justify-center rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:border-accent/50 hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingProviderId === provider.id
|
||||||
|
? `Redirecting to ${provider.name}...`
|
||||||
|
: `Continue with ${provider.name}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<SsoProviderSection
|
||||||
|
loading={false}
|
||||||
|
providers={[
|
||||||
|
{
|
||||||
|
id: 'workos',
|
||||||
|
name: 'WorkOS',
|
||||||
|
protocols: ['oidc'],
|
||||||
|
configured: true,
|
||||||
|
loginMode: 'oidc',
|
||||||
|
callbackPath: '/api/auth/oauth2/callback/workos',
|
||||||
|
teamSync: { enabled: true, claim: 'organization_id' },
|
||||||
|
samlFallback: { configured: false, loginUrl: null },
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
name: 'Keycloak',
|
||||||
|
protocols: ['oidc', 'saml'],
|
||||||
|
configured: true,
|
||||||
|
loginMode: 'saml',
|
||||||
|
callbackPath: null,
|
||||||
|
teamSync: { enabled: true, claim: 'groups' },
|
||||||
|
samlFallback: {
|
||||||
|
configured: true,
|
||||||
|
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/web/src/components/settings/sso-provider-section.tsx
Normal file
67
apps/web/src/components/settings/sso-provider-section.tsx
Normal file
@@ -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 <p className="text-sm text-text-muted">Loading SSO providers...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredProviders = providers.filter((provider) => provider.configured);
|
||||||
|
|
||||||
|
if (providers.length === 0 || configuredProviders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
No SSO providers configured. Set WorkOS or Keycloak environment variables to enable SSO.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{configuredProviders.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.id}
|
||||||
|
className="rounded-lg border border-surface-border bg-surface-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-text-primary">{provider.name}</h3>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{provider.protocols.join(' + ').toUpperCase()}
|
||||||
|
{provider.loginMode ? ` • primary ${provider.loginMode.toUpperCase()}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-1 text-xs font-medium text-accent">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
||||||
|
{provider.callbackPath && <p>Callback: {provider.callbackPath}</p>}
|
||||||
|
{provider.teamSync.enabled && provider.teamSync.claim && (
|
||||||
|
<p>Team sync claim: {provider.teamSync.claim}</p>
|
||||||
|
)}
|
||||||
|
{provider.samlFallback.configured && provider.samlFallback.loginUrl && (
|
||||||
|
<p>SAML fallback: {provider.samlFallback.loginUrl}</p>
|
||||||
|
)}
|
||||||
|
{provider.warnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-warning">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/lib/sso.ts
Normal file
20
apps/web/src/lib/sso.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
@@ -237,14 +237,23 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
|
|||||||
|
|
||||||
### SSO (Optional)
|
### SSO (Optional)
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ------------------------- | ------------------------------ |
|
| --------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID |
|
| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID |
|
||||||
| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret |
|
| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret |
|
||||||
| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL |
|
| `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`
|
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.
|
||||||
is set, a warning is logged and SSO is disabled.
|
|
||||||
|
|
||||||
### Agent
|
### Agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { admin } from 'better-auth/plugins';
|
import { admin } from 'better-auth/plugins';
|
||||||
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
import { genericOAuth, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
|
import { buildGenericOidcProviderConfigs } from './sso.js';
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
db: Db;
|
db: Db;
|
||||||
@@ -10,118 +11,21 @@ export interface AuthConfig {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
|
||||||
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||||
const providers: GenericOAuthConfig[] = [];
|
return buildGenericOidcProviderConfigs() as 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<string | undefined> },
|
|
||||||
): 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) {
|
export function createAuth(config: AuthConfig) {
|
||||||
const { db, baseURL, secret } = config;
|
const { db, baseURL, secret } = config;
|
||||||
|
const oidcConfigs = buildOAuthProviders();
|
||||||
const oauthProviders = buildOAuthProviders();
|
|
||||||
const plugins =
|
const plugins =
|
||||||
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
oidcConfigs.length > 0
|
||||||
|
? [
|
||||||
|
genericOAuth({
|
||||||
|
config: oidcConfigs,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
export { createAuth, type Auth, type AuthConfig } from './auth.js';
|
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';
|
||||||
|
|||||||
62
packages/auth/src/sso.spec.ts
Normal file
62
packages/auth/src/sso.spec.ts
Normal file
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
241
packages/auth/src/sso.ts
Normal file
241
packages/auth/src/sso.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
export type SupportedSsoProviderId = 'authentik' | 'workos' | 'keycloak';
|
||||||
|
export type SsoProtocol = 'oidc' | 'saml';
|
||||||
|
export type SsoLoginMode = 'oidc' | 'saml' | null;
|
||||||
|
|
||||||
|
type EnvMap = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
const fields = [issuer, clientId, clientSecret];
|
||||||
|
const presentCount = fields.filter(Boolean).length;
|
||||||
|
if (presentCount > 0 && presentCount < fields.length) {
|
||||||
|
throw new Error(
|
||||||
|
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, 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');
|
||||||
|
|
||||||
|
const fields = [issuer, clientId, clientSecret];
|
||||||
|
const presentCount = fields.filter(Boolean).length;
|
||||||
|
if (presentCount > 0 && presentCount < fields.length) {
|
||||||
|
throw new Error(
|
||||||
|
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, 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 explicitIssuer = readEnv(env, 'KEYCLOAK_ISSUER');
|
||||||
|
const keycloakUrl = readEnv(env, 'KEYCLOAK_URL');
|
||||||
|
const keycloakRealm = readEnv(env, 'KEYCLOAK_REALM');
|
||||||
|
const clientId = readEnv(env, 'KEYCLOAK_CLIENT_ID');
|
||||||
|
const clientSecret = readEnv(env, 'KEYCLOAK_CLIENT_SECRET');
|
||||||
|
|
||||||
|
// Derive issuer from KEYCLOAK_URL + KEYCLOAK_REALM if KEYCLOAK_ISSUER not set
|
||||||
|
const issuer =
|
||||||
|
explicitIssuer ??
|
||||||
|
(keycloakUrl && keycloakRealm
|
||||||
|
? `${keycloakUrl.replace(/\/$/, '')}/realms/${keycloakRealm}`
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
const anySet = !!(issuer || clientId || clientSecret);
|
||||||
|
if (anySet && (!issuer || !clientId || !clientSecret)) {
|
||||||
|
throw new Error(
|
||||||
|
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user