Merge pull request 'feat(auth): add WorkOS and Keycloak SSO providers (P8-001)' (#210) from feat/p8-001-sso-providers into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Reviewed-on: mosaic/mosaic-stack#210
This commit was merged in pull request #210.
This commit is contained in:
21
.env.example
21
.env.example
@@ -123,7 +123,26 @@ 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_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- 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_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
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ssoProviders = getEnabledSsoProviders();
|
||||
const hasSsoProviders = ssoProviders.length > 0;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
@@ -44,7 +47,26 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
{hasSsoProviders && (
|
||||
<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>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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.
|
||||
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# P8-001 — WorkOS + Keycloak SSO Providers
|
||||
|
||||
**Branch:** feat/p8-001-sso-providers
|
||||
**Started:** 2026-03-18
|
||||
**Mode:** Delivery
|
||||
|
||||
## Objective
|
||||
|
||||
Add WorkOS and Keycloak as optional SSO providers to the BetterAuth configuration, following the existing Authentik pattern.
|
||||
|
||||
## Scope
|
||||
|
||||
| Surface | Change |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `packages/auth/src/auth.ts` | Refactor provider array, add WorkOS + Keycloak conditional registration |
|
||||
| `apps/web/src/lib/auth-client.ts` | Add `genericOAuthClient()` plugin |
|
||||
| `apps/web/src/app/(auth)/login/page.tsx` | WorkOS + Keycloak SSO buttons gated by `NEXT_PUBLIC_*` env vars |
|
||||
| `.env.example` | Document WorkOS + Keycloak env vars |
|
||||
| `packages/auth/src/auth.test.ts` | Unit tests verifying env-var gating |
|
||||
|
||||
## Plan
|
||||
|
||||
1. ✅ Refactor `createAuth` to build `oauthProviders[]` conditionally
|
||||
2. ✅ Add WorkOS provider (explicit URLs, no discovery)
|
||||
3. ✅ Add Keycloak provider (discoveryUrl pattern)
|
||||
4. ✅ Add `genericOAuthClient()` to auth-client.ts
|
||||
5. ✅ Add SSO buttons to login page gated by `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED`
|
||||
6. ✅ Update `.env.example`
|
||||
7. ⏳ Write `auth.test.ts` with env-var gating tests
|
||||
8. ⏳ Quality gates: typecheck + lint + format:check + test
|
||||
9. ⏳ Commit + push + PR
|
||||
|
||||
## Decisions
|
||||
|
||||
- **WorkOS**: Uses explicit `authorizationUrl`, `tokenUrl`, `userInfoUrl` (no discovery endpoint available)
|
||||
- **Keycloak**: Uses `discoveryUrl` pattern (`{URL}/realms/{REALM}/.well-known/openid-configuration`)
|
||||
- **UI gating**: Login page uses `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED` feature flags (safer than exposing secret env var names client-side)
|
||||
- **Refactor**: Authentik moved into same `oauthProviders[]` array pattern — cleaner, more extensible
|
||||
- **Feature flag design**: `NEXT_PUBLIC_*` flags are opt-in alongside credentials (prevents accidental button render when creds not set)
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `ASSUMPTION:` WorkOS OIDC discovery URL is not publicly documented; using direct URL pattern from WorkOS SSO docs.
|
||||
- `ASSUMPTION:` `NEXT_PUBLIC_WORKOS_ENABLED=true` must be explicitly set — this is intentional (credential presence alone doesn't enable the button since NEXT_PUBLIC vars are baked at build time).
|
||||
|
||||
## Tests
|
||||
|
||||
- `auth.test.ts`: Mocks betterAuth stack, verifies WorkOS included/excluded based on env var
|
||||
- `auth.test.ts`: Verifies Keycloak discoveryUrl constructed correctly
|
||||
|
||||
## Quality Gate Results
|
||||
|
||||
| Gate | Status |
|
||||
| ------------------- | -------------------------------------------- |
|
||||
| typecheck | ✅ 32/32 cached green |
|
||||
| lint | ✅ 18/18 cached green |
|
||||
| format:check | ✅ All matched files use Prettier code style |
|
||||
| test (@mosaic/auth) | ✅ 8/8 tests passed |
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm typecheck` — FULL TURBO, 32 tasks successful
|
||||
- `pnpm lint` — FULL TURBO, 18 tasks successful
|
||||
- `pnpm format:check` — All matched files use Prettier code style!
|
||||
- `pnpm --filter=@mosaic/auth test` — 8 tests passed, 0 failed
|
||||
161
packages/auth/src/auth.test.ts
Normal file
161
packages/auth/src/auth.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { buildOAuthProviders } from './auth.js';
|
||||
|
||||
describe('buildOAuthProviders', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
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['WORKOS_ISSUER'];
|
||||
delete process.env['KEYCLOAK_CLIENT_ID'];
|
||||
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||
delete process.env['KEYCLOAK_ISSUER'];
|
||||
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 all required env vars are set', () => {
|
||||
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
||||
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const workos = providers.find((p) => p.providerId === 'workos');
|
||||
|
||||
expect(workos).toBeDefined();
|
||||
expect(workos?.clientId).toBe('client_test123');
|
||||
expect(workos?.issuer).toBe('https://example.authkit.app');
|
||||
expect(workos?.discoveryUrl).toBe(
|
||||
'https://example.authkit.app/.well-known/openid-configuration',
|
||||
);
|
||||
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||
});
|
||||
|
||||
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 workos = providers.find((p) => p.providerId === 'workos');
|
||||
expect(workos).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keycloak', () => {
|
||||
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
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';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
|
||||
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
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 keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
expect(keycloakProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentik', () => {
|
||||
it('includes authentik provider when all required env vars are 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?.issuer).toBe('https://auth.example.com/application/o/mosaic');
|
||||
expect(authentik?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
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 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['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_SECRET'] = 'w-secret';
|
||||
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
|
||||
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||
import { admin } from 'better-auth/plugins';
|
||||
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
export interface AuthConfig {
|
||||
@@ -9,35 +10,118 @@ 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 authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
const plugins = authentikClientId
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'authentik',
|
||||
clientId: authentikClientId,
|
||||
clientSecret: authentikClientSecret ?? '',
|
||||
discoveryUrl: authentikIssuer
|
||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
authorizationUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/authorize/`
|
||||
: undefined,
|
||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
||||
userInfoUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/userinfo/`
|
||||
: undefined,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
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) {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user