feat(auth): add WorkOS and Keycloak SSO providers
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
2026-03-19 20:30:00 -05:00
parent 307bb427d6
commit 77ba13b41b
8 changed files with 454 additions and 83 deletions

View File

@@ -6,14 +6,15 @@ describe('buildOAuthProviders', () => {
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['WORKOS_ISSUER'];
delete process.env['KEYCLOAK_CLIENT_ID'];
delete process.env['KEYCLOAK_CLIENT_SECRET'];
delete process.env['KEYCLOAK_ISSUER'];
delete process.env['KEYCLOAK_URL'];
delete process.env['KEYCLOAK_REALM'];
});
@@ -28,22 +29,32 @@ describe('buildOAuthProviders', () => {
});
describe('WorkOS', () => {
it('includes workos provider when WORKOS_CLIENT_ID is set', () => {
it('includes workos provider when all required env vars are set', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
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?.issuer).toBe('https://example.authkit.app');
expect(workos?.discoveryUrl).toBe(
'https://example.authkit.app/.well-known/openid-configuration',
);
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('excludes workos provider when WORKOS_CLIENT_ID is not set', () => {
it('throws when WorkOS is partially configured', () => {
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 workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeUndefined();
@@ -51,47 +62,78 @@ describe('buildOAuthProviders', () => {
});
describe('Keycloak', () => {
it('includes keycloak provider when KEYCLOAK_CLIENT_ID is set', () => {
it('includes keycloak provider when KEYCLOAK_ISSUER 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_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
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';
const providers = buildOAuthProviders();
const keycloak = providers.find((p) => p.providerId === 'keycloak');
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeDefined();
expect(keycloak?.clientId).toBe('mosaic');
expect(keycloak?.discoveryUrl).toBe(
expect(keycloakProvider?.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', () => {
it('throws when Keycloak is partially configured', () => {
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 keycloak = providers.find((p) => p.providerId === 'keycloak');
expect(keycloak).toBeUndefined();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeUndefined();
});
});
describe('Authentik', () => {
it('includes authentik provider when AUTHENTIK_CLIENT_ID is set', () => {
it('includes authentik provider when all required env vars are 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';
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?.issuer).toBe('https://auth.example.com/application/o/mosaic');
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', () => {
it('throws when Authentik is partially configured', () => {
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 authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeUndefined();
@@ -100,10 +142,14 @@ describe('buildOAuthProviders', () => {
it('registers all three providers when all env vars are set', () => {
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_SECRET'] = 'w-secret';
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
process.env['KEYCLOAK_URL'] = 'https://kc.example.com';
process.env['KEYCLOAK_REALM'] = 'test';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test';
const providers = buildOAuthProviders();
expect(providers).toHaveLength(3);

View File

@@ -1,7 +1,7 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin, genericOAuth } from 'better-auth/plugins';
import type { GenericOAuthConfig } from 'better-auth/plugins';
import { admin } from 'better-auth/plugins';
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import type { Db } from '@mosaic/db';
export interface AuthConfig {
@@ -14,52 +14,108 @@ export interface AuthConfig {
export function buildOAuthProviders(): GenericOAuthConfig[] {
const providers: GenericOAuthConfig[] = [];
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
if (authentikClientId) {
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
assertOptionalProviderConfig('Authentik SSO', {
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
values: [authentikIssuer, authentikClientId, authentikClientSecret],
});
if (authentikIssuer && authentikClientId && authentikClientSecret) {
providers.push({
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: process.env['AUTHENTIK_CLIENT_SECRET'] ?? '',
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,
clientSecret: authentikClientSecret,
issuer: authentikIssuer,
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
});
}
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
const workosClientId = process.env['WORKOS_CLIENT_ID'];
if (workosClientId) {
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
assertOptionalProviderConfig('WorkOS SSO', {
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
values: [workosIssuer, workosClientId, workosClientSecret],
});
if (workosIssuer && workosClientId && workosClientSecret) {
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',
clientSecret: workosClientSecret,
issuer: workosIssuer,
discoveryUrl: buildDiscoveryUrl(workosIssuer),
scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
});
}
const keycloakIssuer = resolveKeycloakIssuer();
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'],
});
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET'];
assertOptionalProviderConfig('Keycloak SSO', {
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'],
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer],
});
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
providers.push(
keycloak({
clientId: keycloakClientId,
clientSecret: keycloakClientSecret,
issuer: keycloakIssuer,
scopes: ['openid', 'email', 'profile'],
}),
);
}
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) {
const { db, baseURL, secret } = config;