Compare commits

..

8 Commits

Author SHA1 Message Date
77ba13b41b feat(auth): add WorkOS and Keycloak SSO providers
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-19 20:30:00 -05:00
307bb427d6 chore: add P8-001 scratchpad
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:18:59 -05:00
b89503fa8c chore: fix prettier formatting on scratchpad files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 20:18:59 -05:00
254da35300 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-19 20:18:59 -05:00
25f880416a Merge pull request 'docs: add TASKS.md agent-column schema to AGENTS.md' (#214) from chore/tasks-schema-agents-md into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-20 01:10:56 +00:00
1138148543 docs: add TASKS.md agent-column schema to AGENTS.md (canonical reference)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-03-19 20:10:45 -05:00
4b70b603b3 Merge pull request 'chore: add agent model column to TASKS.md' (#213) from chore/tasks-agent-column into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2026-03-20 01:08:29 +00:00
2e7711fe65 chore: add agent model column to TASKS.md schema
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Adds 'agent' column to specify which model should execute each task.
Values: codex | sonnet | haiku | glm-5 | opus | — (auto)
Pipeline crons use this to spawn the cheapest capable model per task.
Phase 8 tasks assigned: P8-001/002/003=codex, P8-004=haiku
2026-03-19 20:08:12 -05:00
10 changed files with 575 additions and 175 deletions

View File

@@ -131,11 +131,13 @@ 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,3 +53,28 @@ 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,18 +1,17 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { signIn } from '@/lib/auth-client';
const workosEnabled = process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
const keycloakEnabled = process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
const hasSsoProviders = workosEnabled || keycloakEnabled;
import { getEnabledSsoProviders } from '@/lib/sso-providers';
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const ssoProviders = getEnabledSsoProviders();
const hasSsoProviders = ssoProviders.length > 0;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
@@ -34,16 +33,6 @@ 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>
@@ -60,26 +49,15 @@ export default function LoginPage(): React.ReactElement {
{hasSsoProviders && (
<div className="mt-6 space-y-3">
{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"
{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"
>
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>
)}
{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>

View 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>
);
}

View 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();
});
});

View 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
View 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.

View File

@@ -1,96 +1,100 @@
# 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 | 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 |
| 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-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
| 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-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-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-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
| 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-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
| 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 |
| 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-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-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-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-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-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-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
| 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 |
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
| 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-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
| 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-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-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 |
| 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-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-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
| 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-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-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-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-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-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-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 |
| 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 |
| 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-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 |
| 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-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-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-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-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-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-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 | 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 |
| id | status | agent | 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 |
| 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-006 | done | Phase 0 | OTEL foundation — OpenTelemetry SDK setup | #65 | #6 |
| 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-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-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-004 | done | Phase 1 | @mosaic/queue — migrate from v0 | #71 | #13 |
| 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-007 | done | Phase 1 | WebSocket server — chat streaming | #61 | #16 |
| 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 |
| 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-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-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-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-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-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-006 | done | Phase 3 | Settings — provider config, profile, integrations | #88 | #31 |
| 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 |
| P4-001 | done | Phase 4 | @mosaic/memory — preference + insight stores | — | #34 |
| 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-004 | done | Phase 4 | Summarization pipeline — Haiku-tier LLM + cron | — | #37 |
| P4-005 | done | Phase 4 | Memory integration — inject into agent sessions | — | #38 |
| P4-006 | done | Phase 4 | Skill management — catalog, install, config | — | #39 |
| 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-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-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 |
| 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-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-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
| 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-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-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-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-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-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-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 |
| 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 |
| 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-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 |
| 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-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-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-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-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-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-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 |
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |

View File

@@ -6,14 +6,15 @@ 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'];
});
@@ -28,22 +29,32 @@ describe('buildOAuthProviders', () => {
});
describe('WorkOS', () => {
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
it('includes workos provider when all required env vars are set', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
process.env['WORKOS_CLIENT_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?.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?.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('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
it('throws when WorkOS is partially configured', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
expect(() => buildOAuthProviders()).toThrow(
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
);
});
it('excludes workos provider when WorkOS is not configured', () => {
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeUndefined();
@@ -51,47 +62,78 @@ describe('buildOAuthProviders', () => {
});
describe('Keycloak', () => {
it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => {
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_URL'] = 'https://auth.example.com';
process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeDefined();
expect(keycloakProvider?.clientId).toBe('mosaic');
expect(keycloakProvider?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_URL'] = 'https://auth.example.com/';
process.env['KEYCLOAK_REALM'] = 'myrealm';
const providers = buildOAuthProviders();
const keycloak = providers.find((p) => p.providerId === 'keycloak');
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeDefined();
expect(keycloak?.clientId).toBe('mosaic');
expect(keycloak?.discoveryUrl).toBe(
expect(keycloakProvider?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
expect(keycloak?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('excludes keycloak provider when KEYCLOAK_CLIENT_ID is not set', () => {
it('throws when Keycloak is partially configured', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
expect(() => buildOAuthProviders()).toThrow(
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
);
});
it('excludes keycloak provider when Keycloak is not configured', () => {
const providers = buildOAuthProviders();
const keycloak = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeUndefined();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeUndefined();
});
});
describe('Authentik', () => {
it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => {
it('includes authentik provider when all required env vars are set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
process.env['AUTHENTIK_CLIENT_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('excludes authentik provider when AUTHENTIK_CLIENT_ID is not set', () => {
it('throws when Authentik is partially configured', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
expect(() => buildOAuthProviders()).toThrow(
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
);
});
it('excludes authentik provider when Authentik is not configured', () => {
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeUndefined();
@@ -100,10 +142,14 @@ 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_URL'] = 'https://kc.example.com';
process.env['KEYCLOAK_REALM'] = 'test';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/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, genericOAuth } from 'better-auth/plugins';
import type { GenericOAuthConfig } from 'better-auth/plugins';
import { admin } from 'better-auth/plugins';
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import type { Db } from '@mosaic/db';
export interface AuthConfig {
@@ -14,52 +14,108 @@ export interface AuthConfig {
export function buildOAuthProviders(): GenericOAuthConfig[] {
const providers: GenericOAuthConfig[] = [];
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
if (authentikClientId) {
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
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) {
providers.push({
providerId: 'authentik',
clientId: authentikClientId,
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,
clientSecret: authentikClientSecret,
issuer: authentikIssuer,
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
});
}
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
const workosClientId = process.env['WORKOS_CLIENT_ID'];
if (workosClientId) {
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
assertOptionalProviderConfig('WorkOS SSO', {
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
values: [workosIssuer, workosClientId, workosClientSecret],
});
if (workosIssuer && workosClientId && workosClientSecret) {
providers.push({
providerId: 'workos',
clientId: workosClientId,
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',
clientSecret: workosClientSecret,
issuer: workosIssuer,
discoveryUrl: buildDiscoveryUrl(workosIssuer),
scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
});
}
const keycloakIssuer = resolveKeycloakIssuer();
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
if (keycloakClientId) {
const keycloakUrl = process.env['KEYCLOAK_URL'] ?? '';
const keycloakRealm = process.env['KEYCLOAK_REALM'] ?? '';
providers.push({
providerId: 'keycloak',
clientId: keycloakClientId,
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '',
discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`,
scopes: ['openid', 'email', 'profile'],
});
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;