diff --git a/docs/auth-operations.md b/docs/auth-operations.md index be14502..747b585 100644 --- a/docs/auth-operations.md +++ b/docs/auth-operations.md @@ -73,6 +73,23 @@ Non-secret defaults live in `wrangler.jsonc`: - `MAIL_FROM` - `CAPTCHA_PROVIDER` +Performance-related non-secret defaults also live in `wrangler.jsonc`: + +- `BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE`: Better Auth session cookie cache TTL in seconds. Default is `300`. Lower it if session revocation or role changes must propagate faster. +- `API_KEY_DEFER_UPDATES`: When `true`, API key request counters and timestamps are deferred through Better Auth background tasks and Worker `waitUntil`. + +Auth request latency is logged as structured JSON with: + +- `event=auth_request` +- `method` +- `path` +- `status` +- `durationMs` +- optional `colo` +- optional `cfRay` + +Do not log request bodies, cookies, authorization headers, API keys, emails, phone numbers, or OTP codes. + Set secrets with Wrangler: ```bash @@ -111,3 +128,14 @@ pnpm deploy ``` After deploy, repeat the runtime smoke checks against the production `BETTER_AUTH_URL`. + +## Performance Validation + +After deployment, compare p50, p95, and p99 for: + +- `/api/auth/get-session` +- `/api/auth/api-key/verify` +- phone OTP endpoints +- organization invitation endpoints + +Enable Cloudflare Smart Placement only after logs show that latency is dominated by D1 or external provider round trips. Evaluate D1 read replication or secondary storage only after the first-stage cache and deferred-update changes are measured in production. diff --git a/src/index.ts b/src/index.ts index 5aca853..43fc541 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { createAuth } from "./auth"; import { trustedOrigins, type Env } from "./env"; +import { withAuthRequestLogging } from "./observability"; const app = new Hono<{ Bindings: Env }>(); @@ -24,7 +25,7 @@ app.on(["POST", "GET"], "/api/auth/*", (c) => { c.executionCtx.waitUntil(promise); }, }); - return auth.handler(c.req.raw); + return withAuthRequestLogging(c.req.raw, () => auth.handler(c.req.raw)); }); export default app; diff --git a/src/observability.ts b/src/observability.ts new file mode 100644 index 0000000..1468dc4 --- /dev/null +++ b/src/observability.ts @@ -0,0 +1,35 @@ +export type AuthRequestHandler = () => Response | Promise; + +export async function withAuthRequestLogging( + request: Request, + handler: AuthRequestHandler, +): Promise { + const startedAt = Date.now(); + let status = 500; + + try { + const response = await handler(); + status = response.status; + return response; + } finally { + logAuthRequest(request, status, Date.now() - startedAt); + } +} + +function logAuthRequest(request: Request, status: number, durationMs: number): void { + try { + const event = { + event: "auth_request", + method: request.method, + path: new URL(request.url).pathname, + status, + durationMs, + colo: request.cf?.colo, + cfRay: request.headers.get("cf-ray") ?? undefined, + }; + + console.log(JSON.stringify(event)); + } catch { + // Observability must not affect auth responses. + } +} diff --git a/tests/auth-worker.test.ts b/tests/auth-worker.test.ts index 9e9b059..74e98c0 100644 --- a/tests/auth-worker.test.ts +++ b/tests/auth-worker.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import worker from "../src/index"; import type { Env } from "../src/env"; @@ -15,6 +15,16 @@ const env: Env = { TRUSTED_ORIGINS: "http://localhost:8787", }; +let log: ReturnType; + +beforeEach(() => { + log = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("cfw-auth worker", () => { it("does not expose a custom health endpoint", async () => { const response = await worker.fetch(new Request("http://auth.local/healthz"), env); @@ -89,4 +99,39 @@ describe("cfw-auth worker", () => { ); expect(response.status).toBe(404); }); + + it("logs auth request timing without changing the response", async () => { + const response = await worker.fetch( + new Request("http://auth.local/api/auth/reference", { + headers: { + "cf-ray": "test-ray", + Authorization: "Bearer secret-token", + Cookie: "better-auth.session_token=secret-cookie", + }, + }), + env, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type") ?? "").toContain("text/html"); + expect(log).toHaveBeenCalledTimes(1); + + const [rawMessage] = log.mock.calls[0] ?? []; + expect(typeof rawMessage).toBe("string"); + const message = String(rawMessage); + const event = JSON.parse(message) as Record; + + expect(event).toMatchObject({ + event: "auth_request", + method: "GET", + path: "/api/auth/reference", + status: 200, + cfRay: "test-ray", + }); + expect(typeof event.durationMs).toBe("number"); + expect(message).not.toContain("secret-token"); + expect(message).not.toContain("secret-cookie"); + expect(message.toLowerCase()).not.toContain("authorization"); + expect(message.toLowerCase()).not.toContain("cookie"); + }); }); diff --git a/wrangler.jsonc b/wrangler.jsonc index bfb8c4d..2e16217 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,17 +1,24 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "cfw-auth", + "account_id": "67720b647ff2b55cf37ba3ef9e677083", "main": "src/index.ts", "compatibility_date": "2026-06-10", "compatibility_flags": [ "nodejs_compat" ], + "observability": { + "enabled": true, + "head_sampling_rate": 0.1 + }, "vars": { "BETTER_AUTH_URL": "http://localhost:8788", "TRUSTED_ORIGINS": "http://localhost:8787", "MAIL_PROVIDER": "resend", "MAIL_FROM": "noreply@example.com", - "CAPTCHA_PROVIDER": "cloudflare-turnstile" + "CAPTCHA_PROVIDER": "cloudflare-turnstile", + "BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE": "300", + "API_KEY_DEFER_UPDATES": "true" }, "d1_databases": [ {