feat(auth): add WorkOS and Keycloak SSO discovery

This commit is contained in:
2026-03-19 21:03:46 -05:00
parent 25f880416a
commit 368b20e4ea
17 changed files with 694 additions and 74 deletions

View File

@@ -2,6 +2,7 @@ import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, genericOAuth } from 'better-auth/plugins';
import type { Db } from '@mosaic/db';
import { buildGenericOidcProviderConfigs } from './sso.js';
export interface AuthConfig {
db: Db;
@@ -11,33 +12,15 @@ export interface AuthConfig {
export function createAuth(config: AuthConfig) {
const { db, baseURL, secret } = config;
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
const plugins = authentikClientId
? [
genericOAuth({
config: [
{
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: authentikClientSecret ?? '',
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,
scopes: ['openid', 'email', 'profile'],
},
],
}),
]
: undefined;
const oidcConfigs = buildGenericOidcProviderConfigs();
const plugins =
oidcConfigs.length > 0
? [
genericOAuth({
config: oidcConfigs,
}),
]
: undefined;
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());

View File

@@ -1 +1,12 @@
export { createAuth, type Auth, type AuthConfig } from './auth.js';
export {
buildGenericOidcProviderConfigs,
buildSsoDiscovery,
listSsoStartupWarnings,
type GenericOidcProviderConfig,
type SsoLoginMode,
type SsoProtocol,
type SsoProviderDiscovery,
type SsoTeamSyncConfig,
type SupportedSsoProviderId,
} from './sso.js';

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
buildGenericOidcProviderConfigs,
buildSsoDiscovery,
listSsoStartupWarnings,
} from './sso.js';
describe('SSO provider config helpers', () => {
it('builds OIDC configs for Authentik, WorkOS, and Keycloak when fully configured', () => {
const configs = buildGenericOidcProviderConfigs({
AUTHENTIK_CLIENT_ID: 'authentik-client',
AUTHENTIK_CLIENT_SECRET: 'authentik-secret',
AUTHENTIK_ISSUER: 'https://authentik.example.com',
WORKOS_CLIENT_ID: 'workos-client',
WORKOS_CLIENT_SECRET: 'workos-secret',
WORKOS_ISSUER: 'https://auth.workos.com/sso/client_123',
KEYCLOAK_CLIENT_ID: 'keycloak-client',
KEYCLOAK_CLIENT_SECRET: 'keycloak-secret',
KEYCLOAK_ISSUER: 'https://sso.example.com/realms/mosaic',
});
expect(configs.map((config) => config.providerId)).toEqual(['authentik', 'workos', 'keycloak']);
expect(configs.find((config) => config.providerId === 'workos')).toMatchObject({
discoveryUrl: 'https://auth.workos.com/sso/client_123/.well-known/openid-configuration',
pkce: true,
requireIssuerValidation: true,
});
expect(configs.find((config) => config.providerId === 'keycloak')).toMatchObject({
discoveryUrl: 'https://sso.example.com/realms/mosaic/.well-known/openid-configuration',
pkce: true,
});
});
it('exposes Keycloak SAML fallback when OIDC is not configured', () => {
const providers = buildSsoDiscovery({
KEYCLOAK_SAML_LOGIN_URL: 'https://sso.example.com/realms/mosaic/protocol/saml',
});
expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({
configured: true,
loginMode: 'saml',
samlFallback: {
configured: true,
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
},
});
});
it('reports partial provider configuration as startup warnings', () => {
const warnings = listSsoStartupWarnings({
WORKOS_CLIENT_ID: 'workos-client',
KEYCLOAK_CLIENT_ID: 'keycloak-client',
});
expect(warnings).toContain(
'workos OIDC is partially configured. Missing: WORKOS_CLIENT_SECRET, WORKOS_ISSUER',
);
expect(warnings).toContain(
'keycloak OIDC is partially configured. Missing: KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER',
);
});
});

212
packages/auth/src/sso.ts Normal file
View File

@@ -0,0 +1,212 @@
export type SupportedSsoProviderId = 'authentik' | 'workos' | 'keycloak';
export type SsoProtocol = 'oidc' | 'saml';
export type SsoLoginMode = 'oidc' | 'saml' | null;
type EnvMap = Record<string, string | undefined>;
export interface GenericOidcProviderConfig {
providerId: SupportedSsoProviderId;
clientId: string;
clientSecret: string;
discoveryUrl?: string;
issuer?: string;
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
scopes: string[];
pkce?: boolean;
requireIssuerValidation?: boolean;
}
export interface SsoTeamSyncConfig {
enabled: boolean;
claim: string | null;
}
export interface SsoProviderDiscovery {
id: SupportedSsoProviderId;
name: string;
protocols: SsoProtocol[];
configured: boolean;
loginMode: SsoLoginMode;
callbackPath: string | null;
teamSync: SsoTeamSyncConfig;
samlFallback: {
configured: boolean;
loginUrl: string | null;
};
warnings: string[];
}
const DEFAULT_SCOPES = ['openid', 'email', 'profile'];
function readEnv(env: EnvMap, key: string): string | undefined {
const value = env[key]?.trim();
return value ? value : undefined;
}
function toDiscoveryUrl(issuer: string): string {
return `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
}
function getTeamSyncClaim(env: EnvMap, envKey: string, fallbackClaim?: string): SsoTeamSyncConfig {
const claim = readEnv(env, envKey) ?? fallbackClaim ?? null;
return {
enabled: claim !== null,
claim,
};
}
function buildAuthentikConfig(env: EnvMap): GenericOidcProviderConfig | null {
const issuer = readEnv(env, 'AUTHENTIK_ISSUER');
const clientId = readEnv(env, 'AUTHENTIK_CLIENT_ID');
const clientSecret = readEnv(env, 'AUTHENTIK_CLIENT_SECRET');
if (!issuer || !clientId || !clientSecret) {
return null;
}
const baseIssuer = issuer.replace(/\/$/, '');
return {
providerId: 'authentik',
issuer: baseIssuer,
clientId,
clientSecret,
discoveryUrl: toDiscoveryUrl(baseIssuer),
authorizationUrl: `${baseIssuer}/application/o/authorize/`,
tokenUrl: `${baseIssuer}/application/o/token/`,
userInfoUrl: `${baseIssuer}/application/o/userinfo/`,
scopes: DEFAULT_SCOPES,
};
}
function buildWorkosConfig(env: EnvMap): GenericOidcProviderConfig | null {
const issuer = readEnv(env, 'WORKOS_ISSUER');
const clientId = readEnv(env, 'WORKOS_CLIENT_ID');
const clientSecret = readEnv(env, 'WORKOS_CLIENT_SECRET');
if (!issuer || !clientId || !clientSecret) {
return null;
}
const normalizedIssuer = issuer.replace(/\/$/, '');
return {
providerId: 'workos',
issuer: normalizedIssuer,
clientId,
clientSecret,
discoveryUrl: toDiscoveryUrl(normalizedIssuer),
scopes: DEFAULT_SCOPES,
pkce: true,
requireIssuerValidation: true,
};
}
function buildKeycloakConfig(env: EnvMap): GenericOidcProviderConfig | null {
const issuer = readEnv(env, 'KEYCLOAK_ISSUER');
const clientId = readEnv(env, 'KEYCLOAK_CLIENT_ID');
const clientSecret = readEnv(env, 'KEYCLOAK_CLIENT_SECRET');
if (!issuer || !clientId || !clientSecret) {
return null;
}
const normalizedIssuer = issuer.replace(/\/$/, '');
return {
providerId: 'keycloak',
issuer: normalizedIssuer,
clientId,
clientSecret,
discoveryUrl: toDiscoveryUrl(normalizedIssuer),
scopes: DEFAULT_SCOPES,
pkce: true,
requireIssuerValidation: true,
};
}
function collectWarnings(env: EnvMap, provider: SupportedSsoProviderId): string[] {
const prefix = provider.toUpperCase();
const oidcFields = [
`${prefix}_CLIENT_ID`,
`${prefix}_CLIENT_SECRET`,
`${prefix}_ISSUER`,
] as const;
const presentOidcFields = oidcFields.filter((field) => readEnv(env, field));
const warnings: string[] = [];
if (presentOidcFields.length > 0 && presentOidcFields.length < oidcFields.length) {
const missing = oidcFields.filter((field) => !readEnv(env, field));
warnings.push(`${provider} OIDC is partially configured. Missing: ${missing.join(', ')}`);
}
return warnings;
}
export function buildGenericOidcProviderConfigs(
env: EnvMap = process.env,
): GenericOidcProviderConfig[] {
return [buildAuthentikConfig(env), buildWorkosConfig(env), buildKeycloakConfig(env)].filter(
(config): config is GenericOidcProviderConfig => config !== null,
);
}
export function listSsoStartupWarnings(env: EnvMap = process.env): string[] {
return ['authentik', 'workos', 'keycloak'].flatMap((provider) =>
collectWarnings(env, provider as SupportedSsoProviderId),
);
}
export function buildSsoDiscovery(env: EnvMap = process.env): SsoProviderDiscovery[] {
const oidcConfigs = new Map(
buildGenericOidcProviderConfigs(env).map((config) => [config.providerId, config]),
);
const keycloakSamlLoginUrl = readEnv(env, 'KEYCLOAK_SAML_LOGIN_URL') ?? null;
return [
{
id: 'authentik',
name: 'Authentik',
protocols: ['oidc'],
configured: oidcConfigs.has('authentik'),
loginMode: oidcConfigs.has('authentik') ? 'oidc' : null,
callbackPath: oidcConfigs.has('authentik') ? '/api/auth/oauth2/callback/authentik' : null,
teamSync: getTeamSyncClaim(env, 'AUTHENTIK_TEAM_SYNC_CLAIM', 'groups'),
samlFallback: {
configured: false,
loginUrl: null,
},
warnings: collectWarnings(env, 'authentik'),
},
{
id: 'workos',
name: 'WorkOS',
protocols: ['oidc'],
configured: oidcConfigs.has('workos'),
loginMode: oidcConfigs.has('workos') ? 'oidc' : null,
callbackPath: oidcConfigs.has('workos') ? '/api/auth/oauth2/callback/workos' : null,
teamSync: getTeamSyncClaim(env, 'WORKOS_TEAM_SYNC_CLAIM', 'organization_id'),
samlFallback: {
configured: false,
loginUrl: null,
},
warnings: collectWarnings(env, 'workos'),
},
{
id: 'keycloak',
name: 'Keycloak',
protocols: ['oidc', 'saml'],
configured: oidcConfigs.has('keycloak') || keycloakSamlLoginUrl !== null,
loginMode: oidcConfigs.has('keycloak') ? 'oidc' : keycloakSamlLoginUrl ? 'saml' : null,
callbackPath: oidcConfigs.has('keycloak') ? '/api/auth/oauth2/callback/keycloak' : null,
teamSync: getTeamSyncClaim(env, 'KEYCLOAK_TEAM_SYNC_CLAIM', 'groups'),
samlFallback: {
configured: keycloakSamlLoginUrl !== null,
loginUrl: keycloakSamlLoginUrl,
},
warnings: collectWarnings(env, 'keycloak'),
},
];
}