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
|
# 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_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||||
# AUTHENTIK_CLIENT_ID=
|
# AUTHENTIK_CLIENT_ID=
|
||||||
# AUTHENTIK_CLIENT_SECRET=
|
# 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';
|
'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';
|
||||||
|
|
||||||
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();
|
||||||
@@ -44,7 +47,26 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</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>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
Email
|
Email
|
||||||
|
|||||||
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 { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||||
plugins: [adminClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
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 { 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 { 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 {
|
||||||
@@ -9,35 +10,118 @@ export interface AuthConfig {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuth(config: AuthConfig) {
|
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||||
const { db, baseURL, secret } = config;
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
const providers: GenericOAuthConfig[] = [];
|
||||||
|
|
||||||
|
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
||||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||||
const plugins = authentikClientId
|
assertOptionalProviderConfig('Authentik SSO', {
|
||||||
? [
|
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
|
||||||
genericOAuth({
|
values: [authentikIssuer, authentikClientId, authentikClientSecret],
|
||||||
config: [
|
});
|
||||||
{
|
|
||||||
providerId: 'authentik',
|
if (authentikIssuer && authentikClientId && authentikClientSecret) {
|
||||||
clientId: authentikClientId,
|
providers.push({
|
||||||
clientSecret: authentikClientSecret ?? '',
|
providerId: 'authentik',
|
||||||
discoveryUrl: authentikIssuer
|
clientId: authentikClientId,
|
||||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
clientSecret: authentikClientSecret,
|
||||||
: undefined,
|
issuer: authentikIssuer,
|
||||||
authorizationUrl: authentikIssuer
|
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
|
||||||
? `${authentikIssuer}/application/o/authorize/`
|
scopes: ['openid', 'email', 'profile'],
|
||||||
: undefined,
|
requireIssuerValidation: true,
|
||||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
});
|
||||||
userInfoUrl: authentikIssuer
|
}
|
||||||
? `${authentikIssuer}/application/o/userinfo/`
|
|
||||||
: undefined,
|
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
|
||||||
scopes: ['openid', 'email', 'profile'],
|
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],
|
||||||
: undefined;
|
});
|
||||||
|
|
||||||
|
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 corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user