feat(auth): add WorkOS + Keycloak SSO providers (P8-001)
- Refactor auth.ts to build OAuth providers array dynamically; extract buildOAuthProviders() for unit-testability - Add WorkOS provider (WORKOS_CLIENT_ID/SECRET/REDIRECT_URI env vars) - Add Keycloak provider with realm-scoped OIDC discovery (KEYCLOAK_URL/REALM/CLIENT_ID/CLIENT_SECRET env vars) - Add genericOAuthClient plugin to web auth-client for signIn.oauth2() - Add WorkOS + Keycloak SSO buttons to login page (NEXT_PUBLIC_*_ENABLED feature flags control visibility) - Update .env.example with SSO provider stanzas - Add 8 unit tests covering all provider inclusion/exclusion paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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 { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||
import type { GenericOAuthConfig } from 'better-auth/plugins';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
export interface AuthConfig {
|
||||
@@ -9,35 +10,62 @@ export interface AuthConfig {
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||
const providers: GenericOAuthConfig[] = [];
|
||||
|
||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||
if (authentikClientId) {
|
||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||
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,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||
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 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 oauthProviders = buildOAuthProviders();
|
||||
const plugins =
|
||||
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
||||
|
||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||
|
||||
Reference in New Issue
Block a user