Compare commits

..

3 Commits

Author SHA1 Message Date
70d7ea3be4 chore: add P8-001 scratchpad
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:18:48 -05:00
c54ee4c8d1 chore: fix prettier formatting on scratchpad files
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:17:57 -05:00
ccdad96cbe feat(auth): add WorkOS + Keycloak SSO providers (P8-001)
- Refactor auth.ts to build OAuth providers array dynamically; extract
  buildOAuthProviders() for unit-testability
- Add WorkOS provider (WORKOS_CLIENT_ID/SECRET/REDIRECT_URI env vars)
- Add Keycloak provider with realm-scoped OIDC discovery
  (KEYCLOAK_URL/REALM/CLIENT_ID/CLIENT_SECRET env vars)
- Add genericOAuthClient plugin to web auth-client for signIn.oauth2()
- Add WorkOS + Keycloak SSO buttons to login page (NEXT_PUBLIC_*_ENABLED
  feature flags control visibility)
- Update .env.example with SSO provider stanzas
- Add 8 unit tests covering all provider inclusion/exclusion paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:17:11 -05:00
10 changed files with 175 additions and 575 deletions

View File

@@ -131,13 +131,11 @@ OTEL_SERVICE_NAME=mosaic-gateway
# 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_...
# WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback/workos
# --- 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

View File

@@ -53,28 +53,3 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks
## docs/TASKS.md — Schema (CANONICAL)
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
| Value | When to use | Budget |
| -------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
**Full schema:**
```
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
```
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
- `agent`: model value from table above (set before spawning)
- `estimate`: token budget e.g. `8K`, `25K`

View File

@@ -1,17 +1,18 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
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 {
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();
@@ -33,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
router.push('/chat');
}
async function handleSsoSignIn(providerId: string): Promise<void> {
setError(null);
setLoading(true);
const result = await signIn.oauth2({ providerId, callbackURL: '/chat' });
if (result?.error) {
setError(result.error.message ?? 'SSO sign in failed');
setLoading(false);
}
}
return (
<div>
<h1 className="text-2xl font-semibold">Sign in</h1>
@@ -49,15 +60,26 @@ export default function LoginPage(): React.ReactElement {
{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"
{workosEnabled && (
<button
type="button"
disabled={loading}
onClick={() => handleSsoSignIn('workos')}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{provider.buttonLabel}
</Link>
))}
Continue with WorkOS
</button>
)}
{keycloakEnabled && (
<button
type="button"
disabled={loading}
onClick={() => handleSsoSignIn('keycloak')}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
Continue with Keycloak
</button>
)}
<div className="relative flex items-center">
<div className="flex-1 border-t border-surface-border" />
<span className="mx-3 text-xs text-text-muted">or</span>

View File

@@ -1,77 +0,0 @@
'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>
);
}

View File

@@ -1,48 +0,0 @@
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();
});
});

View File

@@ -1,53 +0,0 @@
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';
}
}

View File

@@ -1,111 +0,0 @@
# 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.

View File

@@ -1,13 +1,9 @@
# Tasks — MVP
> Single-writer: orchestrator only. Workers read but never modify.
>
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
> Pipeline crons pick the cheapest capable model. Override with a specific value when a task genuinely needs it.
> Examples: `opus` for major architecture decisions, `codex` for pure coding, `haiku` for review/verify gates, `glm-5` for cost-sensitive coding.
| id | status | agent | milestone | description | pr | notes |
| ------ | ----------- | ------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------- | ----- |
| id | status | milestone | description | pr | notes |
| ------ | ----------- | --------- | -------------------------------------------------------------------------------------------------- | ---- | ------------- |
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
@@ -93,8 +89,8 @@
| P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 |
| P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
| P8-019 | done | Phase 8 | Verify Platform Architecture — integration + E2E verification | #185 | #172 |
| P8-001 | not-started | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
| P8-002 | not-started | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P8-003 | not-started | codex | Phase 8 | Performance optimization | — | #56 |
| P8-004 | not-started | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
| P8-001 | not-started | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
| P8-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
| P8-004 | not-started | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |

