import type { MiddlewareHandler } from "hono"; import type { AppBindings } from "@/worker/hono"; import { errorContext, type Logger } from "@/worker/logger"; const WINDOW_SECONDS = 60; export interface RateLimitDecision { allowed: boolean; retryAfterSeconds: number; } // Caller passes the binding directly so different call sites can pick a // tier (e.g. `env.RL_AUTH` for tight, `env.RL_API` for loose). Bucket // namespacing keeps unrelated callers on the same binding from sharing // counters. The leading `log` arg lets us fail closed loudly on a // binding throw without threading a separate logger through every // callsite. export const enforceRateLimit = async ( log: Logger, binding: RateLimit, bucket: string, identifier: string, ): Promise => { const key = `rl:${bucket}:${identifier}`; try { const { success } = await binding.limit({ key }); if (!success) { return { allowed: false, retryAfterSeconds: WINDOW_SECONDS }; } return { allowed: true, retryAfterSeconds: 0 }; } catch (err) { // Fail closed. A binding throw must not let the request through // un-charged — a hiccup would otherwise silently disable throttling // on the most abuse-prone endpoints (sign-in, invite-accept) since // the throw escapes through the global handler as a 500 without // incrementing any counter. Returning `allowed: false` here forces // the caller's standard 429 response and keeps abuse capped. log.child({ component: "rate-limit" }).error("rate_limit_binding_error", { bucket, ...errorContext(err, "info"), }); return { allowed: false, retryAfterSeconds: WINDOW_SECONDS }; } }; export const rateLimitResponse = (decision: RateLimitDecision): Response => new Response( JSON.stringify({ error: "rate_limited", retry_after: decision.retryAfterSeconds, // Surface a render-friendly string so consumers (sign-in, // invite-accept) can show a meaningful 429 instead of falling // through to a generic "Sign-in failed." copy when they read // body.message. message: `Too many requests. Try again in ${decision.retryAfterSeconds} seconds.`, }), { status: 429, headers: { "content-type": "application/json", "retry-after": String(decision.retryAfterSeconds), }, }, ); // Public, cacheable, or stateless paths that should not consume budget // even when called repeatedly. const EXEMPT_AUTH_PATHS = new Set(["/api/auth/jwks", "/api/auth/ok", "/api/auth/error"]); // Hot reads served by the Better Auth catchall: session reads (called // on every page navigation by the SPA) and RP-backend OAuth reads // (/oauth2/userinfo, /oauth2/introspect — called per downstream user // request by relying parties). Bucketed against RL_API so a multi-tab // SPA or busy RP backend does not exhaust the tight RL_AUTH budget that // abuse-prone routes share. const LOOSE_AUTH_PATHS = new Set([ "/api/auth/get-session", "/api/auth/update-session", "/api/auth/list-sessions", "/api/auth/list-accounts", "/api/auth/oauth2/userinfo", "/api/auth/oauth2/introspect", ]); const selectAuthTier = (env: Env, path: string): { binding: RateLimit; bucket: string } | null => { if (EXEMPT_AUTH_PATHS.has(path)) return null; if (LOOSE_AUTH_PATHS.has(path)) return { binding: env.RL_API, bucket: "auth-api" }; return { binding: env.RL_AUTH, bucket: "auth-tight" }; }; // Path-aware rate limiter mounted on /api/auth/* before Better Auth's // catchall. Better Auth's own rate limiter is intentionally disabled // (see `rateLimit: { enabled: false }` in makeAuth) so tessera owns // enforcement here through Cloudflare's native RateLimit bindings. // // Mount order: this runs after `BLOCKED_AUTH_PATHS` and // `adminRouteAllowlist` so blocked / non-allowlisted paths 404 without // burning budget. // // CF-Connecting-IP missing: skip the limiter. Cloudflare always sets // this header on requests reaching the worker through a configured // route, so a missing value means the request did not come through // Cloudflare's edge (e.g. local dev / tests via miniflare's SELF.fetch). // Tessera-owned wrappers (sign-in, invite) still call the fail-closed // `remoteIp` for their own enforcement; that boundary is unchanged. export const rateLimitAuthSurface: MiddlewareHandler = async (c, next) => { const tier = selectAuthTier(c.env, c.req.path); if (!tier) return next(); const ip = c.req.header("CF-Connecting-IP")?.trim(); if (!ip) return next(); const decision = await enforceRateLimit(c.var.log, tier.binding, tier.bucket, ip); if (!decision.allowed) { c.var.log.child({ component: "rate-limit-auth" }).warn("auth_surface_rate_limited", { bucket: tier.bucket, path: c.req.path, retryAfterSeconds: decision.retryAfterSeconds, }); return rateLimitResponse(decision); } return next(); };