auth-tenant-pool.ts1174 lines · main
1import { randomBytes } from 'node:crypto';
2
3import { betterAuth } from 'better-auth';
4import { drizzleAdapter } from 'better-auth/adapters/drizzle';
5import { emailOTP, genericOAuth, jwt, magicLink, twoFactor } from 'better-auth/plugins';
6import { passkey } from '@better-auth/passkey';
7import { drizzle } from 'drizzle-orm/node-postgres';
8import pg from 'pg';
9
10import { authSchema } from '../db/auth-customer-schema.js';
11import { dbNameFor } from '../db/data-plane.js';
12import { env } from '../env.js';
13import { log } from '../lib/logger.js';
14import { getRequestContext } from '../lib/request-context.js';
15import { captureSignupGeo } from './signup-geo.js';
16import {
17 checkEmailGate,
18 getUserSecurityState,
19 isUserBlocked,
20} from './auth-security.js';
21import { runInProjectDatabase } from '../db/data-plane.js';
22import {
23 sendBrivenAuthEmailVerification,
24 sendBrivenAuthMagicLink,
25 sendBrivenAuthOtp,
26 sendBrivenAuthPasswordReset,
27} from './auth-mailer.js';
28import { ensureTenantAuthSchema } from './auth-provisioning.js';
29import { brivenOwnOrigins, originsForProject } from './auth-origin-allowlist.js';
30import { publishEvent, type AuthEventType } from './outbound-webhooks.js';
31import { getTenantSecret } from './tenant-secrets.js';
32import { maybeAutoLinkOAuthAccount } from './auth-account-linking.js';
33import { maybeAlertNewDevice } from './auth-device-tracking.js';
34import { mustChangePassword } from './auth-password-policy.js';
35import {
36 computeEnabledProviders,
37 DEFAULT_AUTH_CONFIG,
38 getAuthConfig,
39 type AuthConfig,
40} from './tenant-config-store.js';
41import { TenantInstancePool } from './tenant-instance-pool.js';
42
43/**
44 * Per-tenant Better Auth instance pool — the briven auth-specific factory
45 * that wires `TenantInstancePool` + Drizzle + Better Auth into a working
46 * customer-facing instance per project.
47 *
48 * Lifecycle (ARCHITECTURE.md §3):
49 * - first `getAuthInstance(projectId)` lazily creates the pool
50 * - cache miss opens a per-project `pg` pool bound to the project's own
51 * DoltGres DATABASE (`proj_<id>`), wraps it in a Drizzle client, and
52 * constructs a `betterAuth({...})` instance
53 * - cache hit returns the warm instance
54 * - eviction (idle, LRU, or forced) closes the per-project pool via
55 * `closePool()` so connections are released
56 *
57 * Tenancy: database-per-project (ADR 0001). The auth tables live in the
58 * project's own database `public` schema, so binding the pool to that
59 * database is enough — no `search_path` pinning, and no schema-per-project.
60 * Uses `pg` (node-postgres), not postgres.js, which desyncs with DoltGres.
61 *
62 * v0 provider configuration: email + password only. OAuth providers,
63 * magic link, OTP, and passkeys land in the next step once the per-tenant
64 * config storage + mittera mailer wiring are in place (BUILD_PLAN.md
65 * §13 step 4).
66 */
67
68/**
69 * Type inferred from the factory's return rather than declared explicitly.
70 * Better Auth's `Auth<TOptions>` is invariant in its generic parameter, so
71 * a hand-written `ReturnType<typeof betterAuth>` widens to
72 * `Auth<BetterAuthOptions>` and refuses assignment from the narrow
73 * inferred type of our specific call. Letting TypeScript derive the
74 * type keeps the precise inference all the way through the pool.
75 */
76export type BrivenAuthInstance = Awaited<ReturnType<typeof createAuthInstance>>;
77
78let pool: TenantInstancePool<BrivenAuthInstance> | null = null;
79let processSecret: string | null = null;
80
81/**
82 * In-memory map for OAuth auto-linking (Gap Fix #4).
83 * When `user.create.after` detects a duplicate email and moves the
84 * OAuth account to an existing user, it stores the mapping here so
85 * that the subsequent `session.create.before` hook can redirect the
86 * session to the correct (existing) user id.
87 * Entries are consumed (deleted) on first read in session.create.before.
88 */
89const oauthLinkMap = new Map<string, string>();
90
91/**
92 * Resolve the Better Auth signing secret. Same fallback chain as the
93 * control-plane auth (`apps/api/src/lib/auth.ts`): real env value in
94 * prod, ephemeral per-process in dev.
95 */
96function authSecret(): string {
97 if (env.BRIVEN_BETTER_AUTH_SECRET) return env.BRIVEN_BETTER_AUTH_SECRET;
98 if (env.BRIVEN_ENV !== 'development') {
99 throw new Error(
100 'BRIVEN_BETTER_AUTH_SECRET is required outside development for briven auth',
101 );
102 }
103 if (!processSecret) {
104 processSecret = randomBytes(32).toString('hex');
105 log.warn('briven_auth_ephemeral_secret', {
106 reason: 'dev-only fallback — sessions will not survive restart',
107 });
108 }
109 return processSecret;
110}
111
112// ─── hosted-pages URL helpers ────────────────────────────────────────────
113
114/**
115 * First-party proxy path on the **customer app** that forwards to
116 * `/v1/auth-tenant/*` (see `briven auth scaffold` + Konnos).
117 * Magic-link verify must open here so the browser shows the project host
118 * (valid cert on their domain) and the session cookie can be first-party.
119 */
120export const APP_AUTH_PROXY_PREFIX = '/api/auth/v1/auth-tenant';
121
122/**
123 * Base URL used for **hosted pages** (reset password UI) and as last-resort
124 * fallback when no app origin is known.
125 *
126 * - Custom domain (customer owns DNS+TLS): `https://auth.their.app`
127 * - Magic-link / email-verify **action** links prefer the app origin via
128 * `rewriteAuthActionUrlToApp` — not this host (users must not land on a
129 * bare api.briven.tech JSON page).
130 * - Fallback: API origin (valid public cert).
131 */
132export function hostedAuthBaseUrl(
133 projectId: string,
134 config?: { customAuthDomain?: string | null },
135): string {
136 if (config?.customAuthDomain) {
137 return `https://${config.customAuthDomain}`;
138 }
139 // projectId reserved for future path-style hosted pages if needed
140 void projectId;
141 return env.BRIVEN_API_ORIGIN;
142}
143
144function isLocalhostOrigin(origin: string): boolean {
145 try {
146 const h = new URL(origin).hostname;
147 return h === 'localhost' || h === '127.0.0.1' || h.endsWith('.localhost');
148 } catch {
149 return false;
150 }
151}
152
153/**
154 * Pick the best absolute https origin for email click-through links.
155 * Prefer callbackURL's origin (where the user asked to land), else the first
156 * non-localhost allowed app domain, else null (caller falls back).
157 */
158export function resolveAppOriginForAuthEmail(
159 callbackOrAbsoluteUrl: string | null | undefined,
160 projectOrigins: string[],
161): string | null {
162 if (callbackOrAbsoluteUrl) {
163 try {
164 const u = new URL(callbackOrAbsoluteUrl);
165 if (u.protocol === 'http:' || u.protocol === 'https:') {
166 // Prefer real public host over localhost when a prod origin is registered
167 if (!isLocalhostOrigin(u.origin)) return u.origin;
168 }
169 } catch {
170 // ignore
171 }
172 }
173 for (const raw of projectOrigins) {
174 try {
175 const normalised = raw.replace('://*.', '://');
176 const o = new URL(normalised).origin;
177 if (!isLocalhostOrigin(o) && (o.startsWith('https:') || o.startsWith('http:'))) {
178 return o;
179 }
180 } catch {
181 // skip
182 }
183 }
184 // Last resort among allowlist: localhost (dev)
185 for (const raw of projectOrigins) {
186 try {
187 const o = new URL(raw.replace('://*.', '://')).origin;
188 if (isLocalhostOrigin(o)) return o;
189 } catch {
190 // skip
191 }
192 }
193 if (callbackOrAbsoluteUrl) {
194 try {
195 const u = new URL(callbackOrAbsoluteUrl);
196 if (u.protocol === 'http:' || u.protocol === 'https:') return u.origin;
197 } catch {
198 // ignore
199 }
200 }
201 return null;
202}
203
204/**
205 * Rewrite Better Auth action URLs (magic-link verify, email verify) so the
206 * email link opens on the **customer project host**, not api.briven.tech.
207 *
208 * Shape:
209 * https://pay.example.com/api/auth/v1/auth-tenant/magic-link/verify?token=…&callbackURL=…&briven_project_id=…
210 *
211 * The app must proxy `/api/auth/v1/auth-tenant/*` → api (scaffold + Konnos).
212 * Without a proxy the click 404s — still better than a dead API landing page
213 * if the app follows the one-path setup. Fallback: customAuthDomain, then API.
214 */
215export function rewriteAuthActionUrlToApp(
216 url: string,
217 projectId: string,
218 config?: { customAuthDomain?: string | null },
219 projectOrigins: string[] = [],
220): string {
221 try {
222 const original = new URL(url);
223 const callback = original.searchParams.get('callbackURL');
224 const appOrigin = resolveAppOriginForAuthEmail(callback, projectOrigins);
225
226 if (appOrigin) {
227 // /v1/auth-tenant/magic-link/verify → /api/auth/v1/auth-tenant/magic-link/verify
228 let suffix = original.pathname;
229 const marker = '/v1/auth-tenant';
230 const idx = suffix.indexOf(marker);
231 if (idx >= 0) {
232 suffix = suffix.slice(idx + marker.length) || '/';
233 }
234 if (!suffix.startsWith('/')) suffix = `/${suffix}`;
235 const path = `${APP_AUTH_PROXY_PREFIX}${suffix}`;
236 return new URL(path + original.search, appOrigin).toString();
237 }
238
239 if (config?.customAuthDomain) {
240 return rewriteToHostedUrl(url, projectId, config);
241 }
242 // No app origin registered yet — keep API host (valid TLS) rather than
243 // broken *.auth.briven.tech. Still tag tenant for resolveTenant.
244 return rewriteToHostedUrl(url, projectId, config);
245 } catch {
246 return url;
247 }
248}
249
250/**
251 * The hosted "choose a new password" link Better Auth emails on a reset.
252 * Contract (pinned by auth-tenant-pool-plugins.test.ts):
253 * <hostedAuthBaseUrl>/auth/<projectId>/new-password?token=<token>
254 * The token is URL-encoded so reserved characters survive the query string.
255 */
256export function resetPasswordUrl(
257 projectId: string,
258 token: string,
259 config?: { customAuthDomain?: string | null },
260): string {
261 return `${hostedAuthBaseUrl(projectId, config)}/auth/${projectId}/new-password?token=${encodeURIComponent(token)}`;
262}
263
264// ─── config-driven plugin loading ────────────────────────────────────────
265
266/**
267 * Minimal structural shape of a Better Auth plugin — every plugin exposes a
268 * stable string `id`. We only need the `id` for the config-gating contract +
269 * the plugins array, so this narrow local type keeps the return precise
270 * without importing Better Auth's internal `BetterAuthPlugin` type (which is
271 * invariant and fights assignment across the version boundary, same reason
272 * `BrivenAuthInstance` is inferred rather than declared).
273 */
274type TenantAuthPlugin = { id: string } & Record<string, unknown>;
275
276/**
277 * Tag a Better-Auth-generated action URL with the tenant id so a plain browser
278 * navigation (an email-link click) can still resolve the tenant. Better Auth
279 * builds these links from `baseURL` (api.briven.tech) with no per-tenant marker,
280 * and a link click — unlike the SDK — cannot attach the `x-briven-project-id`
281 * header that the `/v1/auth-tenant/*` bridge normally reads. So we stamp
282 * `briven_project_id` into the query string; `resolveTenant` (auth-service.ts)
283 * reads it as the header's fallback. The id (`p_…`) is a PUBLIC identifier, never
284 * a secret — the one-time token in the same link stays the actual credential —
285 * so it is safe to place in a URL.
286 */
287export function tagTenantUrl(url: string, projectId: string): string {
288 try {
289 const u = new URL(url);
290 u.searchParams.set('briven_project_id', projectId);
291 return u.toString();
292 } catch {
293 // Not an absolute URL (should never happen for a Better Auth link) — leave it.
294 return url;
295 }
296}
297
298/**
299 * Rewrite a Better-Auth-generated URL from the API origin to the project's
300 * hosted auth base URL. This makes magic-link verify links, email-verification
301 * links, and OAuth callbacks land on the customer's auth subdomain
302 * (e.g. auth.murphus.eu) instead of api.briven.tech.
303 */
304export function rewriteToHostedUrl(
305 url: string,
306 projectId: string,
307 config?: { customAuthDomain?: string | null },
308): string {
309 try {
310 const original = new URL(url);
311 const base = new URL(hostedAuthBaseUrl(projectId, config));
312 const rewritten = new URL(original.pathname + original.search, base);
313 return rewritten.toString();
314 } catch {
315 return url;
316 }
317}
318
319/**
320 * Strip the "auth." prefix from a custom auth subdomain to get the parent
321 * domain for cookie scoping. auth.murphus.eu → murphus.eu.
322 * Returns null if the domain does not start with "auth.".
323 */
324function parentDomainFromAuthSubdomain(domain: string): string | undefined {
325 if (domain.toLowerCase().startsWith('auth.')) {
326 return domain.slice(5);
327 }
328 return undefined;
329}
330
331/**
332 * Approximate the registrable domain (eTLD+1) for WebAuthn rpID.
333 * WebAuthn requires rpID to be a suffix of the page host — e.g. for
334 * `code.konnos.org` the rpID must be `konnos.org` or `code.konnos.org`,
335 * NEVER `briven.tech` (browsers reject that as a security error).
336 *
337 * Not a full Public Suffix List: last-two labels for normal TLDs, last-three
338 * for a small set of multi-part TLDs (co.uk, com.au, …). Good enough for
339 * customer app domains on the allowlist.
340 */
341export function registrableDomainFromHost(host: string): string | null {
342 const h = host.trim().toLowerCase().replace(/^\*\./, '');
343 if (!h || h.includes('..') || h.startsWith('.') || h.endsWith('.')) return null;
344 if (h === 'localhost' || h.endsWith('.localhost')) return 'localhost';
345 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return h; // IPv4
346 const parts = h.split('.').filter(Boolean);
347 if (parts.length < 2) return h;
348 const multiPartTlds = new Set([
349 'co.uk',
350 'org.uk',
351 'ac.uk',
352 'gov.uk',
353 'com.au',
354 'net.au',
355 'org.au',
356 'co.nz',
357 'com.br',
358 'co.jp',
359 'com.mx',
360 'co.za',
361 ]);
362 const last2 = parts.slice(-2).join('.');
363 if (multiPartTlds.has(last2) && parts.length >= 3) {
364 return parts.slice(-3).join('.');
365 }
366 return last2;
367}
368
369/**
370 * Pick the WebAuthn rpID for a tenant.
371 *
372 * Priority:
373 * 1. customAuthDomain parent (auth.example.com → example.com)
374 * 2. first non-localhost allowed app origin’s registrable domain
375 * 3. briven.tech in production / localhost in dev (hosted apps only)
376 */
377export function resolvePasskeyRpId(
378 config: { customAuthDomain?: string | null },
379 projectOrigins: string[],
380): string {
381 const custom = config.customAuthDomain?.trim() || null;
382 if (custom) {
383 return parentDomainFromAuthSubdomain(custom) ?? custom;
384 }
385
386 const candidates: string[] = [];
387 for (const raw of projectOrigins) {
388 try {
389 // wildcards stored as https://*.example.com
390 const normalised = raw.replace('://*.', '://');
391 const host = new URL(normalised).hostname;
392 const reg = registrableDomainFromHost(host);
393 if (reg && reg !== 'localhost') candidates.push(reg);
394 } catch {
395 // skip malformed
396 }
397 }
398
399 // Prefer the customer's own domain over briven.tech if both appear.
400 const nonBriven = candidates.find((d) => d !== 'briven.tech' && !d.endsWith('.briven.tech'));
401 if (nonBriven) return nonBriven;
402 if (candidates[0]) return candidates[0];
403
404 return env.BRIVEN_ENV === 'production' ? 'briven.tech' : 'localhost';
405}
406
407/**
408 * Build the per-tenant Better Auth plugins array from the project's stored
409 * auth config. ONLY the passwordless methods the customer has toggled on are
410 * loaded — so `POST /v1/auth-tenant/sign-in/magic-link` (and the OTP / passkey
411 * routes) only register when the matching toggle is enabled. Social / OIDC
412 * providers are handled separately by `buildGenericOAuthConfigs`.
413 *
414 * The mailer callbacks close over `projectId` so each tenant's emails render
415 * with its own branding + sender domain (auth-mailer.ts resolves both).
416 */
417export function buildTenantAuthPlugins(
418 projectId: string,
419 config: AuthConfig,
420 projectOrigins: string[] = [],
421): TenantAuthPlugin[] {
422 const plugins: TenantAuthPlugin[] = [];
423 const p = config.providers;
424
425 if (p.magicLink.enabled) {
426 plugins.push(
427 magicLink({
428 expiresIn: p.magicLink.expiryMinutes * 60,
429 sendMagicLink: async ({ email, url }) => {
430 // Email must open on the **project URL** (via first-party proxy), not
431 // a bare api.briven.tech page. Tenant id stays in the query string.
432 const appUrl = rewriteAuthActionUrlToApp(url, projectId, config, projectOrigins);
433 await sendBrivenAuthMagicLink(projectId, email, tagTenantUrl(appUrl, projectId));
434 },
435 }) as unknown as TenantAuthPlugin,
436 );
437 }
438
439 if (p.emailOtp.enabled) {
440 plugins.push(
441 emailOTP({
442 otpLength: p.emailOtp.codeLength,
443 expiresIn: p.emailOtp.expiryMinutes * 60,
444 sendVerificationOTP: async ({ email, otp }) => {
445 await sendBrivenAuthOtp(projectId, email, otp);
446 },
447 }) as unknown as TenantAuthPlugin,
448 );
449 }
450
451 if (p.passkey.enabled) {
452 // rpID must be a registrable domain suffix of the page origin.
453 // Derive from allowed app domains (e.g. code.konnos.org → konnos.org).
454 // Hardcoding briven.tech broke every customer-domain passkey (browser
455 // SecurityError: rpId is not a valid domain for this origin).
456 // Do NOT pin `origin` on the plugin: verify uses the browser Origin header.
457 const rpID = resolvePasskeyRpId(config, projectOrigins);
458 plugins.push(
459 passkey({
460 rpID,
461 rpName: config.branding.senderName,
462 }) as unknown as TenantAuthPlugin,
463 );
464 }
465
466 if (config.twoFactor.enabled) {
467 plugins.push(
468 twoFactor({
469 issuer: config.twoFactor.issuer ?? 'Briven Auth',
470 backupCodeOptions: {
471 amount: 10,
472 },
473 }) as unknown as TenantAuthPlugin,
474 );
475 }
476
477 return plugins;
478}
479
480/**
481 * Compose the FINAL per-tenant plugins array: unconditional core plugins
482 * first, then the config-gated ones. The jwt plugin is core — every project
483 * gets `GET /v1/auth-tenant/token` (session → signed JWT) and
484 * `GET /v1/auth-tenant/jwks` (public keys) with NO toggle, so backend
485 * services can verify briven auth sessions statelessly. Options stay at the
486 * plugin defaults on purpose: EdDSA/Ed25519 keys, 15-minute expiry, issuer +
487 * audience = the auth baseURL origin, and the stored private key encrypted
488 * with the instance secret (each tenant derives its own).
489 *
490 * Split out from `createAuthInstance` (which needs a live DB) so the
491 * unconditional-core contract is unit-testable alongside the config gate.
492 */
493export function assembleTenantPlugins(
494 passwordlessPlugins: TenantAuthPlugin[],
495 genericOAuthConfigs: ReturnType<typeof buildGenericOAuthConfigs>,
496 config: AuthConfig = DEFAULT_AUTH_CONFIG,
497): TenantAuthPlugin[] {
498 const jwtClaims = config.jwtClaims ?? {};
499 return [
500 jwt({
501 jwt: {
502 definePayload: (_session) => ({
503 ...jwtClaims,
504 }),
505 },
506 }) as unknown as TenantAuthPlugin,
507 ...passwordlessPlugins,
508 ...(genericOAuthConfigs.length > 0
509 ? [genericOAuth({ config: genericOAuthConfigs as never }) as unknown as TenantAuthPlugin]
510 : []),
511 ];
512}
513
514// ─── lifecycle event → webhook dispatch ──────────────────────────────────
515
516/**
517 * Injected sink for auth lifecycle events. Kept as a plain function type so
518 * `buildAuthDatabaseHooks` is pure + unit-testable (the test passes a
519 * recording dispatcher); the pool wires the real webhook fan-out.
520 */
521export type AuthEventDispatcher = (
522 projectId: string,
523 eventType: string,
524 payload: Record<string, unknown>,
525) => void | Promise<void>;
526
527/**
528 * The production dispatcher: fan an auth lifecycle event out to the project's
529 * outbound webhook subscribers. Failures are logged and swallowed — a webhook
530 * hiccup must NEVER break the customer's login/signup flow (the sign-in still
531 * succeeds; the event just doesn't fire).
532 */
533const dispatchAuthEvent: AuthEventDispatcher = async (projectId, eventType, payload) => {
534 try {
535 await publishEvent({ projectId, eventType: eventType as AuthEventType, payload });
536 } catch (err) {
537 log.warn('briven_auth_event_dispatch_failed', {
538 projectId,
539 eventType,
540 message: err instanceof Error ? err.message : String(err),
541 });
542 }
543};
544
545/** Coerce a possible Date to an ISO string, else pass through / undefined. */
546function isoOrUndefined(v: unknown): string | undefined {
547 if (v instanceof Date) return v.toISOString();
548 if (typeof v === 'string') return v;
549 return undefined;
550}
551
552/**
553 * Check whether a user has enrolled any MFA method (TOTP or passkey).
554 * Used by the MFA enforcement gate in session.create.before.
555 */
556async function userHasMfaEnrolled(projectId: string, userId: string): Promise<boolean> {
557 const rows = await runInProjectDatabase<
558 Array<{ totp_count: number; passkey_count: number }>
559 >(projectId, async (tx) =>
560 tx.unsafe(
561 `SELECT
562 (SELECT COUNT(*) FROM "_briven_auth_two_factors" WHERE user_id = $1) AS totp_count,
563 (SELECT COUNT(*) FROM "_briven_auth_passkeys" WHERE user_id = $1) AS passkey_count`,
564 [userId] as never,
565 ) as never,
566 );
567 const row = rows[0];
568 if (!row) return false;
569 return row.totp_count > 0 || row.passkey_count > 0;
570}
571
572/**
573 * Better Auth `databaseHooks` that fan lifecycle transitions out to the
574 * injected dispatcher as `auth.*` webhook events. Payloads are deliberately
575 * minimal — id/email/timestamps only — so no password hash or session token
576 * ever rides the webhook (asserted by the test).
577 *
578 * Also enforces security policy: ban checks, MFA enforcement, and
579 * email-gate defense-in-depth on user creation.
580 */
581export function buildAuthDatabaseHooks(
582 projectId: string,
583 dispatch: AuthEventDispatcher,
584 config: AuthConfig = DEFAULT_AUTH_CONFIG,
585) {
586 return {
587 user: {
588 create: {
589 before: async (user: { email: string }) => {
590 // Defense-in-depth: re-validate email against allowlist/blocklist
591 // even if the bridge already checked it. Catches OAuth sign-ups
592 // where the email arrives from the provider.
593 const gate = checkEmailGate(user.email, {
594 allowedDomains: config.security.allowedEmailDomains,
595 blockedDomains: config.security.blockedEmailDomains,
596 blockDisposable: config.security.blockDisposableEmails,
597 blockSubaddresses: config.security.blockEmailSubaddresses,
598 });
599 if (!gate.allowed) {
600 throw new Error(gate.reason ?? 'sign-up not allowed');
601 }
602 },
603 after: async (user: { id: string; email: string; createdAt?: unknown }) => {
604 // Automatic OAuth account linking — if another user already exists
605 // with the same email, move the OAuth account(s) to that user.
606 // Must never break sign-up if the data plane is unavailable.
607 let linkResult: { linkedToUserId: string } | null = null;
608 try {
609 linkResult = await maybeAutoLinkOAuthAccount(projectId, user.id, user.email);
610 } catch (err) {
611 log.warn('briven_auth_oauth_auto_link_failed', {
612 projectId,
613 userId: user.id,
614 message: err instanceof Error ? err.message : String(err),
615 });
616 }
617 if (linkResult) {
618 // Stash the mapping so session.create.before can redirect the
619 // session to the existing user.
620 oauthLinkMap.set(user.id, linkResult.linkedToUserId);
621 }
622
623 // Control-plane sign-up geo capture (admin-only SEO analytics).
624 // Fire-and-forget: captureSignupGeo swallows all its own errors, so
625 // an unawaited promise can never reject or delay the auth response.
626 // The IP rides an AsyncLocalStorage context set by the auth-tenant
627 // bridge (routes/auth-service.ts) since this hook can't read the
628 // HTTP request itself.
629 void captureSignupGeo({
630 projectId,
631 userId: linkResult?.linkedToUserId ?? user.id,
632 email: user.email,
633 ip: getRequestContext()?.ip ?? null,
634 });
635
636 // Lazily create an empty metadata row for the new user so
637 // getUserMetadata never returns null downstream.
638 void runInProjectDatabase(projectId, async (tx) => {
639 await tx.unsafe(
640 `INSERT INTO "_briven_auth_user_metadata" (id, user_id, public_metadata, private_metadata, created_at, updated_at)
641 VALUES (gen_random_uuid()::text, $1, '{}'::jsonb, '{}'::jsonb, now(), now())
642 ON CONFLICT (user_id) DO NOTHING`,
643 [linkResult?.linkedToUserId ?? user.id] as never,
644 );
645 }).catch(() => {
646 // Swallow — metadata seeding must never break sign-up.
647 });
648
649 await dispatch(projectId, 'auth.signup', {
650 userId: linkResult?.linkedToUserId ?? user.id,
651 email: user.email,
652 createdAt: isoOrUndefined(user.createdAt),
653 });
654 },
655 },
656 },
657 session: {
658 create: {
659 before: async (session: { userId: string }) => {
660 // Ban / suspension check. If the user is blocked, prevent session
661 // creation so they cannot sign in even with valid credentials.
662 const sec = await getUserSecurityState(projectId, session.userId);
663 const blocked = isUserBlocked(sec);
664 if (blocked.blocked) {
665 throw new Error(blocked.reason ?? 'account access denied');
666 }
667
668 // Password expiry / admin force-reset (Sprint S3).
669 try {
670 const must = await mustChangePassword(projectId, session.userId);
671 if (must.required) {
672 throw new Error(must.reason ?? 'password change required');
673 }
674 } catch (err) {
675 if (err instanceof Error && /password/i.test(err.message)) throw err;
676 // Table missing / data-plane blip — do not brick sign-in.
677 }
678
679 // MFA enforcement. When twoFactor.required is true, every user
680 // must have enrolled TOTP or registered a passkey before they
681 // can create a session. Passkeys count as MFA (possession +
682 // biometric), so passkey users bypass the TOTP challenge.
683 if (config.twoFactor.required) {
684 const hasMfa = await userHasMfaEnrolled(projectId, session.userId);
685 if (!hasMfa) {
686 throw new Error(
687 'multi-factor authentication is required. please enroll a passkey or set up an authenticator app.',
688 );
689 }
690 }
691
692 // OAuth auto-link redirect: if this session is being created for a
693 // user that was just auto-linked to an existing user, redirect the
694 // session to the existing user id.
695 const linkedUserId = oauthLinkMap.get(session.userId);
696 if (linkedUserId) {
697 session.userId = linkedUserId;
698 oauthLinkMap.delete(session.userId);
699 }
700 },
701 after: async (session: { id: string; userId: string; createdAt?: unknown }) => {
702 // Track session activity for inactivity timeout.
703 if (config.session.inactivityTimeoutMinutes > 0) {
704 void runInProjectDatabase(projectId, async (tx) => {
705 await tx.unsafe(
706 `INSERT INTO "_briven_auth_session_activity" (id, session_id, last_active_at, created_at, updated_at)
707 VALUES (gen_random_uuid()::text, $1, now(), now(), now())
708 ON CONFLICT (session_id) DO UPDATE SET last_active_at = now(), updated_at = now()`,
709 [session.id] as never,
710 );
711 }).catch(() => {
712 // Swallow — activity tracking must never break sign-in.
713 });
714 }
715
716 // Device tracking — alert on new device (fire-and-forget).
717 const ctx = getRequestContext();
718 if (ctx?.userAgent) {
719 void runInProjectDatabase(projectId, async (tx) => {
720 const rows = (await tx.unsafe(
721 `SELECT email FROM "_briven_auth_users" WHERE id = $1 LIMIT 1`,
722 [session.userId] as never,
723 )) as Array<{ email: string }>;
724 if (rows[0]) {
725 await maybeAlertNewDevice(projectId, session.userId, rows[0].email, ctx.userAgent);
726 }
727 }).catch(() => {
728 // Swallow — device tracking must never break sign-in.
729 });
730 }
731
732 await dispatch(projectId, 'auth.signin', {
733 sessionId: session.id,
734 userId: session.userId,
735 createdAt: isoOrUndefined(session.createdAt),
736 });
737 },
738 },
739 delete: {
740 after: async (
741 session: { id: string; userId: string },
742 ctx?: { path?: string } | null,
743 ) => {
744 // Clean up session activity row when session is deleted.
745 if (config.session.inactivityTimeoutMinutes > 0) {
746 void runInProjectDatabase(projectId, async (tx) => {
747 await tx.unsafe(
748 `DELETE FROM "_briven_auth_session_activity" WHERE session_id = $1`,
749 [session.id] as never,
750 );
751 }).catch(() => {
752 // Swallow.
753 });
754 }
755
756 // A user-initiated /sign-out vs an admin/self /revoke-session are
757 // distinct events downstream; fall back to sign-out when Better
758 // Auth gives no context path.
759 const path = ctx?.path ?? '';
760 const eventType = path.includes('revoke')
761 ? 'auth.session.revoked'
762 : 'auth.signout';
763 await dispatch(projectId, eventType, {
764 sessionId: session.id,
765 userId: session.userId,
766 });
767 },
768 },
769 },
770 };
771}
772
773// ─── social / custom-OIDC (genericOAuth) config building ──────────────────
774
775/** The `<issuer>/.well-known/openid-configuration` discovery URL. */
776function discoveryUrlFor(issuer: string): string {
777 return `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
778}
779
780/**
781 * A single Better Auth `genericOAuth` provider config entry. Structural shape
782 * only — the real plugin validates the full option set.
783 */
784interface GenericOAuthEntry {
785 providerId: string;
786 clientId: string;
787 clientSecret: string;
788 scopes?: string[];
789 pkce?: boolean;
790 discoveryUrl?: string;
791 authorizationUrl?: string;
792 tokenUrl?: string;
793 userInfoUrl?: string;
794}
795
796/**
797 * Resolved secrets for the OIDC/social providers, split so the pure gate
798 * below stays sync + unit-testable: `konnos` is the konnos client secret (or
799 * null), `oidc` maps each custom-OIDC slug → its client secret (or null).
800 */
801export interface OAuthSecretBundle {
802 konnos: string | null;
803 oidc: Record<string, string | null>;
804}
805
806/**
807 * Build the `genericOAuth` `config` array from the tenant's auth config +
808 * resolved secrets. Every entry passes the SAME gate the `listEnabledProviders`
809 * signal uses: enabled + clientId + a stored secret (custom-OIDC additionally
810 * needs an issuer OR all three explicit endpoints). konnos is emitted first so
811 * our own product leads, then custom-OIDC in declaration order.
812 *
813 * Pure + secret-injected on purpose (the pool resolves the secrets, the test
814 * passes a plain bundle) — no postgres, no crypto here.
815 */
816export function buildGenericOAuthConfigs(
817 config: AuthConfig,
818 secrets: OAuthSecretBundle,
819): GenericOAuthEntry[] {
820 const entries: GenericOAuthEntry[] = [];
821
822 const konnos = config.providers.konnos;
823 if (konnos.enabled && konnos.clientId && secrets.konnos) {
824 // Forgejo (code.konnos.org) — gitea-compatible OAuth endpoints, mirroring
825 // the control-plane wiring in lib/auth.ts.
826 const issuer = env.BRIVEN_KONNOS_ISSUER.replace(/\/$/, '');
827 entries.push({
828 providerId: 'konnos',
829 clientId: konnos.clientId,
830 clientSecret: secrets.konnos,
831 authorizationUrl: `${issuer}/login/oauth/authorize`,
832 tokenUrl: `${issuer}/login/oauth/access_token`,
833 userInfoUrl: `${issuer}/api/v1/user`,
834 scopes: ['read:user'],
835 });
836 }
837
838 for (const o of config.customOidc ?? []) {
839 const secret = secrets.oidc[o.id] ?? null;
840 if (!o.enabled || !o.clientId || !secret) continue;
841 const scopes = o.scopes.split(/\s+/).filter(Boolean);
842 if (o.issuer) {
843 entries.push({
844 providerId: o.id,
845 clientId: o.clientId,
846 clientSecret: secret,
847 scopes,
848 pkce: o.pkce,
849 discoveryUrl: discoveryUrlFor(o.issuer),
850 });
851 } else if (o.authorizationUrl && o.tokenUrl && o.userinfoUrl) {
852 entries.push({
853 providerId: o.id,
854 clientId: o.clientId,
855 clientSecret: secret,
856 scopes,
857 pkce: o.pkce,
858 authorizationUrl: o.authorizationUrl,
859 tokenUrl: o.tokenUrl,
860 userInfoUrl: o.userinfoUrl,
861 });
862 }
863 // else: no usable endpoint set — skip (matches oidcHasEndpoints gate).
864 }
865
866 return entries;
867}
868
869/**
870 * Resolve the OAuth secret bundle for a project from the encrypted
871 * control-plane store, probing ONLY the providers the config could enable.
872 * Returns nulls for any absent secret so the gate can skip half-configured
873 * providers. Secrets never touch logs — only their presence gates wiring.
874 */
875async function resolveOAuthSecrets(
876 projectId: string,
877 config: AuthConfig,
878): Promise<OAuthSecretBundle> {
879 const konnos =
880 config.providers.konnos.enabled && config.providers.konnos.clientId
881 ? await getTenantSecret(projectId, 'auth', 'konnos_client_secret')
882 : null;
883 const oidc: Record<string, string | null> = {};
884 for (const o of config.customOidc ?? []) {
885 oidc[o.id] =
886 o.enabled && o.clientId
887 ? await getTenantSecret(projectId, 'auth', `oidc_${o.id}_client_secret`)
888 : null;
889 }
890 return { konnos, oidc };
891}
892
893/**
894 * Resolve the enabled built-in social providers (google/github/discord/
895 * microsoft) into Better Auth's `socialProviders` map. Uses the SAME
896 * enabled-gate as the rest of the surface (`computeEnabledProviders`), then
897 * decrypts only the secrets for providers that passed. konnos is intentionally
898 * excluded here — it rides `genericOAuth`, not `socialProviders`.
899 */
900async function resolveSocialProviders(
901 projectId: string,
902 config: AuthConfig,
903): Promise<Record<string, { clientId: string; clientSecret: string }>> {
904 // Probe presence first (never decrypts) so the shared gate decides which
905 // providers are live, then decrypt only the survivors.
906 const builtins = [
907 'google',
908 'github',
909 'discord',
910 'microsoft',
911 'apple',
912 'twitter',
913 'linkedin',
914 'gitlab',
915 'bitbucket',
916 'dropbox',
917 'facebook',
918 'spotify',
919 ] as const;
920 const present = await Promise.all(
921 builtins.map((k) => getTenantSecret(projectId, 'auth', `${k}_client_secret`)),
922 );
923 const secretByKey = new Map<string, string>();
924 builtins.forEach((k, i) => {
925 if (present[i]) secretByKey.set(`${k}_client_secret`, present[i]!);
926 });
927 const enabled = new Set(
928 computeEnabledProviders(config, (name) => secretByKey.has(name)),
929 );
930
931 const out: Record<string, { clientId: string; clientSecret: string }> = {};
932 for (const k of builtins) {
933 const c = config.providers[k];
934 const secret = secretByKey.get(`${k}_client_secret`);
935 if (enabled.has(k) && c.clientId && secret) {
936 out[k] = { clientId: c.clientId, clientSecret: secret };
937 }
938 }
939 return out;
940}
941
942async function createAuthInstance(projectId: string) {
943 if (!env.BRIVEN_DATA_PLANE_URL) {
944 throw new Error('BRIVEN_DATA_PLANE_URL not configured — briven auth cannot bind a database');
945 }
946
947 // Per-project pg (node-postgres) pool bound to the project's OWN DoltGres
948 // DATABASE (proj_<id>) — database-per-project, mirroring data-plane.ts and
949 // apps/runtime/src/db.ts. Two reasons this replaced postgres.js + a
950 // search_path schema (ADR 0001 / sprint S2.1):
951 // 1. postgres.js's extended-protocol pipelining desyncs with DoltGres
952 // (`unhandled message "&{}"`); `pg` works reliably.
953 // 2. The model is database-per-project, not schema-per-project, so the
954 // auth tables live in this database's `public` schema — no search_path
955 // pinning is needed (the connection is already bound to the right DB).
956 const base = new URL(env.BRIVEN_DATA_PLANE_URL);
957 const pgPool = new pg.Pool({
958 host: base.hostname,
959 port: Number(base.port || 5432),
960 user: decodeURIComponent(base.username),
961 password: decodeURIComponent(base.password),
962 database: dbNameFor(projectId),
963 max: 5,
964 idleTimeoutMillis: 30000,
965 connectionTimeoutMillis: 5000,
966 });
967 const db = drizzle(pgPool);
968
969 // This tenant trusts its own project's registered app domains (+ briven-own)
970 // so the customer's login flow accepts requests from their website.
971 const projectOrigins = await originsForProject(projectId);
972
973 // Load the tenant's stored provider config and turn its toggles into the
974 // actual Better Auth wiring: passwordless plugins (magic-link / OTP /
975 // passkey), built-in social providers, and konnos/custom-OIDC via
976 // genericOAuth. Without this the instance ignored the config and only ever
977 // served email+password (the magic-link 404 bug).
978 const config = await getAuthConfig(projectId);
979 const passwordlessPlugins = buildTenantAuthPlugins(projectId, config, projectOrigins);
980 const socialProviders = await resolveSocialProviders(projectId, config);
981 const oauthSecrets = await resolveOAuthSecrets(projectId, config);
982 const genericOAuthConfigs = buildGenericOAuthConfigs(config, oauthSecrets);
983 const plugins = assembleTenantPlugins(passwordlessPlugins, genericOAuthConfigs, config);
984
985 // Self-heal auth schema on projects provisioned BEFORE later DDL landed
986 // (email templates, passkeys, two_factor column, jwks). Provisioning only
987 // runs on first "Enable Auth", so live tenants never re-run the full batch.
988 // Without this, magic-link / OTP 500 on missing tables/columns (Mavi 2026-07).
989 // Failures are non-fatal so sign-in still attempts when heal partially fails.
990 try {
991 const heal = await ensureTenantAuthSchema(pgPool);
992 if (heal.columnAdded) {
993 log.info('briven_auth_schema_healed_column', {
994 projectId,
995 column: 'two_factor_enabled',
996 });
997 }
998 } catch (err) {
999 log.warn('briven_auth_schema_ensure_failed', {
1000 projectId,
1001 message: err instanceof Error ? err.message : String(err),
1002 });
1003 }
1004
1005 const instance = betterAuth({
1006 appName: `briven-auth-${projectId}`,
1007 secret: authSecret(),
1008 baseURL: env.BRIVEN_API_ORIGIN,
1009 // Distinct from control-plane Better Auth (which owns `/v1/auth/*`
1010 // for the briven.tech dashboard login). Customer-facing tenant auth
1011 // claims `/v1/auth-tenant/*` so the two engines don't collide in
1012 // Hono routing. SDK + hosted-pages both target this prefix.
1013 basePath: '/v1/auth-tenant',
1014 database: drizzleAdapter(db, {
1015 provider: 'pg',
1016 schema: {
1017 user: authSchema.user,
1018 session: authSchema.session,
1019 account: authSchema.account,
1020 verification: authSchema.verification,
1021 // jwt-plugin key store — the plugin looks the model up as `jwks`.
1022 jwks: authSchema.jwks,
1023 // Phase 3 — MFA + Passkeys.
1024 twoFactor: authSchema.twoFactor,
1025 passkey: authSchema.passkey,
1026 },
1027 }),
1028 emailAndPassword: {
1029 // Driven by the tenant's config toggle. Defaults on for backward
1030 // compatibility (DEFAULT_AUTH_CONFIG has emailPassword.enabled = true),
1031 // so existing projects keep password login until they turn it off.
1032 enabled: config.providers.emailPassword.enabled,
1033 requireEmailVerification: env.BRIVEN_ENV === 'production',
1034 minPasswordLength: 10,
1035 maxPasswordLength: 128,
1036 autoSignIn: true,
1037 sendResetPassword: async ({ user, token }) => {
1038 // Route resets through the hosted "new-password" page (the SDK +
1039 // hosted-pages contract), not Better Auth's default API URL.
1040 await sendBrivenAuthPasswordReset(
1041 projectId,
1042 user.email,
1043 resetPasswordUrl(projectId, token, config),
1044 );
1045 },
1046 },
1047 emailVerification: {
1048 sendOnSignUp: env.BRIVEN_ENV === 'production',
1049 autoSignInAfterVerification: true,
1050 sendVerificationEmail: async ({ user, url }) => {
1051 const appUrl = rewriteAuthActionUrlToApp(url, projectId, config, projectOrigins);
1052 await sendBrivenAuthEmailVerification(
1053 projectId,
1054 user.email,
1055 tagTenantUrl(appUrl, projectId),
1056 );
1057 },
1058 },
1059 socialProviders,
1060 plugins,
1061 databaseHooks: buildAuthDatabaseHooks(projectId, dispatchAuthEvent, config) as never,
1062 session: {
1063 expiresIn: 60 * 60 * 24 * config.session.maxLifetimeDays,
1064 updateAge: 60 * 60 * 24 * config.session.updateAgeDays,
1065 },
1066 advanced: {
1067 cookiePrefix: 'briven-auth',
1068 useSecureCookies: env.BRIVEN_ENV === 'production',
1069 defaultCookieAttributes: {
1070 // Customer end-users log in from the customer's OWN domain, which is a
1071 // different site than api.briven.tech — a cross-site context. The
1072 // session cookie must therefore be SameSite=None in production so the
1073 // browser sends it on those cross-site requests. `useSecureCookies`
1074 // above is already true in prod, so the required `Secure` attribute is
1075 // set alongside it. Dev stays 'lax' (browsers reject SameSite=None
1076 // without Secure over http://localhost).
1077 sameSite: env.BRIVEN_ENV === 'production' ? 'none' : 'lax',
1078 httpOnly: true,
1079 // When a custom auth subdomain is configured (auth.murphus.eu), set the
1080 // cookie domain to the parent domain (murphus.eu) so the customer's
1081 // app on the root domain can read the session cookie.
1082 ...(config.customAuthDomain
1083 ? { domain: parentDomainFromAuthSubdomain(config.customAuthDomain) }
1084 : {}),
1085 },
1086 // Privacy (CLAUDE.md §5.1): never persist raw end-user IPs. The
1087 // session.ip_address column exists for Better-Auth compatibility but
1088 // stays null. (Add hashed-IP-on-write later if device tracking is wanted.)
1089 ipAddress: {
1090 disableIpTracking: true,
1091 },
1092 },
1093 trustedOrigins: [
1094 env.BRIVEN_API_ORIGIN,
1095 ...brivenOwnOrigins(),
1096 ...projectOrigins,
1097 ...(env.BRIVEN_ENV !== 'production'
1098 ? (['http://localhost:*', 'https://localhost:*', 'http://127.0.0.1:*', 'https://127.0.0.1:*'] as const)
1099 : []),
1100 ],
1101 });
1102
1103 return {
1104 betterAuth: instance,
1105 closePool: async () => {
1106 await pgPool.end();
1107 },
1108 };
1109}
1110
1111function getPool(): TenantInstancePool<BrivenAuthInstance> {
1112 if (!pool) {
1113 pool = new TenantInstancePool<BrivenAuthInstance>({
1114 maxSize: 256,
1115 idleTtlMs: 10 * 60 * 1000, // 10 minutes
1116 factory: createAuthInstance,
1117 onEvict: async (projectId, instance) => {
1118 await instance.closePool();
1119 log.info('briven_auth_instance_evicted', { projectId });
1120 },
1121 onEvictError: (projectId, err) => {
1122 log.warn('briven_auth_instance_evict_failed', {
1123 projectId,
1124 message: err instanceof Error ? err.message : String(err),
1125 });
1126 },
1127 });
1128 }
1129 return pool;
1130}
1131
1132/**
1133 * Fetch (or create) the Better Auth instance for a project. Cached for
1134 * 10 minutes of idle time; LRU cap at 256 instances per api process.
1135 */
1136export function getAuthInstance(projectId: string): Promise<BrivenAuthInstance> {
1137 return getPool().get(projectId);
1138}
1139
1140/**
1141 * Force-evict a project's instance. Called by the dashboard's
1142 * config-update handlers so the next request sees fresh state — e.g.
1143 * after a customer toggles an OAuth provider or rotates a webhook secret.
1144 */
1145export function invalidateAuthInstance(projectId: string): Promise<void> {
1146 if (!pool) return Promise.resolve();
1147 return pool.evict(projectId);
1148}
1149
1150/**
1151 * Drop every cached instance. Test + graceful-shutdown hook.
1152 */
1153export async function clearAuthInstancePool(): Promise<void> {
1154 if (!pool) return;
1155 await pool.clear();
1156}
1157
1158/**
1159 * Current pool size. Returns 0 before any tenant has been touched.
1160 */
1161export function authInstancePoolSize(): number {
1162 return pool?.size ?? 0;
1163}
1164
1165/**
1166 * Visible for tests — replace the pool factory with a synthetic one so
1167 * test files can exercise the lifecycle without a real postgres + Better
1168 * Auth roundtrip.
1169 */
1170export function __unsafe_setAuthInstancePool_forTesting(
1171 next: TenantInstancePool<BrivenAuthInstance> | null,
1172): void {
1173 pool = next;
1174}