auth-origin-allowlist.ts314 lines · main
1import { newId, ValidationError, brivenError } from '@briven/shared';
2import { and, eq, sql } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { projectAuthOrigins } from '../db/schema.js';
6import { env } from '../env.js';
7import { log } from '../lib/logger.js';
8
9/**
10 * Per-project "allowed app domains" — the browser guest list.
11 *
12 * Each project registers the origins its own app is served from (e.g.
13 * `https://konnos.org`, or a wildcard `https://*.konnos.org` covering every
14 * subdomain). Three gates consult this allowlist so a customer app can log in
15 * through briven auth from its own domain:
16 * - the global CORS gate (apps/api/src/index.ts) — every request
17 * - the CSRF origin check (middleware/csrf.ts)
18 * - each tenant's Better Auth `trustedOrigins` (services/auth-tenant-pool.ts)
19 *
20 * SAFETY: the hot path (`isRegisteredOrigin`) reads an in-memory Set only — it
21 * NEVER touches the database and NEVER throws. If the cache failed to load, the
22 * set is simply empty and behaviour falls back to "briven's own origins only"
23 * (exactly today's behaviour). A bug here can therefore never take the API down.
24 *
25 * The control-plane table is created idempotently on first cache load
26 * (CREATE TABLE IF NOT EXISTS) so no drizzle migration/snapshot is required —
27 * same pattern as auth-provisioning + the per-project _briven_meta table.
28 */
29
30/** Non-superadmin cap on registered origins per project. */
31export const APP_DOMAINS_CAP = 20;
32
33/** Thrown when a non-superadmin project hits the origin cap. Maps to HTTP 402. */
34export class AppDomainLimitExceeded extends brivenError {
35 constructor(count: number, limit: number) {
36 super(
37 'app_domain_limit_exceeded',
38 `allowed app domains limit reached (${count}/${limit})`,
39 { status: 402, context: { count, limit } },
40 );
41 this.name = 'AppDomainLimitExceeded';
42 }
43}
44
45export interface AllowedOrigin {
46 id: string;
47 origin: string;
48 isWildcard: boolean;
49 createdAt: string;
50}
51
52// ─── in-memory cache (the hot path) ─────────────────────────────────────────
53
54interface CacheEntry {
55 /** Exact origins (scheme://host[:port], lowercased). */
56 exact: Set<string>;
57 /** Wildcard base origins (scheme://host, lowercased) — match host + subdomains. */
58 wildcards: string[];
59}
60
61let CACHE: CacheEntry = { exact: new Set(), wildcards: [] };
62let refreshTimer: ReturnType<typeof setInterval> | null = null;
63let tableReady = false;
64
65async function ensureTable(): Promise<void> {
66 if (tableReady) return;
67 const db = getDb();
68 // Idempotent, single-statement DDL (postgres-js runs one statement per call).
69 await db.execute(
70 sql.raw(
71 `CREATE TABLE IF NOT EXISTS "project_auth_origins" (
72 "id" text PRIMARY KEY NOT NULL,
73 "project_id" text NOT NULL,
74 "origin" text NOT NULL,
75 "is_wildcard" boolean DEFAULT false NOT NULL,
76 "created_by" text,
77 "created_at" timestamp with time zone DEFAULT now() NOT NULL
78 )`,
79 ),
80 );
81 await db.execute(
82 sql.raw(
83 `CREATE UNIQUE INDEX IF NOT EXISTS "project_auth_origins_project_origin_idx" ON "project_auth_origins" ("project_id","origin")`,
84 ),
85 );
86 await db.execute(
87 sql.raw(
88 `CREATE INDEX IF NOT EXISTS "project_auth_origins_origin_idx" ON "project_auth_origins" ("origin")`,
89 ),
90 );
91 tableReady = true;
92}
93
94/**
95 * Briven's own origins — always trusted, independent of the DB/cache.
96 * Includes product host aliases (app./admin./www.) for the apex in
97 * BRIVEN_WEB_ORIGIN — Traefik serves the dashboard on briven.tech AND
98 * app.briven.tech; CLI Allow was failing CSRF on the app host (2026-07-29).
99 */
100export function brivenOwnOrigins(): string[] {
101 const list = new Set<string>();
102 for (const o of [
103 env.BRIVEN_WEB_ORIGIN,
104 env.BRIVEN_STUDIO_ORIGIN,
105 env.BRIVEN_ADMIN_ORIGIN,
106 env.BRIVEN_API_ORIGIN,
107 ]) {
108 if (o) list.add(o.replace(/\/$/, ''));
109 }
110 try {
111 const web = new URL(env.BRIVEN_WEB_ORIGIN);
112 const host = web.hostname;
113 // Only add aliases for real product apex hosts (not localhost).
114 if (host && !host.includes('localhost') && host !== '127.0.0.1') {
115 for (const sub of ['app', 'admin', 'www']) {
116 list.add(`${web.protocol}//${sub}.${host}`);
117 }
118 }
119 } catch {
120 /* ignore bad WEB_ORIGIN */
121 }
122 return [...list].filter(Boolean);
123}
124
125/**
126 * Normalise an origin to `scheme://host[:port]`, lowercased, no trailing slash.
127 * Returns null if it isn't a valid http(s) origin. A leading `*.` on the host is
128 * preserved (wildcard marker handled separately).
129 */
130export function normaliseOrigin(raw: string): string | null {
131 const trimmed = raw.trim().toLowerCase().replace(/\/+$/, '');
132 const m = /^(https?):\/\/(\*\.)?([a-z0-9.-]+)(:[0-9]{1,5})?$/.exec(trimmed);
133 if (!m) return null;
134 const scheme = m[1];
135 const wild = m[2] ?? '';
136 const host = m[3];
137 const port = m[4] ?? '';
138 if (!host || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return null;
139 return `${scheme}://${wild}${host}${port}`;
140}
141
142/**
143 * HOT PATH. Is this incoming Origin header registered by ANY project (or a
144 * briven-own origin)? Pure in-memory; never throws, never hits the DB.
145 */
146export function isRegisteredOrigin(origin: string | null | undefined): boolean {
147 if (!origin) return false;
148 try {
149 const norm = normaliseOrigin(origin);
150 if (!norm) return false;
151 if (brivenOwnOrigins().includes(norm)) return true;
152 if (CACHE.exact.has(norm)) return true;
153 // wildcard entries are stored normalised as `scheme://*.base` → compare hosts
154 const om = /^(https?):\/\/([^/]+)$/.exec(norm);
155 if (!om) return false;
156 const originScheme = om[1];
157 const originHostPort = om[2];
158 if (!originScheme || !originHostPort) return false;
159 for (const w of CACHE.wildcards) {
160 const wm = /^(https?):\/\/\*\.([^/]+)$/.exec(w);
161 if (!wm) continue;
162 const wildcardBase = wm[2];
163 if (wildcardBase && wm[1] === originScheme && (originHostPort === wildcardBase || originHostPort.endsWith(`.${wildcardBase}`))) return true;
164 }
165 return false;
166 } catch {
167 return false; // fail closed for customer origins; briven-own handled above
168 }
169}
170
171/** For the CORS `origin` callback: echo the origin if trusted, else null. */
172export function resolveCorsOrigin(origin: string | undefined): string | null {
173 if (!origin) return null;
174 return isRegisteredOrigin(origin) ? origin : null;
175}
176
177// ─── cache loading + refresh ────────────────────────────────────────────────
178
179async function loadCache(): Promise<void> {
180 try {
181 await ensureTable();
182 const rows = await getDb()
183 .select({ origin: projectAuthOrigins.origin, isWildcard: projectAuthOrigins.isWildcard })
184 .from(projectAuthOrigins);
185 const exact = new Set<string>();
186 const wildcards: string[] = [];
187 for (const r of rows) {
188 if (r.isWildcard) wildcards.push(r.origin);
189 else exact.add(r.origin);
190 }
191 CACHE = { exact, wildcards };
192 } catch (err) {
193 // Never let a load failure crash boot or a request. Keep the last-good
194 // cache (or empty) — briven-own origins still work.
195 log.warn('auth_origin_allowlist_load_failed', {
196 message: err instanceof Error ? err.message : String(err),
197 });
198 }
199}
200
201/** Kick off background loading + periodic refresh. Safe to call at boot. */
202export function startOriginAllowlist(): void {
203 void loadCache();
204 if (!refreshTimer) {
205 refreshTimer = setInterval(() => void loadCache(), 60_000);
206 if (typeof refreshTimer.unref === 'function') refreshTimer.unref();
207 }
208}
209
210// ─── per-project reads (for tenant Better Auth) ─────────────────────────────
211
212/**
213 * The registered origins for ONE project, as concrete trustedOrigins strings
214 * for Better Auth. Wildcards are expanded to Better Auth's `*.base` form.
215 * Called at tenant-instance build time (infrequent), so a direct query is fine.
216 */
217export async function originsForProject(projectId: string): Promise<string[]> {
218 try {
219 await ensureTable();
220 const rows = await getDb()
221 .select({ origin: projectAuthOrigins.origin })
222 .from(projectAuthOrigins)
223 .where(eq(projectAuthOrigins.projectId, projectId));
224 return rows.map((r) => r.origin);
225 } catch (err) {
226 log.warn('auth_origins_for_project_failed', {
227 projectId,
228 message: err instanceof Error ? err.message : String(err),
229 });
230 return [];
231 }
232}
233
234// ─── admin CRUD (for the dashboard routes) ──────────────────────────────────
235
236export async function listOrigins(projectId: string): Promise<AllowedOrigin[]> {
237 await ensureTable();
238 const rows = await getDb()
239 .select()
240 .from(projectAuthOrigins)
241 .where(eq(projectAuthOrigins.projectId, projectId));
242 return rows
243 .map((r) => ({
244 id: r.id,
245 origin: r.origin,
246 isWildcard: r.isWildcard,
247 createdAt: (r.createdAt instanceof Date ? r.createdAt : new Date(r.createdAt)).toISOString(),
248 }))
249 .sort((a, b) => a.origin.localeCompare(b.origin));
250}
251
252export async function addOrigin(input: {
253 projectId: string;
254 origin: string;
255 isWildcard: boolean;
256 createdBy: string;
257 /** When true, the per-project cap is skipped (founder/superadmin). */
258 unlimited: boolean;
259}): Promise<AllowedOrigin> {
260 const norm = normaliseOrigin(input.origin);
261 if (!norm) {
262 throw new ValidationError(
263 'domain must be a full origin like https://yourapp.com (optionally https://*.yourapp.com for subdomains)',
264 { origin: input.origin },
265 );
266 }
267 const isWildcard = input.isWildcard || norm.includes('://*.');
268 // Store wildcards consistently in `scheme://*.host` form so the matcher
269 // (which keys off the `*.`) works whether the caller typed `*.` themselves
270 // or just ticked the wildcard box on a bare `https://host`.
271 const stored =
272 isWildcard && !norm.includes('://*.') ? norm.replace(/^(https?:\/\/)/, '$1*.') : norm;
273 await ensureTable();
274 const db = getDb();
275
276 if (!input.unlimited) {
277 const existing = await db
278 .select({ id: projectAuthOrigins.id })
279 .from(projectAuthOrigins)
280 .where(eq(projectAuthOrigins.projectId, input.projectId));
281 if (existing.length >= APP_DOMAINS_CAP) {
282 throw new AppDomainLimitExceeded(existing.length, APP_DOMAINS_CAP);
283 }
284 }
285
286 const row = {
287 id: newId('ao'),
288 projectId: input.projectId,
289 origin: stored,
290 isWildcard,
291 createdBy: input.createdBy,
292 };
293 try {
294 await db.insert(projectAuthOrigins).values(row);
295 } catch (err) {
296 // Unique index (project_id, origin) — treat as a friendly 400.
297 throw new ValidationError('that domain is already registered for this project', {
298 origin: norm,
299 cause: err instanceof Error ? err.message : String(err),
300 });
301 }
302 void loadCache(); // refresh the hot-path cache immediately
303 return { id: row.id, origin: stored, isWildcard, createdAt: new Date().toISOString() };
304}
305
306export async function removeOrigin(projectId: string, originId: string): Promise<boolean> {
307 await ensureTable();
308 const deleted = await getDb()
309 .delete(projectAuthOrigins)
310 .where(and(eq(projectAuthOrigins.id, originId), eq(projectAuthOrigins.projectId, projectId)))
311 .returning({ id: projectAuthOrigins.id });
312 void loadCache();
313 return deleted.length > 0;
314}