View File

@@ -6,15 +6,14 @@ describe('buildOAuthProviders', () => {
beforeEach(() => {
process.env = { ...originalEnv };
// Clear all SSO-related env vars before each test
delete process.env['AUTHENTIK_CLIENT_ID'];
delete process.env['AUTHENTIK_CLIENT_SECRET'];
delete process.env['AUTHENTIK_ISSUER'];
delete process.env['WORKOS_CLIENT_ID'];
delete process.env['WORKOS_CLIENT_SECRET'];
delete process.env['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'];
});
@@ -29,32 +28,22 @@ describe('buildOAuthProviders', () => {
});
describe('WorkOS', () => {
it('includes workos provider when all required env vars are set', () => {
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
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?.authorizationUrl).toBe('https://api.workos.com/sso/authorize');
expect(workos?.tokenUrl).toBe('https://api.workos.com/sso/token');
expect(workos?.userInfoUrl).toBe('https://api.workos.com/sso/profile');
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('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', () => {
it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeUndefined();
@@ -62,78 +51,47 @@ describe('buildOAuthProviders', () => {
});
describe('Keycloak', () => {
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_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_URL'] = 'https://auth.example.com';
process.env['KEYCLOAK_REALM'] = 'myrealm';
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
const keycloak = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider?.discoveryUrl).toBe(
expect(keycloak).toBeDefined();
expect(keycloak?.clientId).toBe('mosaic');
expect(keycloak?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
expect(keycloak?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('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', () => {
it('excludes keycloak provider when KEYCLOAK_CLIENT_ID is not set', () => {
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeUndefined();
const keycloak = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeUndefined();
});
});
describe('Authentik', () => {
it('includes authentik provider when all required env vars are set', () => {
it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/';
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', () => {
it('excludes authentik provider when AUTHENTIK_CLIENT_ID is not set', () => {
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeUndefined();
@@ -142,14 +100,10 @@ describe('buildOAuthProviders', () => {
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';
process.env['KEYCLOAK_URL'] = 'https://kc.example.com';
process.env['KEYCLOAK_REALM'] = 'test';
const providers = buildOAuthProviders();
expect(providers).toHaveLength(3);

View File

@@ -1,7 +1,7 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins';
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import { admin, genericOAuth } from 'better-auth/plugins';
import type { GenericOAuthConfig } from 'better-auth/plugins';
import type { Db } from '@mosaic/db';
export interface AuthConfig {
@@ -14,108 +14,52 @@ export interface AuthConfig {
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'];
assertOptionalProviderConfig('Authentik SSO', {
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
values: [authentikIssuer, authentikClientId, authentikClientSecret],
});
if (authentikIssuer && authentikClientId && authentikClientSecret) {
if (authentikClientId) {
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
providers.push({
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: authentikClientSecret,
issuer: authentikIssuer,
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
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'],
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) {
if (workosClientId) {
providers.push({
providerId: 'workos',
clientId: workosClientId,
clientSecret: workosClientSecret,
issuer: workosIssuer,
discoveryUrl: buildDiscoveryUrl(workosIssuer),
clientSecret: process.env['WORKOS_CLIENT_SECRET'] ?? '',
authorizationUrl: 'https://api.workos.com/sso/authorize',
tokenUrl: 'https://api.workos.com/sso/token',
userInfoUrl: 'https://api.workos.com/sso/profile',
scopes: ['openid', 'email', 'profile'],
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({
if (keycloakClientId) {
const keycloakUrl = process.env['KEYCLOAK_URL'] ?? '';
const keycloakRealm = process.env['KEYCLOAK_REALM'] ?? '';
providers.push({
providerId: 'keycloak',
clientId: keycloakClientId,
clientSecret: keycloakClientSecret,
issuer: keycloakIssuer,
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '',
discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`,
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;