feat(auth): add WorkOS and Keycloak SSO discovery
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
|
||||
62
packages/auth/src/sso.spec.ts
Normal file
62
packages/auth/src/sso.spec.ts
Normal 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
212
packages/auth/src/sso.ts
Normal 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'),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user