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= # AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) --- # --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_... # WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_... # WORKOS_CLIENT_SECRET=sk_live_...
# WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback/workos
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) --- # --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com # KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master # KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic # KEYCLOAK_CLIENT_ID=mosaic

View File

@@ -53,28 +53,3 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
- ESM everywhere (`"type": "module"`, `.js` extensions in imports) - ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs - NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks - 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'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from '@/lib/auth-client'; import { signIn } from '@/lib/auth-client';
import { getEnabledSsoProviders } from '@/lib/sso-providers';
const workosEnabled = process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
const keycloakEnabled = process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
const hasSsoProviders = workosEnabled || keycloakEnabled;
export default function LoginPage(): React.ReactElement { export default function LoginPage(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ssoProviders = getEnabledSsoProviders();
const hasSsoProviders = ssoProviders.length > 0;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> { async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault(); e.preventDefault();
@@ -33,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
router.push('/chat'); router.push('/chat');
} }
async function handleSsoSignIn(providerId: string): Promise<void> {
setError(null);
setLoading(true);
const result = await signIn.oauth2({ providerId, callbackURL: '/chat' });
if (result?.error) {
setError(result.error.message ?? 'SSO sign in failed');
setLoading(false);
}
}
return ( return (
<div> <div>
<h1 className="text-2xl font-semibold">Sign in</h1> <h1 className="text-2xl font-semibold">Sign in</h1>
@@ -49,15 +60,26 @@ export default function LoginPage(): React.ReactElement {
{hasSsoProviders && ( {hasSsoProviders && (
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">
{ssoProviders.map((provider) => ( {workosEnabled && (
<Link <button
key={provider.id} type="button"
href={provider.href} disabled={loading}
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" 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} Continue with WorkOS
</Link> </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="relative flex items-center">
<div className="flex-1 border-t border-surface-border" /> <div className="flex-1 border-t border-surface-border" />
<span className="mx-3 text-xs text-text-muted">or</span> <span className="mx-3 text-xs text-text-muted">or</span>

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,100 +1,96 @@
# Tasks — MVP # Tasks — MVP
> Single-writer: orchestrator only. Workers read but never modify. > 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-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 | | 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 | | P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
| P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 | | P0-004 | done | Phase 0 | @mosaic/auth — BetterAuth email/password setup | #68 | #4 |
| P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 | | P0-005 | done | Phase 0 | Docker Compose — PG 17, Valkey 8, SigNoz | #65 | #5 |
| P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 | | P0-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
| P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 | | P0-007 | done | Phase 0 | CI pipeline — Woodpecker config | #69 | #7 |
| P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 | | P0-008 | done | Phase 0 | Project docs — AGENTS.md, CLAUDE.md, README | #69 | #8 |
| P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 | | P0-009 | done | Phase 0 | Verify Phase 0 — CI green, all packages build | #70 | #9 |
| P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 | | P1-001 | done | Phase 1 | apps/gateway scaffold — NestJS + Fastify adapter | #61 | #10 |
| P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 | | P1-002 | done | Phase 1 | Auth middleware — BetterAuth session validation | #71 | #11 |
| P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 | | P1-003 | done | Phase 1 | @mosaic/brain — migrate from v0, PG backend | #71 | #12 |
| P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 | | P1-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
| P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 | | P1-005 | done | Phase 1 | Gateway routes — conversations CRUD + messages | #72 | #14 |
| P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 | | P1-006 | done | Phase 1 | Gateway routes — tasks, projects, missions CRUD | #72 | #15 |
| P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 | | P1-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
| P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 | | P1-008 | done | Phase 1 | Basic agent dispatch — single provider | #61 | #17 |
| P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 | | P1-009 | done | Phase 1 | Verify Phase 1 — gateway functional, API tested | #73 | #18 |
| P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 | | P2-001 | done | Phase 2 | @mosaic/agent — Pi SDK integration + agent pool | #61 | #19 |
| P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 | | P2-002 | done | Phase 2 | Multi-provider support — Anthropic + Ollama | #74 | #20 |
| P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 | | P2-003 | done | Phase 2 | Agent routing engine — cost/capability matrix | #75 | #21 |
| P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 | | P2-004 | done | Phase 2 | Tool registration — brain, queue, memory tools | #76 | #22 |
| P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 | | P2-005 | done | Phase 2 | @mosaic/coord — migrate from v0, gateway integration | #77 | #23 |
| P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 | | P2-006 | done | Phase 2 | Agent session management — tmux + monitoring | #78 | #24 |
| P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 | | P2-007 | done | Phase 2 | Verify Phase 2 — multi-provider routing works | #79 | #25 |
| P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 | | P3-001 | done | Phase 3 | apps/web scaffold — Next.js 16 + BetterAuth + Tailwind | #82 | #26 |
| P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 | | P3-002 | done | Phase 3 | Auth pages — login, registration, SSO redirect | #83 | #27 |
| P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 | | P3-003 | done | Phase 3 | Chat UI — conversations, messages, streaming | #84 | #28 |
| P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 | | P3-004 | done | Phase 3 | Task management — list view + kanban board | #86 | #29 |
| P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 | | P3-005 | done | Phase 3 | Project & mission views — dashboard + PRD viewer | #87 | #30 |
| P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 | | P3-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
| P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 | | P3-007 | done | Phase 3 | Admin panel — user management, RBAC | #89 | #32 |
| P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 | | P3-008 | done | Phase 3 | Verify Phase 3 — web dashboard functional E2E | — | #33 |
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 | | P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
| P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 | | P4-002 | done | Phase 4 | Semantic search — pgvector embeddings + search API | — | #35 |
| P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 | | P4-003 | done | Phase 4 | @mosaic/log — log ingest, parsing, tiered storage | — | #36 |
| P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 | | P4-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 | | P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 | | P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
| P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 | | P4-007 | done | Phase 4 | Verify Phase 4 — memory + log pipeline working | — | #40 |
| P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 | | P5-001 | done | Phase 5 | Plugin host — gateway plugin loading + channel interface | — | #41 |
| P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 | | P5-002 | done | Phase 5 | @mosaic/discord-plugin — Discord bot + channel plugin | #61 | #42 |
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 | | P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 | | P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 | | P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 | | P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 | | P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 | | P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 | | P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 | | P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 | | P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done | | P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done | | P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done | | P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done | | P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done | | P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done | | P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
| P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done | | P7-012 | done | Phase 7 | Web provider management UI — add, configure, test LLM providers | #142 | #123 W4 done |
| P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done | | P7-017 | done | Phase 7 | Agent skill invocation — load and execute skills from catalog | #143 | #128 W4 done |
| P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done | | P7-013 | done | Phase 7 | Web settings persistence — profile, preferences save to DB | #145 | #124 W5 done |
| P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done | | P7-018 | done | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | #144 | #129 W5 done |
| P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done | | P7-014 | done | Phase 7 | Web admin panel — user CRUD, role assignment, system health | #150 | #125 W6 done |
| P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done | | P7-019 | done | Phase 7 | CLI session management — list, resume, destroy sessions | #146 | #130 W6 done |
| P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done | | P7-020 | done | Phase 7 | Coord DB migration — project-scoped missions, multi-tenant RBAC | #149 | #131 W7 done |
| FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done | | FIX-02 | done | Backlog | TUI agent:end — fix React state updater side-effect | #147 | #133 W8 done |
| FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done | | FIX-03 | done | Backlog | Agent session — cwd sandbox, system prompt, tool restrictions | #148 | #134 W8 done |
| P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done | | P7-004 | done | Phase 7 | E2E test suite — Playwright critical paths | #152 | #55 W9 done |
| P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done | | P7-006 | done | Phase 7 | Documentation — user guide, admin guide, dev guide | #151 | #57 W9 done |
| P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done | | P7-007 | done | Phase 7 | Bare-metal deployment docs + .env.example | #153 | #58 W9 done |
| P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done | | P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done |
| P8-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | | | P8-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | |
| P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | | | P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | |
| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 | | P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 |
| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 | | P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 |
| P8-009 | done | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | #176 | #162 | | P8-009 | done | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | #176 | #162 |
| P8-010 | done | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | #178 | #163 | | P8-010 | done | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | #178 | #163 |
| P8-011 | done | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | #180 | #164 | | P8-011 | done | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | #180 | #164 |
| P8-012 | done | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | #181 | #165 | | P8-012 | done | Phase 8 | Gateway Phase 4 — /agent, /provider (URL+clipboard), /mission, /prdy, /tools commands | #181 | #165 |
| P8-013 | done | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | #182 | #166 | | P8-013 | done | Phase 8 | Gateway Phase 5 — MosaicPlugin lifecycle, ReloadService, hot reload, system:reload TUI | #182 | #166 |
| P8-014 | done | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | #179 | #167 | | P8-014 | done | Phase 8 | Gateway Phase 6 — SessionGCService (all tiers), /gc command, cron integration | #179 | #167 |
| P8-015 | done | Phase 8 | Gateway Phase 7 — WorkspaceService, ProjectBootstrapService, teams project ownership | #183 | #168 | | P8-015 | done | Phase 8 | Gateway Phase 7 — WorkspaceService, ProjectBootstrapService, teams project ownership | #183 | #168 |
| P8-016 | done | Phase 8 | Security — file/git/shell tool strict path hardening, sandbox escape prevention | #177 | #169 | | P8-016 | done | Phase 8 | Security — file/git/shell tool strict path hardening, sandbox escape prevention | #177 | #169 |
| P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 | | 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-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-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-001 | not-started | 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-002 | not-started | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P8-003 | not-started | codex | Phase 8 | Performance optimization | — | #56 | | P8-003 | not-started | Phase 8 | Performance optimization | — | #56 |
| P8-004 | not-started | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 | | 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 | | FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |

View File

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

View File

@@ -1,7 +1,7 @@
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins'; import { admin, genericOAuth } from 'better-auth/plugins';
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth'; import type { GenericOAuthConfig } from 'better-auth/plugins';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaic/db';
export interface AuthConfig { export interface AuthConfig {
@@ -14,108 +14,52 @@ export interface AuthConfig {
export function buildOAuthProviders(): GenericOAuthConfig[] { export function buildOAuthProviders(): GenericOAuthConfig[] {
const providers: GenericOAuthConfig[] = []; const providers: GenericOAuthConfig[] = [];
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID']; const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET']; if (authentikClientId) {
assertOptionalProviderConfig('Authentik SSO', { const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
values: [authentikIssuer, authentikClientId, authentikClientSecret],
});
if (authentikIssuer && authentikClientId && authentikClientSecret) {
providers.push({ providers.push({
providerId: 'authentik', providerId: 'authentik',
clientId: authentikClientId, clientId: authentikClientId,
clientSecret: authentikClientSecret, clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
issuer: authentikIssuer, discoveryUrl: authentikIssuer
discoveryUrl: buildDiscoveryUrl(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'], scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
}); });
} }
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
const workosClientId = process.env['WORKOS_CLIENT_ID']; const workosClientId = process.env['WORKOS_CLIENT_ID'];
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET']; if (workosClientId) {
assertOptionalProviderConfig('WorkOS SSO', {
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
values: [workosIssuer, workosClientId, workosClientSecret],
});
if (workosIssuer && workosClientId && workosClientSecret) {
providers.push({ providers.push({
providerId: 'workos', providerId: 'workos',
clientId: workosClientId, clientId: workosClientId,
clientSecret: workosClientSecret, clientSecret: process.env['WORKOS_CLIENT_SECRET'] ?? '',
issuer: workosIssuer, authorizationUrl: 'https://api.workos.com/sso/authorize',
discoveryUrl: buildDiscoveryUrl(workosIssuer), tokenUrl: 'https://api.workos.com/sso/token',
userInfoUrl: 'https://api.workos.com/sso/profile',
scopes: ['openid', 'email', 'profile'], scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
}); });
} }
const keycloakIssuer = resolveKeycloakIssuer();
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID']; const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET']; if (keycloakClientId) {
assertOptionalProviderConfig('Keycloak SSO', { const keycloakUrl = process.env['KEYCLOAK_URL'] ?? '';
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'], const keycloakRealm = process.env['KEYCLOAK_REALM'] ?? '';
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer], providers.push({
}); providerId: 'keycloak',
clientId: keycloakClientId,
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) { clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '',
providers.push( discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`,
keycloak({ scopes: ['openid', 'email', 'profile'],
clientId: keycloakClientId, });
clientSecret: keycloakClientSecret,
issuer: keycloakIssuer,
scopes: ['openid', 'email', 'profile'],
}),
);
} }
return providers; return providers;
} }
function resolveKeycloakIssuer(): string | undefined {
const issuer = normalizeIssuer(process.env['KEYCLOAK_ISSUER']);
if (issuer) {
return issuer;
}
const baseUrl = normalizeIssuer(process.env['KEYCLOAK_URL']);
const realm = process.env['KEYCLOAK_REALM']?.trim();
if (!baseUrl || !realm) {
return undefined;
}
return `${baseUrl}/realms/${realm}`;
}
function buildDiscoveryUrl(issuer: string): string {
return `${issuer}/.well-known/openid-configuration`;
}
function normalizeIssuer(value: string | undefined): string | undefined {
return value?.trim().replace(/\/+$/, '') || undefined;
}
function assertOptionalProviderConfig(
providerName: string,
config: { required: string[]; values: Array<string | undefined> },
): void {
const hasAnyValue = config.values.some((value) => Boolean(value?.trim()));
const hasAllValues = config.values.every((value) => Boolean(value?.trim()));
if (!hasAnyValue || hasAllValues) {
return;
}
throw new Error(
`@mosaic/auth: ${providerName} requires ${config.required.join(', ')}. Set them in your config or via the listed environment variables.`,
);
}
export function createAuth(config: AuthConfig) { export function createAuth(config: AuthConfig) {
const { db, baseURL, secret } = config; const { db, baseURL, secret } = config;