feat(auth): add WorkOS and Keycloak SSO discovery
This commit is contained in:
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