Compare commits
3 Commits
77ba13b41b
...
70d7ea3be4
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d7ea3be4 | |||
| c54ee4c8d1 | |||
| ccdad96cbe |
@@ -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
|
||||||
|
|||||||
25
AGENTS.md
25
AGENTS.md
@@ -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`
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
# 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 |
|
||||||
@@ -93,8 +89,8 @@
|
|||||||
| 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 |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
|
|
||||||
providers.push(
|
|
||||||
keycloak({
|
|
||||||
clientId: keycloakClientId,
|
clientId: keycloakClientId,
|
||||||
clientSecret: keycloakClientSecret,
|
clientSecret: process.env['KEYCLOAK_CLIENT_SECRET'] ?? '',
|
||||||
issuer: keycloakIssuer,
|
discoveryUrl: `${keycloakUrl}/realms/${keycloakRealm}/.well-known/openid-configuration`,
|
||||||
scopes: ['openid', 'email', 'profile'],
|
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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user