tenant-config-store.ts647 lines · main
| 1 | import { ValidationError } from '@briven/shared'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 5 | import { log } from '../lib/logger.js'; |
| 6 | import { hasTenantSecret } from './tenant-secrets.js'; |
| 7 | |
| 8 | /** |
| 9 | * Non-secret per-tenant auth config (BUILD_PLAN.md §6 Providers panel + |
| 10 | * §6 Branding panel + §6 Usage panel sender-domain field). |
| 11 | * |
| 12 | * Storage: customer's `_briven_meta` jsonb under key `auth_config`. Same |
| 13 | * table that `auth.enabled` flag lives in — keeps the per-project meta |
| 14 | * surface coherent. |
| 15 | * |
| 16 | * **Secrets do NOT live here.** OAuth client secrets, mittera API keys, |
| 17 | * webhook signing keys all go through `tenant-secret-store.ts` (HKDF + |
| 18 | * AES-256-GCM, control-plane `tenant_secrets` table). This file only |
| 19 | * holds the flags + the public bits (OAuth client ids are public-by-design |
| 20 | * per upstream provider docs). |
| 21 | */ |
| 22 | |
| 23 | // ─── schema ───────────────────────────────────────────────────────────── |
| 24 | |
| 25 | const oauthProviderConfig = z.object({ |
| 26 | enabled: z.boolean(), |
| 27 | /** |
| 28 | * Public client identifier from the upstream provider. Visible to the |
| 29 | * customer's end-users in the redirect URL — not a secret. |
| 30 | */ |
| 31 | clientId: z.string().nullable(), |
| 32 | }); |
| 33 | |
| 34 | /** |
| 35 | * Generic / custom OIDC (OpenID Connect) provider configured by the customer. |
| 36 | * Same non-secret split as the built-in social providers: the public client id |
| 37 | * + endpoints live here (project DoltGres `_briven_meta`), the client secret |
| 38 | * rides the encrypted control-plane tenant-secret-store under the name |
| 39 | * `oidc_<id>_client_secret` (service 'auth'). |
| 40 | * |
| 41 | * The provider can be configured EITHER via an OIDC `issuer` (the engine fetches |
| 42 | * `<issuer>/.well-known/openid-configuration`) OR via the three explicit |
| 43 | * endpoints. Either path is gated on enabled + clientId + a stored secret before |
| 44 | * it reaches the Better Auth `genericOAuth` plugin (mirrors the konnos gate). |
| 45 | */ |
| 46 | const customOidcProviderConfig = z.object({ |
| 47 | /** URL-safe slug; also the Better Auth `providerId` + the `provider` query value. */ |
| 48 | id: z |
| 49 | .string() |
| 50 | .min(1) |
| 51 | .max(40) |
| 52 | .regex(/^[a-z0-9-]+$/, 'id must be a slug of lowercase letters, digits and hyphens'), |
| 53 | /** Human label shown on the hosted-pages + SDK button (e.g. "Acme SSO"). */ |
| 54 | displayName: z.string().min(1).max(64), |
| 55 | enabled: z.boolean(), |
| 56 | /** Public OAuth client id from the upstream provider — not a secret. */ |
| 57 | clientId: z.string().nullable(), |
| 58 | /** |
| 59 | * OIDC issuer base URL. When set, the engine discovers the |
| 60 | * authorization/token/userinfo endpoints from |
| 61 | * `<issuer>/.well-known/openid-configuration`. Mutually-sufficient with the |
| 62 | * three explicit endpoints below — supply one or the other. |
| 63 | */ |
| 64 | issuer: z.string().url().nullable(), |
| 65 | authorizationUrl: z.string().url().nullable(), |
| 66 | tokenUrl: z.string().url().nullable(), |
| 67 | userinfoUrl: z.string().url().nullable(), |
| 68 | /** Space-separated OAuth scopes. Defaults to the standard OIDC trio. */ |
| 69 | scopes: z.string().min(1).default('openid profile email'), |
| 70 | /** Whether to use PKCE on the authorization code exchange. Defaults on. */ |
| 71 | pkce: z.boolean().optional(), |
| 72 | }); |
| 73 | |
| 74 | export type CustomOidcProvider = z.infer<typeof customOidcProviderConfig>; |
| 75 | |
| 76 | const authConfigSchema = z.object({ |
| 77 | providers: z.object({ |
| 78 | emailPassword: z.object({ enabled: z.boolean() }), |
| 79 | magicLink: z.object({ |
| 80 | enabled: z.boolean(), |
| 81 | expiryMinutes: z.number().int().min(1).max(60), |
| 82 | }), |
| 83 | emailOtp: z.object({ |
| 84 | enabled: z.boolean(), |
| 85 | codeLength: z.number().int().min(4).max(8), |
| 86 | expiryMinutes: z.number().int().min(1).max(30), |
| 87 | }), |
| 88 | passkey: z.object({ enabled: z.boolean() }), |
| 89 | google: oauthProviderConfig, |
| 90 | github: oauthProviderConfig, |
| 91 | discord: oauthProviderConfig, |
| 92 | microsoft: oauthProviderConfig, |
| 93 | apple: oauthProviderConfig, |
| 94 | twitter: oauthProviderConfig, |
| 95 | linkedin: oauthProviderConfig, |
| 96 | gitlab: oauthProviderConfig, |
| 97 | bitbucket: oauthProviderConfig, |
| 98 | dropbox: oauthProviderConfig, |
| 99 | facebook: oauthProviderConfig, |
| 100 | spotify: oauthProviderConfig, |
| 101 | // Generic OIDC/OAuth provider (Forgejo at code.konnos.org). Same |
| 102 | // {enabled, clientId} shape as the built-in social providers — the |
| 103 | // public client id is non-secret; the secret rides the encrypted |
| 104 | // tenant-secret-store like the others. |
| 105 | konnos: oauthProviderConfig, |
| 106 | }), |
| 107 | twoFactor: z.object({ |
| 108 | enabled: z.boolean(), |
| 109 | /** Issuer name shown in the authenticator app (defaults to project name). */ |
| 110 | issuer: z.string().max(64).nullable(), |
| 111 | /** When true, all users MUST enroll MFA (TOTP or passkey) before signing in. */ |
| 112 | required: z.boolean().default(false), |
| 113 | }), |
| 114 | /** |
| 115 | * Custom JWT claims added to every token issued by the jwt plugin. |
| 116 | * Keys are claim names; values are static strings. For dynamic claims |
| 117 | * (e.g. org_role), the customer should use a webhook + their own |
| 118 | * signing layer; this surface is for simple static claims only. |
| 119 | */ |
| 120 | jwtClaims: z.record(z.string().min(1).max(64), z.string().max(256)).default({}), |
| 121 | branding: z.object({ |
| 122 | /** Customer-uploaded logo URL. Null means use the briven default mark. */ |
| 123 | logoUrl: z.string().url().nullable(), |
| 124 | /** |
| 125 | * Primary accent color. Must be a 6-digit hex (with leading `#`) so |
| 126 | * the dashboard's WCAG-AA contrast check can normalise it. Defaults |
| 127 | * to briven green per BRAND.md. |
| 128 | */ |
| 129 | primaryColor: z |
| 130 | .string() |
| 131 | .regex(/^#[0-9a-fA-F]{6}$/, 'primaryColor must be a 6-digit hex like #00e87a'), |
| 132 | /** |
| 133 | * mittera-verified sender domain (e.g. `mail.customerapp.com`). Null |
| 134 | * until the customer completes the verification wizard; mail then |
| 135 | * falls back to `noreply@auth.briven.tech`. |
| 136 | */ |
| 137 | senderDomain: z |
| 138 | .string() |
| 139 | // RFC-1035-ish domain match — single conservative pass; mittera |
| 140 | // does the authoritative DNS validation downstream. |
| 141 | .regex( |
| 142 | /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i, |
| 143 | 'senderDomain must be a valid DNS domain', |
| 144 | ) |
| 145 | .nullable(), |
| 146 | /** Display name in the From: header. */ |
| 147 | senderName: z.string().min(1).max(64), |
| 148 | }), |
| 149 | /** |
| 150 | * Customer-defined generic OIDC providers (in addition to the built-in |
| 151 | * social providers above). Optional so configs written before this field |
| 152 | * existed still parse — `getAuthConfig` callers treat a missing array as `[]`. |
| 153 | */ |
| 154 | customOidc: z.array(customOidcProviderConfig).optional(), |
| 155 | /** Customer-owned auth subdomain (e.g. auth.murphus.eu). Null means use the default hosted pages. */ |
| 156 | customAuthDomain: z |
| 157 | .string() |
| 158 | .regex( |
| 159 | /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i, |
| 160 | 'customAuthDomain must be a valid DNS domain', |
| 161 | ) |
| 162 | .nullable(), |
| 163 | /** |
| 164 | * Session management settings (Phase 2 — BUILD_PLAN.md). |
| 165 | */ |
| 166 | session: z |
| 167 | .object({ |
| 168 | /** Maximum session lifetime in days (5 min to 10 years). Default 30. */ |
| 169 | maxLifetimeDays: z.number().int().min(1).max(3650).default(30), |
| 170 | /** Refresh session token if older than this many days. Default 7. */ |
| 171 | updateAgeDays: z.number().int().min(1).max(365).default(7), |
| 172 | /** Invalidate session after this many minutes of inactivity. 0 = disabled. */ |
| 173 | inactivityTimeoutMinutes: z.number().int().min(0).max(1440).default(0), |
| 174 | }) |
| 175 | .default({ maxLifetimeDays: 30, updateAgeDays: 7, inactivityTimeoutMinutes: 0 }), |
| 176 | /** |
| 177 | * Security hardening settings (Phase 1 — BUILD_PLAN.md). |
| 178 | * Controls sign-up gating, email restrictions, and abuse prevention. |
| 179 | */ |
| 180 | security: z |
| 181 | .object({ |
| 182 | /** Who may sign up: public (anyone), restricted (invite/enterprise only), waitlist (admin approval). */ |
| 183 | signUpMode: z.enum(['public', 'restricted', 'waitlist']).default('public'), |
| 184 | /** Only allow sign-ups from these email domains. Empty = no restriction. */ |
| 185 | allowedEmailDomains: z.array(z.string().min(1).max(128)).default([]), |
| 186 | /** Block sign-ups from these email domains. Checked before allowedEmailDomains. */ |
| 187 | blockedEmailDomains: z.array(z.string().min(1).max(128)).default([]), |
| 188 | /** Reject known disposable email domains. */ |
| 189 | blockDisposableEmails: z.boolean().default(false), |
| 190 | /** Reject email addresses with subaddress tags (+tag, #tag). */ |
| 191 | blockEmailSubaddresses: z.boolean().default(false), |
| 192 | /** Check passwords against Have I Been Pwned breach database on sign-up. */ |
| 193 | breachDetection: z.object({ enabled: z.boolean() }).default({ enabled: false }), |
| 194 | /** Rate limiting configuration for auth endpoints. */ |
| 195 | rateLimiting: z |
| 196 | .object({ |
| 197 | enabled: z.boolean().default(true), |
| 198 | maxAttemptsPerIp: z.number().int().min(1).max(1000).default(100), |
| 199 | windowMinutes: z.number().int().min(1).max(1440).default(15), |
| 200 | }) |
| 201 | .default({ enabled: true, maxAttemptsPerIp: 100, windowMinutes: 15 }), |
| 202 | }) |
| 203 | .default({ |
| 204 | signUpMode: 'public', |
| 205 | allowedEmailDomains: [], |
| 206 | blockedEmailDomains: [], |
| 207 | blockDisposableEmails: false, |
| 208 | blockEmailSubaddresses: false, |
| 209 | breachDetection: { enabled: false }, |
| 210 | rateLimiting: { enabled: true, maxAttemptsPerIp: 100, windowMinutes: 15 }, |
| 211 | }), |
| 212 | /** |
| 213 | * Cloudflare Turnstile bot protection (Phase 1 — BUILD_PLAN.md). |
| 214 | * The site key is public (rendered in frontend); the secret is global env. |
| 215 | */ |
| 216 | turnstile: z |
| 217 | .object({ |
| 218 | enabled: z.boolean().default(false), |
| 219 | siteKey: z.string().nullable().default(null), |
| 220 | }) |
| 221 | .default({ enabled: false, siteKey: null }), |
| 222 | /** |
| 223 | * Log retention settings (Phase 6.3). |
| 224 | * Controls how long audit logs and app logs are retained. |
| 225 | */ |
| 226 | retention: z |
| 227 | .object({ |
| 228 | /** Audit log retention in days. 7 | 30 | 90. Default 30. */ |
| 229 | auditLogDays: z.number().int().min(7).max(90).default(30), |
| 230 | /** App log retention in days. 7 | 30 | 90. Default 7. */ |
| 231 | appLogDays: z.number().int().min(7).max(90).default(7), |
| 232 | }) |
| 233 | .default({ auditLogDays: 30, appLogDays: 7 }), |
| 234 | /** |
| 235 | * Localization settings (Phase 6.8). |
| 236 | * Default locale for hosted pages and email templates. |
| 237 | */ |
| 238 | locale: z |
| 239 | .string() |
| 240 | .regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'locale must be a valid BCP 47 language tag') |
| 241 | .default('en'), |
| 242 | }); |
| 243 | |
| 244 | export type AuthConfig = z.infer<typeof authConfigSchema>; |
| 245 | |
| 246 | /** |
| 247 | * Hard-coded defaults — used by `getAuthConfig` when a project has no |
| 248 | * `auth_config` row yet (e.g. first dashboard load after Enable Auth). |
| 249 | * Frozen so accidental mutation throws. |
| 250 | * |
| 251 | * Explicit literal (no `.default()` chains in the schema) because zod 4 |
| 252 | * tightened the type of `.default()` to the output shape, which made |
| 253 | * field-level defaulting on nested objects fight typecheck. Trade-off: |
| 254 | * callers must always pass a full config to `parse`; partial patches |
| 255 | * route through `mergeAuthConfig` which seeds from this constant. |
| 256 | */ |
| 257 | function deepFreeze<T>(obj: T): T { |
| 258 | if (obj !== null && typeof obj === 'object' && !Object.isFrozen(obj)) { |
| 259 | for (const v of Object.values(obj as Record<string, unknown>)) deepFreeze(v); |
| 260 | Object.freeze(obj); |
| 261 | } |
| 262 | return obj; |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Starter pack after Enable Auth — **passwordless ON by default** (Clerk-simple). |
| 267 | * Email+password stays on. OAuth stays off until secrets exist. |
| 268 | * Turning providers off is still a dashboard/API choice; new projects must |
| 269 | * work with magic link / OTP / passkey without a second "toggle providers" step |
| 270 | * (fleet pain 2026-07 — agents and owners re-toggled forever while defaults |
| 271 | * left passwordless OFF). |
| 272 | */ |
| 273 | export const DEFAULT_AUTH_CONFIG: AuthConfig = deepFreeze({ |
| 274 | providers: { |
| 275 | emailPassword: { enabled: true }, |
| 276 | magicLink: { enabled: true, expiryMinutes: 15 }, |
| 277 | emailOtp: { enabled: true, codeLength: 6, expiryMinutes: 5 }, |
| 278 | passkey: { enabled: true }, |
| 279 | google: { enabled: false, clientId: null }, |
| 280 | github: { enabled: false, clientId: null }, |
| 281 | discord: { enabled: false, clientId: null }, |
| 282 | microsoft: { enabled: false, clientId: null }, |
| 283 | apple: { enabled: false, clientId: null }, |
| 284 | twitter: { enabled: false, clientId: null }, |
| 285 | linkedin: { enabled: false, clientId: null }, |
| 286 | gitlab: { enabled: false, clientId: null }, |
| 287 | bitbucket: { enabled: false, clientId: null }, |
| 288 | dropbox: { enabled: false, clientId: null }, |
| 289 | facebook: { enabled: false, clientId: null }, |
| 290 | spotify: { enabled: false, clientId: null }, |
| 291 | konnos: { enabled: false, clientId: null }, |
| 292 | }, |
| 293 | twoFactor: { enabled: false, issuer: null, required: false }, |
| 294 | jwtClaims: {}, |
| 295 | session: { maxLifetimeDays: 30, updateAgeDays: 7, inactivityTimeoutMinutes: 0 }, |
| 296 | branding: { |
| 297 | logoUrl: null, |
| 298 | primaryColor: '#00e87a', |
| 299 | senderDomain: null, |
| 300 | senderName: 'briven auth', |
| 301 | }, |
| 302 | customOidc: [], |
| 303 | customAuthDomain: null, |
| 304 | security: { |
| 305 | signUpMode: 'public', |
| 306 | allowedEmailDomains: [], |
| 307 | blockedEmailDomains: [], |
| 308 | blockDisposableEmails: false, |
| 309 | blockEmailSubaddresses: false, |
| 310 | breachDetection: { enabled: false }, |
| 311 | rateLimiting: { enabled: true, maxAttemptsPerIp: 100, windowMinutes: 15 }, |
| 312 | }, |
| 313 | turnstile: { enabled: false, siteKey: null }, |
| 314 | retention: { auditLogDays: 30, appLogDays: 7 }, |
| 315 | locale: 'en', |
| 316 | }) as AuthConfig; |
| 317 | |
| 318 | // ─── pure helpers (unit-testable without postgres) ─────────────────────── |
| 319 | |
| 320 | /** |
| 321 | * Deep-merge a partial patch over the current config, then re-validate |
| 322 | * via zod so writes can't smuggle invalid shapes through. Unknown keys |
| 323 | * are stripped by zod's default `.strip` mode. |
| 324 | */ |
| 325 | export function mergeAuthConfig(current: AuthConfig, patch: unknown): AuthConfig { |
| 326 | if (patch === null || typeof patch !== 'object') { |
| 327 | throw new ValidationError('auth config patch must be an object'); |
| 328 | } |
| 329 | const merged = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>); |
| 330 | const parsed = authConfigSchema.safeParse(merged); |
| 331 | if (!parsed.success) { |
| 332 | throw new ValidationError('auth config patch failed validation', { |
| 333 | issues: parsed.error.issues, |
| 334 | }); |
| 335 | } |
| 336 | return parsed.data; |
| 337 | } |
| 338 | |
| 339 | function deepMerge( |
| 340 | base: Record<string, unknown>, |
| 341 | patch: Record<string, unknown>, |
| 342 | ): Record<string, unknown> { |
| 343 | const out: Record<string, unknown> = { ...base }; |
| 344 | for (const [k, v] of Object.entries(patch)) { |
| 345 | if (v === undefined) continue; |
| 346 | const existing = out[k]; |
| 347 | if ( |
| 348 | v !== null && |
| 349 | typeof v === 'object' && |
| 350 | !Array.isArray(v) && |
| 351 | existing !== null && |
| 352 | typeof existing === 'object' && |
| 353 | !Array.isArray(existing) |
| 354 | ) { |
| 355 | out[k] = deepMerge( |
| 356 | existing as Record<string, unknown>, |
| 357 | v as Record<string, unknown>, |
| 358 | ); |
| 359 | } else { |
| 360 | out[k] = v; |
| 361 | } |
| 362 | } |
| 363 | return out; |
| 364 | } |
| 365 | |
| 366 | // ─── postgres-backed accessors ────────────────────────────────────────── |
| 367 | |
| 368 | const META_KEY = 'auth_config'; |
| 369 | |
| 370 | /** |
| 371 | * Read the current auth config for a project. Returns the defaults if no |
| 372 | * row exists yet (project enabled auth but hasn't visited the providers |
| 373 | * panel). |
| 374 | * |
| 375 | * Reads run inside a `runInProjectDatabase` transaction against the |
| 376 | * project's own DoltGres database. Single-statement read. |
| 377 | */ |
| 378 | /** |
| 379 | * DoltGres (via node-postgres) can hand a `jsonb` column back as a raw JSON |
| 380 | * *string* rather than a parsed object. Normalise both shapes so a read never |
| 381 | * silently fails on a perfectly-valid stored value. |
| 382 | */ |
| 383 | function normaliseJsonb(raw: unknown): unknown { |
| 384 | if (typeof raw !== 'string') return raw; |
| 385 | try { |
| 386 | return JSON.parse(raw); |
| 387 | } catch { |
| 388 | return raw; |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | export async function getAuthConfig(projectId: string): Promise<AuthConfig> { |
| 393 | const rows = await runInProjectDatabase<{ value: unknown }[]>(projectId, async (tx) => |
| 394 | (await tx.unsafe( |
| 395 | `SELECT value FROM "_briven_meta" WHERE key = $1 LIMIT 1`, |
| 396 | [META_KEY], |
| 397 | )) as { value: unknown }[], |
| 398 | ); |
| 399 | if (rows.length === 0) return DEFAULT_AUTH_CONFIG; |
| 400 | const parsed = authConfigSchema.safeParse(normaliseJsonb(rows[0]!.value)); |
| 401 | if (!parsed.success) { |
| 402 | // A stored row exists but doesn't match the current schema (older shape, |
| 403 | // or unparseable). Fall back to defaults so the read never throws — but |
| 404 | // LOG it, because this silent fallback is exactly what hid a real bug |
| 405 | // where valid saved config was read back as a string and discarded. |
| 406 | log.warn('auth_config_parse_failed_fallback_default', { |
| 407 | projectId, |
| 408 | issues: parsed.error.issues.slice(0, 3), |
| 409 | }); |
| 410 | return DEFAULT_AUTH_CONFIG; |
| 411 | } |
| 412 | return parsed.data; |
| 413 | } |
| 414 | |
| 415 | /** |
| 416 | * Apply a partial patch to the auth config and persist. Returns the new |
| 417 | * full config. Idempotent — calling with an empty patch is a no-op write |
| 418 | * that still validates the stored shape. |
| 419 | */ |
| 420 | export async function updateAuthConfig( |
| 421 | projectId: string, |
| 422 | patch: unknown, |
| 423 | ): Promise<AuthConfig> { |
| 424 | const current = await getAuthConfig(projectId); |
| 425 | const next = mergeAuthConfig(current, patch); |
| 426 | await runInProjectDatabase(projectId, async (tx) => { |
| 427 | // DoltGres upsert WITHOUT `ON CONFLICT` (its `excluded` pseudo-table is |
| 428 | // unsupported and ON CONFLICT behaviour has been unreliable): probe for an |
| 429 | // existing row, then UPDATE or INSERT. Atomic within the BEGIN/COMMIT. |
| 430 | const existing = (await tx.unsafe( |
| 431 | `SELECT 1 FROM "_briven_meta" WHERE key = $1 LIMIT 1`, |
| 432 | [META_KEY], |
| 433 | )) as unknown[]; |
| 434 | if (existing.length > 0) { |
| 435 | await tx.unsafe( |
| 436 | `UPDATE "_briven_meta" SET value = $2::jsonb WHERE key = $1`, |
| 437 | [META_KEY, JSON.stringify(next)], |
| 438 | ); |
| 439 | } else { |
| 440 | await tx.unsafe( |
| 441 | `INSERT INTO "_briven_meta" (key, value) VALUES ($1, $2::jsonb)`, |
| 442 | [META_KEY, JSON.stringify(next)], |
| 443 | ); |
| 444 | } |
| 445 | }); |
| 446 | return next; |
| 447 | } |
| 448 | |
| 449 | /** |
| 450 | * Read the `_briven_meta.auth_enabled` flag for a project. Set to true by |
| 451 | * `POST /v1/projects/:id/auth/enable`; absent or `false` until then. The |
| 452 | * dashboard's Overview page uses this to decide between the Enable CTA |
| 453 | * and the configured state. |
| 454 | */ |
| 455 | export async function isAuthEnabled(projectId: string): Promise<boolean> { |
| 456 | const rows = await runInProjectDatabase<{ value: unknown }[]>(projectId, async (tx) => |
| 457 | (await tx.unsafe( |
| 458 | `SELECT value FROM "_briven_meta" WHERE key = 'auth_enabled' LIMIT 1`, |
| 459 | )) as { value: unknown }[], |
| 460 | ); |
| 461 | if (rows.length === 0) return false; |
| 462 | return normaliseJsonb(rows[0]!.value) === true; |
| 463 | } |
| 464 | |
| 465 | // ─── enabled-providers signal (render-gating) ──────────────────────────── |
| 466 | |
| 467 | /** |
| 468 | * Built-in social provider keys, konnos-first (our own product leads). These |
| 469 | * map 1:1 to the secret name convention `<key>_client_secret` and the Better |
| 470 | * Auth `provider` value used to start a sign-in. |
| 471 | */ |
| 472 | export const SOCIAL_PROVIDER_KEYS = [ |
| 473 | 'konnos', |
| 474 | 'google', |
| 475 | 'github', |
| 476 | 'discord', |
| 477 | 'microsoft', |
| 478 | 'apple', |
| 479 | 'twitter', |
| 480 | 'linkedin', |
| 481 | 'gitlab', |
| 482 | 'bitbucket', |
| 483 | 'dropbox', |
| 484 | 'facebook', |
| 485 | 'spotify', |
| 486 | ] as const; |
| 487 | |
| 488 | /** One social provider key — the `<key>` half of `<key>_client_secret`. */ |
| 489 | export type SocialProviderKey = (typeof SOCIAL_PROVIDER_KEYS)[number]; |
| 490 | |
| 491 | /** Type guard: is `v` one of the built-in social provider keys? */ |
| 492 | export function isSocialProviderKey(v: unknown): v is SocialProviderKey { |
| 493 | return typeof v === 'string' && (SOCIAL_PROVIDER_KEYS as readonly string[]).includes(v); |
| 494 | } |
| 495 | |
| 496 | /** |
| 497 | * Does a custom-OIDC entry have a usable endpoint set — an issuer OR all three |
| 498 | * explicit endpoints? Mirrors the gate `buildGenericOAuthConfigs` applies in |
| 499 | * the pool, so the "enabled" signal never lists a provider the engine would |
| 500 | * skip. |
| 501 | */ |
| 502 | function oidcHasEndpoints(o: CustomOidcProvider): boolean { |
| 503 | return Boolean(o.issuer) || Boolean(o.authorizationUrl && o.tokenUrl && o.userinfoUrl); |
| 504 | } |
| 505 | |
| 506 | /** |
| 507 | * Pure gate: which providers are fully configured given a secret-presence |
| 508 | * probe. The SINGLE source of truth for "is this provider live?" — the exact |
| 509 | * same `enabled && clientId && secret-present` rule `createAuthInstance` wires |
| 510 | * with (custom-OIDC additionally needs an endpoint set). `hasSecret` is the |
| 511 | * `<name>` half of the (projectId,'auth',name) triple so this stays sync + |
| 512 | * unit-testable with a plain map. |
| 513 | * |
| 514 | * Returns provider keys for built-ins and the slug `id` for custom-OIDC, |
| 515 | * konnos-first then the rest, then custom-OIDC in declaration order. |
| 516 | */ |
| 517 | export function computeEnabledProviders( |
| 518 | config: AuthConfig, |
| 519 | hasSecret: (name: string) => boolean, |
| 520 | ): string[] { |
| 521 | const out: string[] = []; |
| 522 | for (const key of SOCIAL_PROVIDER_KEYS) { |
| 523 | const c = config.providers[key]; |
| 524 | if (c.enabled && c.clientId && hasSecret(`${key}_client_secret`)) out.push(key); |
| 525 | } |
| 526 | for (const o of config.customOidc ?? []) { |
| 527 | if (o.enabled && o.clientId && oidcHasEndpoints(o) && hasSecret(`oidc_${o.id}_client_secret`)) { |
| 528 | out.push(o.id); |
| 529 | } |
| 530 | } |
| 531 | return out; |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * Async wrapper over `computeEnabledProviders` that resolves secret presence |
| 536 | * from the encrypted control-plane store. Batches the presence probes (never |
| 537 | * decrypts) and feeds the result set into the one shared gate. Pass `preloaded` |
| 538 | * to reuse a config already in hand and skip the extra meta read. |
| 539 | */ |
| 540 | export async function listEnabledProviders( |
| 541 | projectId: string, |
| 542 | preloaded?: AuthConfig, |
| 543 | ): Promise<string[]> { |
| 544 | const config = preloaded ?? (await getAuthConfig(projectId)); |
| 545 | const names = [ |
| 546 | ...SOCIAL_PROVIDER_KEYS.map((k) => `${k}_client_secret`), |
| 547 | ...(config.customOidc ?? []).map((o) => `oidc_${o.id}_client_secret`), |
| 548 | ]; |
| 549 | const present = await Promise.all( |
| 550 | names.map((name) => hasTenantSecret(projectId, 'auth', name)), |
| 551 | ); |
| 552 | const set = new Set(names.filter((_, i) => present[i])); |
| 553 | return computeEnabledProviders(config, (name) => set.has(name)); |
| 554 | } |
| 555 | |
| 556 | /** |
| 557 | * Shape of the public, UNAUTHENTICATED branding/config payload. Carries only |
| 558 | * non-secret presentation + the enabled-provider signal — NEVER a clientId, |
| 559 | * secret, toggle, or endpoint. The hosted pages + SDK read this to decide which |
| 560 | * OAuth buttons to render. |
| 561 | */ |
| 562 | export interface AuthBrandingPublicPayload { |
| 563 | primaryColor: string; |
| 564 | senderName: string; |
| 565 | /** Enabled provider keys / custom-OIDC slugs — the `provider` start value. */ |
| 566 | socialProviders: string[]; |
| 567 | /** Display labels for the enabled custom-OIDC entries (built-ins self-label). */ |
| 568 | customOidc: Array<{ id: string; displayName: string }>; |
| 569 | /** Turnstile bot-protection config (public site key only). */ |
| 570 | turnstile: { |
| 571 | enabled: boolean; |
| 572 | siteKey: string | null; |
| 573 | }; |
| 574 | } |
| 575 | |
| 576 | /** |
| 577 | * Build the public branding/config payload from a config + its already-computed |
| 578 | * enabled list. Deliberately projects ONLY safe fields so a clientId/secret can |
| 579 | * never leak through this unauthenticated surface. |
| 580 | */ |
| 581 | export function buildAuthBrandingPublicPayload( |
| 582 | config: AuthConfig, |
| 583 | enabledProviders: string[], |
| 584 | ): AuthBrandingPublicPayload { |
| 585 | const enabled = new Set(enabledProviders); |
| 586 | return { |
| 587 | primaryColor: config.branding.primaryColor, |
| 588 | senderName: config.branding.senderName, |
| 589 | socialProviders: enabledProviders, |
| 590 | customOidc: (config.customOidc ?? []) |
| 591 | .filter((o) => enabled.has(o.id)) |
| 592 | .map((o) => ({ id: o.id, displayName: o.displayName })), |
| 593 | turnstile: { |
| 594 | enabled: config.turnstile.enabled, |
| 595 | siteKey: config.turnstile.siteKey, |
| 596 | }, |
| 597 | }; |
| 598 | } |
| 599 | |
| 600 | // ─── custom-OIDC CRUD ──────────────────────────────────────────────────── |
| 601 | |
| 602 | /** |
| 603 | * Validate + upsert a single custom-OIDC provider (keyed by `id`) into the |
| 604 | * project's config, persisting via `updateAuthConfig`. Replacing the whole |
| 605 | * `customOidc` array is intentional — `deepMerge` treats arrays as wholesale |
| 606 | * replacements, so we recompute the array here. Returns the new full config. |
| 607 | * Throws `ValidationError` on a malformed entry (bad slug, missing displayName…). |
| 608 | */ |
| 609 | export async function upsertCustomOidcProvider( |
| 610 | projectId: string, |
| 611 | entry: unknown, |
| 612 | ): Promise<AuthConfig> { |
| 613 | const parsed = customOidcProviderConfig.safeParse(entry); |
| 614 | if (!parsed.success) { |
| 615 | throw new ValidationError('custom OIDC provider failed validation', { |
| 616 | issues: parsed.error.issues, |
| 617 | }); |
| 618 | } |
| 619 | const e = parsed.data; |
| 620 | const current = await getAuthConfig(projectId); |
| 621 | const list = (current.customOidc ?? []).filter((o) => o.id !== e.id); |
| 622 | return updateAuthConfig(projectId, { customOidc: [...list, e] }); |
| 623 | } |
| 624 | |
| 625 | /** |
| 626 | * Remove a custom-OIDC provider by `id`. Idempotent — removing an absent id is |
| 627 | * a no-op write that still re-validates the stored shape. The encrypted secret |
| 628 | * (`oidc_<id>_client_secret`) is left in place (harmless: nothing references it |
| 629 | * once the entry is gone, and the gate requires the entry to exist). |
| 630 | */ |
| 631 | export async function removeCustomOidcProvider( |
| 632 | projectId: string, |
| 633 | id: string, |
| 634 | ): Promise<AuthConfig> { |
| 635 | const current = await getAuthConfig(projectId); |
| 636 | const list = (current.customOidc ?? []).filter((o) => o.id !== id); |
| 637 | return updateAuthConfig(projectId, { customOidc: list }); |
| 638 | } |
| 639 | |
| 640 | /** |
| 641 | * Visible-for-tests: the raw zod schema. Lets test files exercise |
| 642 | * validation without going through the postgres path. |
| 643 | */ |
| 644 | export const __authConfigSchema = authConfigSchema; |
| 645 | |
| 646 | /** Visible-for-tests: the custom-OIDC element schema. */ |
| 647 | export const __customOidcSchema = customOidcProviderConfig; |