Compare commits
3 Commits
fix/build-
...
70d7ea3be4
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d7ea3be4 | |||
| c54ee4c8d1 | |||
| ccdad96cbe |
19
.env.example
19
.env.example
@@ -123,7 +123,24 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
|||||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||||
|
|
||||||
|
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||||
# AUTHENTIK_CLIENT_ID=
|
# AUTHENTIK_CLIENT_ID=
|
||||||
# AUTHENTIK_CLIENT_SECRET=
|
# AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||||
|
# WORKOS_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_URL=https://auth.example.com
|
||||||
|
# KEYCLOAK_REALM=master
|
||||||
|
# KEYCLOAK_CLIENT_ID=mosaic
|
||||||
|
# KEYCLOAK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
||||||
|
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||||
|
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn } from '@/lib/auth-client';
|
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;
|
||||||
|
|
||||||
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);
|
||||||
@@ -30,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>
|
||||||
@@ -44,7 +58,37 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
{hasSsoProviders && (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
Continue with WorkOS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{keycloakEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleSsoSignIn('keycloak')}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continue with Keycloak
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
|
<span className="mx-3 text-xs text-text-muted">or</span>
|
||||||
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
Email
|
Email
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||||
plugins: [adminClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
export const { useSession, signIn, signUp, signOut } = authClient;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# BUG-CLI Scratchpad
|
# BUG-CLI Scratchpad
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
- #192: Ctrl+T leaks 't' into input
|
- #192: Ctrl+T leaks 't' into input
|
||||||
- #193: Duplicate React keys in CommandAutocomplete
|
- #193: Duplicate React keys in CommandAutocomplete
|
||||||
- #194: /provider login false clipboard claim
|
- #194: /provider login false clipboard claim
|
||||||
@@ -12,28 +14,33 @@ Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
|||||||
## Plan and Fixes
|
## Plan and Fixes
|
||||||
|
|
||||||
### Bug #192 — Ctrl+T character leak
|
### Bug #192 — Ctrl+T character leak
|
||||||
|
|
||||||
- Location: `packages/cli/src/tui/app.tsx`
|
- Location: `packages/cli/src/tui/app.tsx`
|
||||||
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
||||||
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||||
leaked character and return early.
|
leaked character and return early.
|
||||||
|
|
||||||
### Bug #193 — Duplicate React keys
|
### Bug #193 — Duplicate React keys
|
||||||
|
|
||||||
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||||
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
||||||
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||||
that share a name with local commands. Local commands take precedence.
|
that share a name with local commands. Local commands take precedence.
|
||||||
|
|
||||||
### Bug #194 — False clipboard claim
|
### Bug #194 — False clipboard claim
|
||||||
|
|
||||||
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||||
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||||
|
|
||||||
### Bug #199 — Hardcoded version "0.0.0"
|
### Bug #199 — Hardcoded version "0.0.0"
|
||||||
|
|
||||||
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
||||||
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
||||||
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
||||||
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
- CLI typecheck: PASSED
|
- CLI typecheck: PASSED
|
||||||
- CLI lint: PASSED
|
- CLI lint: PASSED
|
||||||
- Prettier format:check: PASSED
|
- Prettier format:check: PASSED
|
||||||
|
|||||||
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
65
docs/scratchpads/p8-001-sso-providers.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# P8-001 — WorkOS + Keycloak SSO Providers
|
||||||
|
|
||||||
|
**Branch:** feat/p8-001-sso-providers
|
||||||
|
**Started:** 2026-03-18
|
||||||
|
**Mode:** Delivery
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add WorkOS and Keycloak as optional SSO providers to the BetterAuth configuration, following the existing Authentik pattern.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `packages/auth/src/auth.ts` | Refactor provider array, add WorkOS + Keycloak conditional registration |
|
||||||
|
| `apps/web/src/lib/auth-client.ts` | Add `genericOAuthClient()` plugin |
|
||||||
|
| `apps/web/src/app/(auth)/login/page.tsx` | WorkOS + Keycloak SSO buttons gated by `NEXT_PUBLIC_*` env vars |
|
||||||
|
| `.env.example` | Document WorkOS + Keycloak env vars |
|
||||||
|
| `packages/auth/src/auth.test.ts` | Unit tests verifying env-var gating |
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. ✅ Refactor `createAuth` to build `oauthProviders[]` conditionally
|
||||||
|
2. ✅ Add WorkOS provider (explicit URLs, no discovery)
|
||||||
|
3. ✅ Add Keycloak provider (discoveryUrl pattern)
|
||||||
|
4. ✅ Add `genericOAuthClient()` to auth-client.ts
|
||||||
|
5. ✅ Add SSO buttons to login page gated by `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED`
|
||||||
|
6. ✅ Update `.env.example`
|
||||||
|
7. ⏳ Write `auth.test.ts` with env-var gating tests
|
||||||
|
8. ⏳ Quality gates: typecheck + lint + format:check + test
|
||||||
|
9. ⏳ Commit + push + PR
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **WorkOS**: Uses explicit `authorizationUrl`, `tokenUrl`, `userInfoUrl` (no discovery endpoint available)
|
||||||
|
- **Keycloak**: Uses `discoveryUrl` pattern (`{URL}/realms/{REALM}/.well-known/openid-configuration`)
|
||||||
|
- **UI gating**: Login page uses `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED` feature flags (safer than exposing secret env var names client-side)
|
||||||
|
- **Refactor**: Authentik moved into same `oauthProviders[]` array pattern — cleaner, more extensible
|
||||||
|
- **Feature flag design**: `NEXT_PUBLIC_*` flags are opt-in alongside credentials (prevents accidental button render when creds not set)
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `ASSUMPTION:` WorkOS OIDC discovery URL is not publicly documented; using direct URL pattern from WorkOS SSO docs.
|
||||||
|
- `ASSUMPTION:` `NEXT_PUBLIC_WORKOS_ENABLED=true` must be explicitly set — this is intentional (credential presence alone doesn't enable the button since NEXT_PUBLIC vars are baked at build time).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `auth.test.ts`: Mocks betterAuth stack, verifies WorkOS included/excluded based on env var
|
||||||
|
- `auth.test.ts`: Verifies Keycloak discoveryUrl constructed correctly
|
||||||
|
|
||||||
|
## Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Status |
|
||||||
|
| ------------------- | -------------------------------------------- |
|
||||||
|
| typecheck | ✅ 32/32 cached green |
|
||||||
|
| lint | ✅ 18/18 cached green |
|
||||||
|
| format:check | ✅ All matched files use Prettier code style |
|
||||||
|
| test (@mosaic/auth) | ✅ 8/8 tests passed |
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- `pnpm typecheck` — FULL TURBO, 32 tasks successful
|
||||||
|
- `pnpm lint` — FULL TURBO, 18 tasks successful
|
||||||
|
- `pnpm format:check` — All matched files use Prettier code style!
|
||||||
|
- `pnpm --filter=@mosaic/auth test` — 8 tests passed, 0 failed
|
||||||
115
packages/auth/src/auth.test.ts
Normal file
115
packages/auth/src/auth.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { buildOAuthProviders } from './auth.js';
|
||||||
|
|
||||||
|
describe('buildOAuthProviders', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
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['KEYCLOAK_CLIENT_ID'];
|
||||||
|
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||||
|
delete process.env['KEYCLOAK_URL'];
|
||||||
|
delete process.env['KEYCLOAK_REALM'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no SSO env vars are set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
expect(providers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WorkOS', () => {
|
||||||
|
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
|
||||||
|
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||||
|
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
||||||
|
|
||||||
|
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?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const workos = providers.find((p) => p.providerId === 'workos');
|
||||||
|
expect(workos).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keycloak', () => {
|
||||||
|
it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => {
|
||||||
|
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||||
|
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||||
|
process.env['KEYCLOAK_URL'] = 'https://auth.example.com';
|
||||||
|
process.env['KEYCLOAK_REALM'] = 'myrealm';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const keycloak = providers.find((p) => p.providerId === 'keycloak');
|
||||||
|
|
||||||
|
expect(keycloak).toBeDefined();
|
||||||
|
expect(keycloak?.clientId).toBe('mosaic');
|
||||||
|
expect(keycloak?.discoveryUrl).toBe(
|
||||||
|
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
expect(keycloak?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes keycloak provider when KEYCLOAK_CLIENT_ID is not set', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const keycloak = providers.find((p) => p.providerId === 'keycloak');
|
||||||
|
expect(keycloak).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentik', () => {
|
||||||
|
it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => {
|
||||||
|
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||||
|
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
|
||||||
|
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||||
|
|
||||||
|
expect(authentik).toBeDefined();
|
||||||
|
expect(authentik?.clientId).toBe('authentik-client');
|
||||||
|
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', () => {
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||||
|
expect(authentik).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers all three providers when all env vars are set', () => {
|
||||||
|
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
|
||||||
|
process.env['WORKOS_CLIENT_ID'] = 'w-id';
|
||||||
|
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
||||||
|
process.env['KEYCLOAK_URL'] = 'https://kc.example.com';
|
||||||
|
process.env['KEYCLOAK_REALM'] = 'test';
|
||||||
|
|
||||||
|
const providers = buildOAuthProviders();
|
||||||
|
expect(providers).toHaveLength(3);
|
||||||
|
const ids = providers.map((p) => p.providerId);
|
||||||
|
expect(ids).toContain('authentik');
|
||||||
|
expect(ids).toContain('workos');
|
||||||
|
expect(ids).toContain('keycloak');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||||
|
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 {
|
||||||
@@ -9,35 +10,62 @@ export interface AuthConfig {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuth(config: AuthConfig) {
|
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||||
const { db, baseURL, secret } = config;
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
const providers: GenericOAuthConfig[] = [];
|
||||||
|
|
||||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
if (authentikClientId) {
|
||||||
const plugins = authentikClientId
|
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||||
? [
|
providers.push({
|
||||||
genericOAuth({
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
providerId: 'authentik',
|
providerId: 'authentik',
|
||||||
clientId: authentikClientId,
|
clientId: authentikClientId,
|
||||||
clientSecret: authentikClientSecret ?? '',
|
clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
|
||||||
discoveryUrl: authentikIssuer
|
discoveryUrl: authentikIssuer
|
||||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
? `${authentikIssuer}/.well-known/openid-configuration`
|
||||||
: undefined,
|
: undefined,
|
||||||
authorizationUrl: authentikIssuer
|
authorizationUrl: authentikIssuer ? `${authentikIssuer}/application/o/authorize/` : undefined,
|
||||||
? `${authentikIssuer}/application/o/authorize/`
|
|
||||||
: undefined,
|
|
||||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
||||||
userInfoUrl: authentikIssuer
|
userInfoUrl: authentikIssuer ? `${authentikIssuer}/application/o/userinfo/` : undefined,
|
||||||
? `${authentikIssuer}/application/o/userinfo/`
|
|
||||||
: undefined,
|
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'],
|
||||||
},
|
});
|
||||||
],
|
}
|
||||||
}),
|
|
||||||
]
|
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||||
: undefined;
|
if (workosClientId) {
|
||||||
|
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',
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuth(config: AuthConfig) {
|
||||||
|
const { db, baseURL, secret } = config;
|
||||||
|
|
||||||
|
const oauthProviders = buildOAuthProviders();
|
||||||
|
const plugins =
|
||||||
|
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
||||||
|
|
||||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user