160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
import { betterAuth } from 'better-auth';
|
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
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 {
|
|
db: Db;
|
|
baseURL?: string;
|
|
secret?: string;
|
|
}
|
|
|
|
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
|
const providers: GenericOAuthConfig[] = [];
|
|
|
|
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
|
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: 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'];
|
|
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: workosClientSecret,
|
|
issuer: workosIssuer,
|
|
discoveryUrl: buildDiscoveryUrl(workosIssuer),
|
|
scopes: ['openid', 'email', 'profile'],
|
|
requireIssuerValidation: true,
|
|
});
|
|
}
|
|
|
|
const keycloakIssuer = resolveKeycloakIssuer();
|
|
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
|
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;
|
|
|
|
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());
|
|
|
|
return betterAuth({
|
|
database: drizzleAdapter(db, {
|
|
provider: 'pg',
|
|
usePlural: true,
|
|
}),
|
|
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
|
|
secret: secret ?? process.env['BETTER_AUTH_SECRET'],
|
|
basePath: '/api/auth',
|
|
trustedOrigins,
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
},
|
|
user: {
|
|
additionalFields: {
|
|
role: {
|
|
type: 'string',
|
|
required: false,
|
|
defaultValue: 'member',
|
|
input: false,
|
|
},
|
|
},
|
|
},
|
|
session: {
|
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
|
updateAge: 60 * 60 * 24, // refresh daily
|
|
},
|
|
plugins: [...(plugins ?? []), admin({ defaultRole: 'member', adminRoles: ['admin'] })],
|
|
});
|
|
}
|
|
|
|
export type Auth = ReturnType<typeof createAuth>;
|