auth-origin-allowlist.ts292 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/** Briven's own origins — always trusted, independent of the DB/cache. */
95export function brivenOwnOrigins(): string[] {
96 return [
97 env.BRIVEN_WEB_ORIGIN,
98 env.BRIVEN_STUDIO_ORIGIN,
99 ...(env.BRIVEN_ADMIN_ORIGIN ? [env.BRIVEN_ADMIN_ORIGIN] : []),
100 ].filter(Boolean);
101}
102
103/**
104 * Normalise an origin to `scheme://host[:port]`, lowercased, no trailing slash.
105 * Returns null if it isn't a valid http(s) origin. A leading `*.` on the host is
106 * preserved (wildcard marker handled separately).
107 */
108export function normaliseOrigin(raw: string): string | null {
109 const trimmed = raw.trim().toLowerCase().replace(/\/+$/, '');
110 const m = /^(https?):\/\/(\*\.)?([a-z0-9.-]+)(:[0-9]{1,5})?$/.exec(trimmed);
111 if (!m) return null;
112 const scheme = m[1];
113 const wild = m[2] ?? '';
114 const host = m[3];
115 const port = m[4] ?? '';
116 if (!host || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return null;
117 return `${scheme}://${wild}${host}${port}`;
118}
119
120/**
121 * HOT PATH. Is this incoming Origin header registered by ANY project (or a
122 * briven-own origin)? Pure in-memory; never throws, never hits the DB.
123 */
124export function isRegisteredOrigin(origin: string | null | undefined): boolean {
125 if (!origin) return false;
126 try {
127 const norm = normaliseOrigin(origin);
128 if (!norm) return false;
129 if (brivenOwnOrigins().includes(norm)) return true;
130 if (CACHE.exact.has(norm)) return true;
131 // wildcard entries are stored normalised as `scheme://*.base` → compare hosts
132 const om = /^(https?):\/\/([^/]+)$/.exec(norm);
133 if (!om) return false;
134 const originScheme = om[1];
135 const originHostPort = om[2];
136 if (!originScheme || !originHostPort) return false;
137 for (const w of CACHE.wildcards) {
138 const wm = /^(https?):\/\/\*\.([^/]+)$/.exec(w);
139 if (!wm) continue;
140 const wildcardBase = wm[2];
141 if (wildcardBase && wm[1] === originScheme && (originHostPort === wildcardBase || originHostPort.endsWith(`.${wildcardBase}`))) return true;
142 }
143 return false;
144 } catch {
145 return false; // fail closed for customer origins; briven-own handled above
146 }
147}
148
149/** For the CORS `origin` callback: echo the origin if trusted, else null. */
150export function resolveCorsOrigin(origin: string | undefined): string | null {
151 if (!origin) return null;
152 return isRegisteredOrigin(origin) ? origin : null;
153}
154
155// ─── cache loading + refresh ────────────────────────────────────────────────
156
157async function loadCache(): Promise<void> {
158 try {
159 await ensureTable();
160 const rows = await getDb()
161 .select({ origin: projectAuthOrigins.origin, isWildcard: projectAuthOrigins.isWildcard })
162 .from(projectAuthOrigins);
163 const exact = new Set<string>();
164 const wildcards: string[] = [];
165 for (const r of rows) {
166 if (r.isWildcard) wildcards.push(r.origin);
167 else exact.add(r.origin);
168 }
169 CACHE = { exact, wildcards };
170 } catch (err) {
171 // Never let a load failure crash boot or a request. Keep the last-good
172 // cache (or empty) — briven-own origins still work.
173 log.warn('auth_origin_allowlist_load_failed', {
174 message: err instanceof Error ? err.message : String(err),
175 });
176 }
177}
178
179/** Kick off background loading + periodic refresh. Safe to call at boot. */
180export function startOriginAllowlist(): void {
181 void loadCache();
182 if (!refreshTimer) {
183 refreshTimer = setInterval(() => void loadCache(), 60_000);
184 if (typeof refreshTimer.unref === 'function') refreshTimer.unref();
185 }
186}
187
188// ─── per-project reads (for tenant Better Auth) ─────────────────────────────
189
190/**
191 * The registered origins for ONE project, as concrete trustedOrigins strings
192 * for Better Auth. Wildcards are expanded to Better Auth's `*.base` form.
193 * Called at tenant-instance build time (infrequent), so a direct query is fine.
194 */
195export async function originsForProject(projectId: string): Promise<string[]> {
196 try {
197 await ensureTable();
198 const rows = await getDb()
199 .select({ origin: projectAuthOrigins.origin })
200 .from(projectAuthOrigins)
201 .where(eq(projectAuthOrigins.projectId, projectId));
202 return rows.map((r) => r.origin);
203 } catch (err) {
204 log.warn('auth_origins_for_project_failed', {
205 projectId,
206 message: err instanceof Error ? err.message : String(err),
207 });
208 return [];
209 }
210}
211
212// ─── admin CRUD (for the dashboard routes) ──────────────────────────────────
213
214export async function listOrigins(projectId: string): Promise<AllowedOrigin[]> {
215 await ensureTable();
216 const rows = await getDb()
217 .select()
218 .from(projectAuthOrigins)
219 .where(eq(projectAuthOrigins.projectId, projectId));
220 return rows
221 .map((r) => ({
222 id: r.id,
223 origin: r.origin,
224 isWildcard: r.isWildcard,
225 createdAt: (r.createdAt instanceof Date ? r.createdAt : new Date(r.createdAt)).toISOString(),
226 }))
227 .sort((a, b) => a.origin.localeCompare(b.origin));
228}
229
230export async function addOrigin(input: {
231 projectId: string;
232 origin: string;
233 isWildcard: boolean;
234 createdBy: string;
235 /** When true, the per-project cap is skipped (founder/superadmin). */
236 unlimited: boolean;
237}): Promise<AllowedOrigin> {
238 const norm = normaliseOrigin(input.origin);
239 if (!norm) {
240 throw new ValidationError(
241 'domain must be a full origin like https://yourapp.com (optionally https://*.yourapp.com for subdomains)',
242 { origin: input.origin },
243 );
244 }
245 const isWildcard = input.isWildcard || norm.includes('://*.');
246 // Store wildcards consistently in `scheme://*.host` form so the matcher
247 // (which keys off the `*.`) works whether the caller typed `*.` themselves
248 // or just ticked the wildcard box on a bare `https://host`.
249 const stored =
250 isWildcard && !norm.includes('://*.') ? norm.replace(/^(https?:\/\/)/, '$1*.') : norm;
251 await ensureTable();
252 const db = getDb();
253
254 if (!input.unlimited) {
255 const existing = await db
256 .select({ id: projectAuthOrigins.id })
257 .from(projectAuthOrigins)
258 .where(eq(projectAuthOrigins.projectId, input.projectId));
259 if (existing.length >= APP_DOMAINS_CAP) {
260 throw new AppDomainLimitExceeded(existing.length, APP_DOMAINS_CAP);
261 }
262 }
263
264 const row = {
265 id: newId('ao'),
266 projectId: input.projectId,
267 origin: stored,
268 isWildcard,
269 createdBy: input.createdBy,
270 };
271 try {
272 await db.insert(projectAuthOrigins).values(row);
273 } catch (err) {
274 // Unique index (project_id, origin) — treat as a friendly 400.
275 throw new ValidationError('that domain is already registered for this project', {
276 origin: norm,
277 cause: err instanceof Error ? err.message : String(err),
278 });
279 }
280 void loadCache(); // refresh the hot-path cache immediately
281 return { id: row.id, origin: stored, isWildcard, createdAt: new Date().toISOString() };
282}
283
284export async function removeOrigin(projectId: string, originId: string): Promise<boolean> {
285 await ensureTable();
286 const deleted = await getDb()
287 .delete(projectAuthOrigins)
288 .where(and(eq(projectAuthOrigins.id, originId), eq(projectAuthOrigins.projectId, projectId)))
289 .returning({ id: projectAuthOrigins.id });
290 void loadCache();
291 return deleted.length > 0;
292}