feat(auth): add WorkOS and Keycloak SSO providers
This commit is contained in:
@@ -131,11 +131,13 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
|||||||
# AUTHENTIK_CLIENT_SECRET=
|
# AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||||
|
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||||
# WORKOS_CLIENT_ID=client_...
|
# WORKOS_CLIENT_ID=client_...
|
||||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||||
# WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback/workos
|
|
||||||
|
|
||||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||||
|
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||||
|
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||||
# KEYCLOAK_URL=https://auth.example.com
|
# KEYCLOAK_URL=https://auth.example.com
|
||||||
# KEYCLOAK_REALM=master
|
# KEYCLOAK_REALM=master
|
||||||
# KEYCLOAK_CLIENT_ID=mosaic
|
# KEYCLOAK_CLIENT_ID=mosaic
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
|
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
||||||
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 {
|
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 hasSsoProviders = ssoProviders.length > 0;
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -34,16 +33,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
router.push('/chat');
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
@@ -60,26 +49,15 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
|
|
||||||
{hasSsoProviders && (
|
{hasSsoProviders && (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{workosEnabled && (
|
{ssoProviders.map((provider) => (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
key={provider.id}
|
||||||
disabled={loading}
|
href={provider.href}
|
||||||
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"
|
||||||
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
|
{provider.buttonLabel}
|
||||||
</button>
|
</Link>
|
||||||
)}
|
))}
|
||||||
{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="relative flex items-center">
|
||||||
<div className="flex-1 border-t border-surface-border" />
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
<span className="mx-3 text-xs text-text-muted">or</span>
|
<span className="mx-3 text-xs text-text-muted">or</span>
|
||||||
|
|||||||
77
apps/web/src/app/auth/provider/[provider]/page.tsx
Normal file
77
apps/web/src/app/auth/provider/[provider]/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
import { signIn } from '@/lib/auth-client';
|
||||||
|
import { getSsoProvider } from '@/lib/sso-providers';
|
||||||
|
|
||||||
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
|
const params = useParams<{ provider: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
|
const provider = getSsoProvider(providerId);
|
||||||
|
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentProvider = provider;
|
||||||
|
|
||||||
|
if (!currentProvider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProvider.enabled) {
|
||||||
|
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProvider = currentProvider;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function redirectToProvider(): Promise<void> {
|
||||||
|
const result = await signIn.oauth2({
|
||||||
|
providerId: activeProvider.id,
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && result?.error) {
|
||||||
|
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void redirectToProvider();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [callbackURL, provider]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
|
{provider
|
||||||
|
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||||
|
: 'Preparing your sign-in request...'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Return to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||||
|
If the redirect does not start automatically, return to the login page and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/web/src/lib/sso-providers.test.ts
Normal file
48
apps/web/src/lib/sso-providers.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||||
|
|
||||||
|
describe('sso-providers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the enabled providers in login button order', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/workos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: true,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||||
|
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||||
|
|
||||||
|
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||||
|
expect(getSsoProvider('keycloak')).toEqual({
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
enabled: false,
|
||||||
|
href: '/auth/provider/keycloak',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown providers', () => {
|
||||||
|
expect(getSsoProvider('authentik')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/web/src/lib/sso-providers.ts
Normal file
53
apps/web/src/lib/sso-providers.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type SsoProviderId = 'workos' | 'keycloak';
|
||||||
|
|
||||||
|
export interface SsoProvider {
|
||||||
|
id: SsoProviderId;
|
||||||
|
buttonLabel: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||||
|
workos: {
|
||||||
|
id: 'workos',
|
||||||
|
buttonLabel: 'Continue with WorkOS',
|
||||||
|
description: 'Enterprise SSO via WorkOS',
|
||||||
|
},
|
||||||
|
keycloak: {
|
||||||
|
id: 'keycloak',
|
||||||
|
buttonLabel: 'Continue with Keycloak',
|
||||||
|
description: 'Enterprise SSO via Keycloak',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||||
|
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||||
|
.map((providerId) => getSsoProvider(providerId))
|
||||||
|
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||||
|
if (!isSsoProviderId(providerId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...PROVIDER_METADATA[providerId],
|
||||||
|
enabled: isSsoProviderEnabled(providerId),
|
||||||
|
href: `/auth/provider/${providerId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||||
|
return value === 'workos' || value === 'keycloak';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||||
|
switch (providerId) {
|
||||||
|
case 'workos':
|
||||||
|
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||||
|
case 'keycloak':
|
||||||
|
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
111
docs/SSO-PROVIDERS.md
Normal file
111
docs/SSO-PROVIDERS.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# SSO Providers
|
||||||
|
|
||||||
|
Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
|
||||||
|
```
|
||||||
|
|
||||||
|
For the providers in this document:
|
||||||
|
|
||||||
|
- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik`
|
||||||
|
- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos`
|
||||||
|
- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak`
|
||||||
|
|
||||||
|
## Required environment variables
|
||||||
|
|
||||||
|
### Authentik
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
|
||||||
|
AUTHENTIK_CLIENT_ID=...
|
||||||
|
AUTHENTIK_CLIENT_SECRET=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### WorkOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WORKOS_ISSUER=https://your-company.authkit.app
|
||||||
|
WORKOS_CLIENT_ID=client_...
|
||||||
|
WORKOS_CLIENT_SECRET=...
|
||||||
|
NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer.
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||||
|
KEYCLOAK_CLIENT_ID=mosaic
|
||||||
|
KEYCLOAK_CLIENT_SECRET=...
|
||||||
|
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer, you can keep the issuer split as:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYCLOAK_URL=https://auth.example.com
|
||||||
|
KEYCLOAK_REALM=master
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth package will derive `KEYCLOAK_ISSUER` from those two values.
|
||||||
|
|
||||||
|
## WorkOS setup
|
||||||
|
|
||||||
|
1. In WorkOS, create or select the application that will back Mosaic login.
|
||||||
|
2. Configure an AuthKit domain or custom authentication domain for the application.
|
||||||
|
3. Add the redirect URI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`.
|
||||||
|
5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2.
|
||||||
|
6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use.
|
||||||
|
7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered.
|
||||||
|
|
||||||
|
## Keycloak setup
|
||||||
|
|
||||||
|
1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic.
|
||||||
|
2. Create a confidential OIDC client named `mosaic` or your preferred client ID.
|
||||||
|
3. Set the valid redirect URI to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Set the web origin to the public Mosaic web URL.
|
||||||
|
5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`.
|
||||||
|
6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`.
|
||||||
|
7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered.
|
||||||
|
|
||||||
|
### Local Keycloak smoke test
|
||||||
|
|
||||||
|
If you want to test locally with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --name mosaic-keycloak \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e KEYCLOAK_ADMIN=admin \
|
||||||
|
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||||
|
quay.io/keycloak/keycloak:26.1 start-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYCLOAK_ISSUER=http://localhost:8080/realms/master
|
||||||
|
KEYCLOAK_CLIENT_ID=mosaic
|
||||||
|
KEYCLOAK_CLIENT_SECRET=...
|
||||||
|
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web flow
|
||||||
|
|
||||||
|
The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider.
|
||||||
|
|
||||||
|
## Failure mode
|
||||||
|
|
||||||
|
Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaic/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider.
|
||||||
@@ -6,14 +6,15 @@ describe('buildOAuthProviders', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
// Clear all SSO-related env vars before each test
|
|
||||||
delete process.env['AUTHENTIK_CLIENT_ID'];
|
delete process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
delete process.env['AUTHENTIK_CLIENT_SECRET'];
|
delete process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||||
delete process.env['AUTHENTIK_ISSUER'];
|
delete process.env['AUTHENTIK_ISSUER'];
|
||||||
delete process.env['WORKOS_CLIENT_ID'];
|
delete process.env['WORKOS_CLIENT_ID'];
|
||||||
delete process.env['WORKOS_CLIENT_SECRET'];
|
delete process.env['WORKOS_CLIENT_SECRET'];
|
||||||
|
delete process.env['WORKOS_ISSUER'];
|
||||||
delete process.env['KEYCLOAK_CLIENT_ID'];
|
delete process.env['KEYCLOAK_CLIENT_ID'];
|
||||||
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||||
|
delete process.env['KEYCLOAK_ISSUER'];
|
||||||
delete process.env['KEYCLOAK_URL'];
|
delete process.env['KEYCLOAK_URL'];
|
||||||
delete process.env['KEYCLOAK_REALM'];
|
delete process.env['KEYCLOAK_REALM'];
|
||||||
});
|
});
|
||||||
@@ -28,22 +29,32 @@ describe('buildOAuthProviders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('WorkOS', () => {
|
describe('WorkOS', () => {
|
||||||
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
|
it('includes workos provider when all required env vars are set', () => {
|
||||||
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||||
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
||||||
|
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
|
||||||
|
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
const workos = providers.find((p) => p.providerId === 'workos');
|
const workos = providers.find((p) => p.providerId === 'workos');
|
||||||
|
|
||||||
expect(workos).toBeDefined();
|
expect(workos).toBeDefined();
|
||||||
expect(workos?.clientId).toBe('client_test123');
|
expect(workos?.clientId).toBe('client_test123');
|
||||||
expect(workos?.authorizationUrl).toBe('https://api.workos.com/sso/authorize');
|
expect(workos?.issuer).toBe('https://example.authkit.app');
|
||||||
expect(workos?.tokenUrl).toBe('https://api.workos.com/sso/token');
|
expect(workos?.discoveryUrl).toBe(
|
||||||
expect(workos?.userInfoUrl).toBe('https://api.workos.com/sso/profile');
|
'https://example.authkit.app/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
|
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
|
it('throws when WorkOS is partially configured', () => {
|
||||||
|
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||||
|
|
||||||
|
expect(() => buildOAuthProviders()).toThrow(
|
||||||
|
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes workos provider when WorkOS is not configured', () => {
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
const workos = providers.find((p) => p.providerId === 'workos');
|
const workos = providers.find((p) => p.providerId === 'workos');
|
||||||
expect(workos).toBeUndefined();
|
expect(workos).toBeUndefined();
|
||||||
@@ -51,47 +62,78 @@ describe('buildOAuthProviders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Keycloak', () => {
|
describe('Keycloak', () => {
|
||||||
it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => {
|
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
|
||||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||||
process.env['KEYCLOAK_URL'] = 'https://auth.example.com';
|
process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||||
|
|
||||||
|
expect(keycloakProvider).toBeDefined();
|
||||||
|
expect(keycloakProvider?.clientId).toBe('mosaic');
|
||||||
|
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||||
|
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => {
|
||||||
|
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||||
|
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||||
|
process.env['KEYCLOAK_URL'] = 'https://auth.example.com/';
|
||||||
process.env['KEYCLOAK_REALM'] = 'myrealm';
|
process.env['KEYCLOAK_REALM'] = 'myrealm';
|
||||||
|
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
const keycloak = providers.find((p) => p.providerId === 'keycloak');
|
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||||
|
|
||||||
expect(keycloak).toBeDefined();
|
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||||
expect(keycloak?.clientId).toBe('mosaic');
|
|
||||||
expect(keycloak?.discoveryUrl).toBe(
|
|
||||||
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||||
);
|
);
|
||||||
expect(keycloak?.scopes).toEqual(['openid', 'email', 'profile']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes keycloak provider when KEYCLOAK_CLIENT_ID is not set', () => {
|
it('throws when Keycloak is partially configured', () => {
|
||||||
|
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||||
|
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||||
|
|
||||||
|
expect(() => buildOAuthProviders()).toThrow(
|
||||||
|
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes keycloak provider when Keycloak is not configured', () => {
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
const keycloak = providers.find((p) => p.providerId === 'keycloak');
|
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||||
expect(keycloak).toBeUndefined();
|
expect(keycloakProvider).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Authentik', () => {
|
describe('Authentik', () => {
|
||||||
it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => {
|
it('includes authentik provider when all required env vars are set', () => {
|
||||||
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||||
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
|
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
|
||||||
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
|
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/';
|
||||||
|
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
const authentik = providers.find((p) => p.providerId === 'authentik');
|
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||||
|
|
||||||
expect(authentik).toBeDefined();
|
expect(authentik).toBeDefined();
|
||||||
expect(authentik?.clientId).toBe('authentik-client');
|
expect(authentik?.clientId).toBe('authentik-client');
|
||||||
|
expect(authentik?.issuer).toBe('https://auth.example.com/application/o/mosaic');
|
||||||
expect(authentik?.discoveryUrl).toBe(
|
expect(authentik?.discoveryUrl).toBe(
|
||||||
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
|
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes authentik provider when AUTHENTIK_CLIENT_ID is not set', () => {
|
it('throws when Authentik is partially configured', () => {
|
||||||
|
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||||
|
|
||||||
|
expect(() => buildOAuthProviders()).toThrow(
|
||||||
|
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes authentik provider when Authentik is not configured', () => {
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
const authentik = providers.find((p) => p.providerId === 'authentik');
|
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||||
expect(authentik).toBeUndefined();
|
expect(authentik).toBeUndefined();
|
||||||
@@ -100,10 +142,14 @@ describe('buildOAuthProviders', () => {
|
|||||||
|
|
||||||
it('registers all three providers when all env vars are set', () => {
|
it('registers all three providers when all env vars are set', () => {
|
||||||
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
|
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
|
||||||
|
process.env['AUTHENTIK_CLIENT_SECRET'] = 'a-secret';
|
||||||
|
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
|
||||||
process.env['WORKOS_CLIENT_ID'] = 'w-id';
|
process.env['WORKOS_CLIENT_ID'] = 'w-id';
|
||||||
|
process.env['WORKOS_CLIENT_SECRET'] = 'w-secret';
|
||||||
|
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
|
||||||
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
||||||
process.env['KEYCLOAK_URL'] = 'https://kc.example.com';
|
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
|
||||||
process.env['KEYCLOAK_REALM'] = 'test';
|
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test';
|
||||||
|
|
||||||
const providers = buildOAuthProviders();
|
const providers = buildOAuthProviders();
|
||||||
expect(providers).toHaveLength(3);
|
expect(providers).toHaveLength(3);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, genericOAuth } from 'better-auth/plugins';
|
import { admin } from 'better-auth/plugins';
|
||||||
import type { GenericOAuthConfig } from 'better-auth/plugins';
|
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
@@ -14,52 +14,108 @@ export interface AuthConfig {
|
|||||||
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||||
const providers: GenericOAuthConfig[] = [];
|
const providers: GenericOAuthConfig[] = [];
|
||||||
|
|
||||||
|
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
||||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
if (authentikClientId) {
|
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
assertOptionalProviderConfig('Authentik SSO', {
|
||||||
|
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
|
||||||
|
values: [authentikIssuer, authentikClientId, authentikClientSecret],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authentikIssuer && authentikClientId && authentikClientSecret) {
|
||||||
providers.push({
|
providers.push({
|
||||||
providerId: 'authentik',
|
providerId: 'authentik',
|
||||||
clientId: authentikClientId,
|
clientId: authentikClientId,
|
||||||
clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
|
clientSecret: authentikClientSecret,
|
||||||
discoveryUrl: authentikIssuer
|
issuer: authentikIssuer,
|
||||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
|
||||||
: 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'],
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
requireIssuerValidation: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
|
||||||
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||||
if (workosClientId) {
|
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
|
||||||
|
assertOptionalProviderConfig('WorkOS SSO', {
|
||||||
|
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
|
||||||
|
values: [workosIssuer, workosClientId, workosClientSecret],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workosIssuer && workosClientId && workosClientSecret) {
|
||||||
providers.push({
|
providers.push({
|
||||||
providerId: 'workos',
|
providerId: 'workos',
|
||||||
clientId: workosClientId,
|
clientId: workosClientId,
|
||||||
clientSecret: process.env['WORKOS_CLIENT_SECRET'] ?? '',
|
clientSecret: workosClientSecret,
|
||||||
authorizationUrl: 'https://api.workos.com/sso/authorize',
|
issuer: workosIssuer,
|
||||||
tokenUrl: 'https://api.workos.com/sso/token',
|
discoveryUrl: buildDiscoveryUrl(workosIssuer),
|
||||||
userInfoUrl: 'https://api.workos.com/sso/profile',
|
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
requireIssuerValidation: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keycloakIssuer = resolveKeycloakIssuer();
|
||||||
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
||||||
if (keycloakClientId) {
|
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||||
const keycloakUrl = process.env['KEYCLOAK_URL'] ?? '';
|
assertOptionalProviderConfig('Keycloak SSO', {
|
||||||
const keycloakRealm = process.env['KEYCLOAK_REALM'] ?? '';
|
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'],
|
||||||
providers.push({
|
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer],
|
||||||
providerId: 'keycloak',
|
});
|
||||||
clientId: keycloakClientId,
|
|
||||||
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '',
|
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
|
||||||
discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`,
|
providers.push(
|
||||||
scopes: ['openid', 'email', 'profile'],
|
keycloak({
|
||||||
});
|
clientId: keycloakClientId,
|
||||||
|
clientSecret: keycloakClientSecret,
|
||||||
|
issuer: keycloakIssuer,
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return providers;
|
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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user