auth-security.ts522 lines · main
| 1 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 2 | |
| 3 | /** |
| 4 | * User security management — suspensions, bans, allowlist/blocklist, |
| 5 | * waitlist, and email validation. |
| 6 | * |
| 7 | * All user-moderation state lives in `_briven_auth_user_security` (an |
| 8 | * auxiliary table — we never ALTER the users table per DoltGres constraints). |
| 9 | * Waitlist entries live in `_briven_auth_waitlist`. |
| 10 | */ |
| 11 | |
| 12 | // ─── disposable email domains ───────────────────────────────────────────── |
| 13 | |
| 14 | /** A small built-in set of known disposable domains. Expanded at runtime |
| 15 | * from a downloaded blocklist if available. */ |
| 16 | const BUILT_IN_DISPOSABLE = new Set([ |
| 17 | 'mailinator.com', |
| 18 | 'tempmail.com', |
| 19 | 'throwaway.com', |
| 20 | 'guerrillamail.com', |
| 21 | 'yopmail.com', |
| 22 | 'sharklasers.com', |
| 23 | 'getairmail.com', |
| 24 | '10minutemail.com', |
| 25 | 'burnermail.io', |
| 26 | 'temp-mail.org', |
| 27 | 'fakeinbox.com', |
| 28 | 'mailnesia.com', |
| 29 | 'trashmail.com', |
| 30 | 'getnada.com', |
| 31 | ' Mohmal.com', |
| 32 | 'maildrop.cc', |
| 33 | 'harakirimail.com', |
| 34 | 'spamgourmet.com', |
| 35 | 'mailcatch.com', |
| 36 | 'mytrashmail.com', |
| 37 | 'emailondeck.com', |
| 38 | 'dispostable.com', |
| 39 | ]); |
| 40 | |
| 41 | // ─── email validation helpers ───────────────────────────────────────────── |
| 42 | |
| 43 | /** |
| 44 | * Normalize email for gates + linking. |
| 45 | * Gmail/googlemail: ignore dots and strip +tags so `a.b+x@gmail.com` ≡ `ab@gmail.com`. |
| 46 | */ |
| 47 | export function normalizeEmail(email: string): string { |
| 48 | const trimmed = email.trim().toLowerCase(); |
| 49 | const at = trimmed.lastIndexOf('@'); |
| 50 | if (at < 0) return trimmed; |
| 51 | let local = trimmed.slice(0, at); |
| 52 | const domain = trimmed.slice(at + 1); |
| 53 | // Gmail normalization: dots ignored, +tags stripped, googlemail → gmail. |
| 54 | if (domain === 'gmail.com' || domain === 'googlemail.com') { |
| 55 | local = local.replace(/\./g, '').split('+')[0]!; |
| 56 | return `${local}@gmail.com`; |
| 57 | } |
| 58 | return `${local}@${domain}`; |
| 59 | } |
| 60 | |
| 61 | function domainFromEmail(email: string): string { |
| 62 | const at = email.lastIndexOf('@'); |
| 63 | return at >= 0 ? email.slice(at + 1) : ''; |
| 64 | } |
| 65 | |
| 66 | function hasSubaddress(email: string): boolean { |
| 67 | const local = email.split('@')[0] ?? ''; |
| 68 | return /[+#=]/.test(local); |
| 69 | } |
| 70 | |
| 71 | function isDisposableDomain(domain: string): boolean { |
| 72 | return BUILT_IN_DISPOSABLE.has(domain.toLowerCase()); |
| 73 | } |
| 74 | |
| 75 | // ─── allowlist / blocklist checks ───────────────────────────────────────── |
| 76 | |
| 77 | export interface EmailGateResult { |
| 78 | allowed: boolean; |
| 79 | reason?: string; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Check whether an email address passes the project's allowlist/blocklist |
| 84 | * rules. Called BEFORE sign-up and BEFORE org invite acceptance. |
| 85 | */ |
| 86 | export function checkEmailGate( |
| 87 | email: string, |
| 88 | opts: { |
| 89 | allowedDomains: string[]; |
| 90 | blockedDomains: string[]; |
| 91 | blockDisposable: boolean; |
| 92 | blockSubaddresses: boolean; |
| 93 | }, |
| 94 | ): EmailGateResult { |
| 95 | const normalized = normalizeEmail(email); |
| 96 | const domain = domainFromEmail(normalized); |
| 97 | |
| 98 | if (!domain) { |
| 99 | return { allowed: false, reason: 'invalid email address' }; |
| 100 | } |
| 101 | |
| 102 | // Blocklist is checked first — explicit blocks override allows. |
| 103 | if (opts.blockedDomains.length > 0) { |
| 104 | const blocked = opts.blockedDomains.some( |
| 105 | (d) => domain === d.toLowerCase() || normalized.endsWith(`@${d.toLowerCase()}`), |
| 106 | ); |
| 107 | if (blocked) { |
| 108 | return { allowed: false, reason: 'this email domain is not allowed' }; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | if (opts.blockDisposable && isDisposableDomain(domain)) { |
| 113 | return { allowed: false, reason: 'disposable email addresses are not allowed' }; |
| 114 | } |
| 115 | |
| 116 | if (opts.blockSubaddresses && hasSubaddress(normalized)) { |
| 117 | return { allowed: false, reason: 'email subaddresses are not allowed' }; |
| 118 | } |
| 119 | |
| 120 | if (opts.allowedDomains.length > 0) { |
| 121 | const allowed = opts.allowedDomains.some( |
| 122 | (d) => domain === d.toLowerCase() || normalized.endsWith(`@${d.toLowerCase()}`), |
| 123 | ); |
| 124 | if (!allowed) { |
| 125 | return { allowed: false, reason: 'your email domain is not on the allowed list' }; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | return { allowed: true }; |
| 130 | } |
| 131 | |
| 132 | // ─── user security state (bans / suspensions) ───────────────────────────── |
| 133 | |
| 134 | export interface UserSecurityState { |
| 135 | suspendedAt: Date | null; |
| 136 | suspendedReason: string | null; |
| 137 | bannedAt: Date | null; |
| 138 | bannedReason: string | null; |
| 139 | banExpiresAt: Date | null; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Fetch the security state for a user. Returns null when no moderation |
| 144 | * row exists (the common case — most users are unmoderated). |
| 145 | */ |
| 146 | export async function getUserSecurityState( |
| 147 | projectId: string, |
| 148 | userId: string, |
| 149 | ): Promise<UserSecurityState | null> { |
| 150 | const rows = await runInProjectDatabase< |
| 151 | Array<{ |
| 152 | suspended_at: Date | null; |
| 153 | suspended_reason: string | null; |
| 154 | banned_at: Date | null; |
| 155 | banned_reason: string | null; |
| 156 | ban_expires_at: Date | null; |
| 157 | }> |
| 158 | >(projectId, async (tx) => |
| 159 | tx.unsafe( |
| 160 | `SELECT suspended_at, suspended_reason, banned_at, banned_reason, ban_expires_at |
| 161 | FROM "_briven_auth_user_security" |
| 162 | WHERE user_id = $1 |
| 163 | LIMIT 1`, |
| 164 | [userId] as never[], |
| 165 | ) as never, |
| 166 | ); |
| 167 | const row = rows[0]; |
| 168 | if (!row) return null; |
| 169 | return { |
| 170 | suspendedAt: row.suspended_at, |
| 171 | suspendedReason: row.suspended_reason, |
| 172 | bannedAt: row.banned_at, |
| 173 | bannedReason: row.banned_reason, |
| 174 | banExpiresAt: row.ban_expires_at, |
| 175 | }; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Is the user currently blocked from authentication? Checks both suspension |
| 180 | * and ban (including expired bans). |
| 181 | */ |
| 182 | export function isUserBlocked(state: UserSecurityState | null): { blocked: boolean; reason?: string } { |
| 183 | if (!state) return { blocked: false }; |
| 184 | |
| 185 | // Permanent ban |
| 186 | if (state.bannedAt && !state.banExpiresAt) { |
| 187 | return { blocked: true, reason: state.bannedReason ?? 'account banned' }; |
| 188 | } |
| 189 | |
| 190 | // Temporary ban (check expiry) |
| 191 | if (state.bannedAt && state.banExpiresAt) { |
| 192 | if (new Date() < state.banExpiresAt) { |
| 193 | return { blocked: true, reason: state.bannedReason ?? 'account temporarily banned' }; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // Suspension |
| 198 | if (state.suspendedAt) { |
| 199 | return { blocked: true, reason: state.suspendedReason ?? 'account suspended' }; |
| 200 | } |
| 201 | |
| 202 | return { blocked: false }; |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Ban a user. Idempotent — re-banning updates the reason and timestamp. |
| 207 | */ |
| 208 | export async function banUser( |
| 209 | projectId: string, |
| 210 | userId: string, |
| 211 | opts: { reason?: string; expiresAt?: Date }, |
| 212 | ): Promise<void> { |
| 213 | await runInProjectDatabase(projectId, async (tx) => { |
| 214 | // Upsert: insert if absent, update if present. |
| 215 | const existing = await tx.unsafe( |
| 216 | `SELECT 1 FROM "_briven_auth_user_security" WHERE user_id = $1 LIMIT 1`, |
| 217 | [userId] as never[], |
| 218 | ); |
| 219 | const now = new Date().toISOString(); |
| 220 | const expires = opts.expiresAt ? opts.expiresAt.toISOString() : null; |
| 221 | if ((existing as unknown[]).length > 0) { |
| 222 | await tx.unsafe( |
| 223 | `UPDATE "_briven_auth_user_security" |
| 224 | SET banned_at = $2, banned_reason = $3, ban_expires_at = $4, updated_at = $2 |
| 225 | WHERE user_id = $1`, |
| 226 | [userId, now, opts.reason ?? null, expires] as never, |
| 227 | ); |
| 228 | } else { |
| 229 | await tx.unsafe( |
| 230 | `INSERT INTO "_briven_auth_user_security" |
| 231 | (id, user_id, banned_at, banned_reason, ban_expires_at, created_at, updated_at) |
| 232 | VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $2, $2)`, |
| 233 | [userId, now, opts.reason ?? null, expires] as never, |
| 234 | ); |
| 235 | } |
| 236 | }); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Unban a user. Idempotent — no-op if the user was never banned. |
| 241 | */ |
| 242 | export async function unbanUser(projectId: string, userId: string): Promise<void> { |
| 243 | await runInProjectDatabase(projectId, async (tx) => { |
| 244 | await tx.unsafe( |
| 245 | `UPDATE "_briven_auth_user_security" |
| 246 | SET banned_at = NULL, banned_reason = NULL, ban_expires_at = NULL, updated_at = $2 |
| 247 | WHERE user_id = $1`, |
| 248 | [userId, new Date().toISOString()] as never, |
| 249 | ); |
| 250 | }); |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * Suspend a user. |
| 255 | */ |
| 256 | export async function suspendUser( |
| 257 | projectId: string, |
| 258 | userId: string, |
| 259 | opts: { reason?: string } = {}, |
| 260 | ): Promise<void> { |
| 261 | await runInProjectDatabase(projectId, async (tx) => { |
| 262 | const existing = await tx.unsafe( |
| 263 | `SELECT 1 FROM "_briven_auth_user_security" WHERE user_id = $1 LIMIT 1`, |
| 264 | [userId] as never[], |
| 265 | ); |
| 266 | const now = new Date().toISOString(); |
| 267 | if ((existing as unknown[]).length > 0) { |
| 268 | await tx.unsafe( |
| 269 | `UPDATE "_briven_auth_user_security" |
| 270 | SET suspended_at = $2, suspended_reason = $3, updated_at = $2 |
| 271 | WHERE user_id = $1`, |
| 272 | [userId, now, opts.reason ?? null] as never, |
| 273 | ); |
| 274 | } else { |
| 275 | await tx.unsafe( |
| 276 | `INSERT INTO "_briven_auth_user_security" |
| 277 | (id, user_id, suspended_at, suspended_reason, created_at, updated_at) |
| 278 | VALUES (gen_random_uuid()::text, $1, $2, $3, $2, $2)`, |
| 279 | [userId, now, opts.reason ?? null] as never, |
| 280 | ); |
| 281 | } |
| 282 | }); |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Unsuspend a user. |
| 287 | */ |
| 288 | export async function unsuspendUser(projectId: string, userId: string): Promise<void> { |
| 289 | await runInProjectDatabase(projectId, async (tx) => { |
| 290 | await tx.unsafe( |
| 291 | `UPDATE "_briven_auth_user_security" |
| 292 | SET suspended_at = NULL, suspended_reason = NULL, updated_at = $2 |
| 293 | WHERE user_id = $1`, |
| 294 | [userId, new Date().toISOString()] as never, |
| 295 | ); |
| 296 | }); |
| 297 | } |
| 298 | |
| 299 | // ─── waitlist ───────────────────────────────────────────────────────────── |
| 300 | |
| 301 | export interface WaitlistEntry { |
| 302 | id: string; |
| 303 | email: string; |
| 304 | name: string | null; |
| 305 | status: 'pending' | 'approved' | 'rejected'; |
| 306 | createdAt: Date; |
| 307 | } |
| 308 | |
| 309 | /** |
| 310 | * Add an email to the waitlist. Idempotent — if the email already exists, |
| 311 | * returns the existing row without overwriting. |
| 312 | */ |
| 313 | export async function addToWaitlist( |
| 314 | projectId: string, |
| 315 | input: { email: string; name?: string }, |
| 316 | ): Promise<WaitlistEntry> { |
| 317 | const normalized = normalizeEmail(input.email); |
| 318 | const rows = await runInProjectDatabase< |
| 319 | Array<{ id: string; email: string; name: string | null; status: string; created_at: Date }> |
| 320 | >(projectId, async (tx) => { |
| 321 | // Try insert; on conflict (unique email) return existing. |
| 322 | const inserted = await tx.unsafe( |
| 323 | `INSERT INTO "_briven_auth_waitlist" (id, email, name, status, created_at, updated_at) |
| 324 | VALUES (gen_random_uuid()::text, $1, $2, 'pending', now(), now()) |
| 325 | ON CONFLICT (email) DO UPDATE SET updated_at = now() |
| 326 | RETURNING id, email, name, status, created_at`, |
| 327 | [normalized, input.name ?? null] as never, |
| 328 | ); |
| 329 | return inserted as never; |
| 330 | }); |
| 331 | const row = rows[0]; |
| 332 | if (!row) throw new Error('waitlist insert returned no row'); |
| 333 | return { |
| 334 | id: row.id, |
| 335 | email: row.email, |
| 336 | name: row.name, |
| 337 | status: row.status as WaitlistEntry['status'], |
| 338 | createdAt: row.created_at, |
| 339 | }; |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * Check whether an email has been approved on the waitlist. |
| 344 | */ |
| 345 | export async function isWaitlistApproved( |
| 346 | projectId: string, |
| 347 | email: string, |
| 348 | ): Promise<boolean> { |
| 349 | const rows = await runInProjectDatabase<Array<{ status: string }>>( |
| 350 | projectId, |
| 351 | async (tx) => |
| 352 | tx.unsafe( |
| 353 | `SELECT status FROM "_briven_auth_waitlist" WHERE email = $1 LIMIT 1`, |
| 354 | [normalizeEmail(email)] as never, |
| 355 | ) as never, |
| 356 | ); |
| 357 | const row = rows[0]; |
| 358 | return row?.status === 'approved'; |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Approve a waitlist entry by id. |
| 363 | */ |
| 364 | export async function approveWaitlistEntry( |
| 365 | projectId: string, |
| 366 | entryId: string, |
| 367 | approvedBy: string, |
| 368 | ): Promise<void> { |
| 369 | await runInProjectDatabase(projectId, async (tx) => { |
| 370 | await tx.unsafe( |
| 371 | `UPDATE "_briven_auth_waitlist" |
| 372 | SET status = 'approved', approved_at = now(), approved_by = $2, updated_at = now() |
| 373 | WHERE id = $1`, |
| 374 | [entryId, approvedBy] as never, |
| 375 | ); |
| 376 | }); |
| 377 | } |
| 378 | |
| 379 | /** |
| 380 | * Reject a waitlist entry by id. |
| 381 | */ |
| 382 | export async function rejectWaitlistEntry( |
| 383 | projectId: string, |
| 384 | entryId: string, |
| 385 | opts: { reason?: string } = {}, |
| 386 | ): Promise<void> { |
| 387 | await runInProjectDatabase(projectId, async (tx) => { |
| 388 | await tx.unsafe( |
| 389 | `UPDATE "_briven_auth_waitlist" |
| 390 | SET status = 'rejected', rejected_at = now(), rejected_reason = $2, updated_at = now() |
| 391 | WHERE id = $1`, |
| 392 | [entryId, opts.reason ?? null] as never, |
| 393 | ); |
| 394 | }); |
| 395 | } |
| 396 | |
| 397 | /** |
| 398 | * List waitlist entries for the admin dashboard. |
| 399 | */ |
| 400 | export async function listWaitlist( |
| 401 | projectId: string, |
| 402 | opts: { status?: string; limit?: number; cursor?: string | null } = {}, |
| 403 | ): Promise<{ items: WaitlistEntry[]; nextCursor: string | null }> { |
| 404 | const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); |
| 405 | |
| 406 | // Build WHERE clauses with parameterized queries to avoid injection. |
| 407 | const conditions: string[] = []; |
| 408 | const params: unknown[] = []; |
| 409 | |
| 410 | if (opts.status) { |
| 411 | conditions.push(`status = $${params.length + 1}`); |
| 412 | params.push(opts.status); |
| 413 | } |
| 414 | |
| 415 | if (opts.cursor) { |
| 416 | const [createdAt, id] = opts.cursor.split('__'); |
| 417 | if (createdAt && id) { |
| 418 | conditions.push( |
| 419 | `(created_at < $${params.length + 1} OR (created_at = $${params.length + 1} AND id < $${params.length + 2}))`, |
| 420 | ); |
| 421 | params.push(createdAt, id); |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; |
| 426 | params.push(limit + 1); |
| 427 | const limitPlaceholder = `$${params.length}`; |
| 428 | |
| 429 | const rows = await runInProjectDatabase< |
| 430 | Array<{ |
| 431 | id: string; |
| 432 | email: string; |
| 433 | name: string | null; |
| 434 | status: string; |
| 435 | created_at: Date; |
| 436 | }> |
| 437 | >( |
| 438 | projectId, |
| 439 | async (tx) => |
| 440 | tx.unsafe( |
| 441 | `SELECT id, email, name, status, created_at |
| 442 | FROM "_briven_auth_waitlist" |
| 443 | ${whereClause} |
| 444 | ORDER BY created_at DESC, id DESC |
| 445 | LIMIT ${limitPlaceholder}`, |
| 446 | params as never, |
| 447 | ) as never, |
| 448 | ); |
| 449 | |
| 450 | const hasMore = rows.length > limit; |
| 451 | const page = hasMore ? rows.slice(0, limit) : rows; |
| 452 | |
| 453 | const items = page.map((r) => ({ |
| 454 | id: r.id, |
| 455 | email: r.email, |
| 456 | name: r.name, |
| 457 | status: r.status as WaitlistEntry['status'], |
| 458 | createdAt: r.created_at, |
| 459 | })); |
| 460 | |
| 461 | const nextCursor = hasMore |
| 462 | ? `${page[page.length - 1]!.created_at.toISOString()}__${page[page.length - 1]!.id}` |
| 463 | : null; |
| 464 | |
| 465 | return { items, nextCursor }; |
| 466 | } |
| 467 | |
| 468 | // ─── sign-up mode gate ──────────────────────────────────────────────────── |
| 469 | |
| 470 | export interface SignUpGateResult { |
| 471 | allowed: boolean; |
| 472 | reason?: string; |
| 473 | waitlistEntry?: WaitlistEntry; |
| 474 | } |
| 475 | |
| 476 | /** |
| 477 | * The master sign-up gate. Combines signUpMode + email allowlist/blocklist |
| 478 | * + waitlist status into a single decision. |
| 479 | * |
| 480 | * Called by the auth-tenant bridge BEFORE Better Auth processes sign-up. |
| 481 | */ |
| 482 | export async function checkSignUpGate( |
| 483 | projectId: string, |
| 484 | email: string, |
| 485 | opts: { |
| 486 | signUpMode: 'public' | 'restricted' | 'waitlist'; |
| 487 | allowedDomains: string[]; |
| 488 | blockedDomains: string[]; |
| 489 | blockDisposable: boolean; |
| 490 | blockSubaddresses: boolean; |
| 491 | }, |
| 492 | ): Promise<SignUpGateResult> { |
| 493 | // 1. Email format / blocklist / allowlist. |
| 494 | const emailGate = checkEmailGate(email, { |
| 495 | allowedDomains: opts.allowedDomains, |
| 496 | blockedDomains: opts.blockedDomains, |
| 497 | blockDisposable: opts.blockDisposable, |
| 498 | blockSubaddresses: opts.blockSubaddresses, |
| 499 | }); |
| 500 | if (!emailGate.allowed) { |
| 501 | return { allowed: false, reason: emailGate.reason }; |
| 502 | } |
| 503 | |
| 504 | // 2. Sign-up mode. |
| 505 | if (opts.signUpMode === 'restricted') { |
| 506 | return { allowed: false, reason: 'sign-ups are restricted to invited users only' }; |
| 507 | } |
| 508 | |
| 509 | if (opts.signUpMode === 'waitlist') { |
| 510 | const approved = await isWaitlistApproved(projectId, email); |
| 511 | if (!approved) { |
| 512 | const entry = await addToWaitlist(projectId, { email }); |
| 513 | return { |
| 514 | allowed: false, |
| 515 | reason: 'you have been added to the waitlist. you will receive an email when approved.', |
| 516 | waitlistEntry: entry, |
| 517 | }; |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | return { allowed: true }; |
| 522 | } |