diff --git a/.gitignore b/.gitignore index f16400b..9c54e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ test/perf/last-memory-results.json test/perf/last-memory-report.md test/perf/last-memory-profile.json test/perf/*-results.json +# Per-variant auth strategy dumps (keep summary.json + docs/auth-strategy-assessment.md) +test/perf/auth-strategy/*/ profiles/ package-lock.json npm-debug.log* diff --git a/README.md b/README.md index 4079d9b..52c86c2 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ JWTs locally instead, set `AUTH_RESOLVE_VIA_COUCH_SESSION=false`, only; invalid tokens fail closed as anonymous, and Couch still independently validates the forwarded token. +Hybrid (recommended when most clients are JWT but Basic/Cookie still matter): +keep `AUTH_RESOLVE_VIA_COUCH_SESSION=true` and also set `JWT_LOCAL_VERIFY=true` +with a matching `JWT_HMAC_SECRET`. Bearer tokens skip the `/_session` RTT; +Basic/Cookie still resolve via Couch. See `docs/auth-strategy-assessment.md` +and `pnpm test:perf:auth` for measured trade-offs. + ```bash # mint a token (Node), then: curl -s http://127.0.0.1:8000/_session -H "Authorization: Bearer " @@ -128,7 +134,7 @@ Unmapped endpoints return **404** for non-admins (default-deny). `_list`, `_show | `ACL_DB_INCLUDE` / `ACL_DB_EXCLUDE` | Opt-in database allow/deny lists (CSV). Entries are exact names or `/regex/flags`. Empty = historical behaviour. Exclude wins. Non-admins only; hidden DBs are omitted from `/_all_dbs` and return **404**. Example: `ACL_DB_INCLUDE=/^data-/`. | | `ACL_ROUTE_INCLUDE` / `ACL_ROUTE_EXCLUDE` | Opt-in API surface allow/deny lists (CSV). Entries are feature/bundle names (`pouch-sync`, `session`, `changes`, …), `METHOD /restmap-path` templates, or `/regex/flags` over `METHOD pathname`. Empty = all restmap routes. Exclude wins. Non-admins get **403**. | | `AUTH_RESOLVE_VIA_COUCH_SESSION` | Default `true` | -| `JWT_LOCAL_VERIFY` / `JWT_HMAC_SECRET` | Optional local Bearer JWT verification; required together when Couch session resolution is disabled | +| `JWT_LOCAL_VERIFY` / `JWT_HMAC_SECRET` | Optional local Bearer JWT verification. Alone with session resolve off = Bearer-only. Together with session resolve on = Bearer fast-path (skip `/_session`); Basic/Cookie still use Couch. Secret must match Couch `[jwt_keys]`. | | `JWT_ROLES_CLAIM_PATH` / `JWT_REQUIRED_CLAIMS` | Local JWT role claim path and comma-separated required claims | | `COUCH_MAX_ID_LENGTH` | Maximum accepted document-id length (default `200`) | | `CORS_ORIGINS` | Comma allowlist (**required for browser CORS**; empty = no Origin reflection) | @@ -216,6 +222,7 @@ pnpm test:perf # writes test/perf/last-results.json; not in CI # Same harness + server phase profiling (auth/acl/upstream/filter) pnpm test:perf:profile # compose profile overlay + scrape /_couch-auth-proxy/profile +pnpm test:perf:auth # compare session TTL / hybrid JWT / local JWT (writes docs/auth-strategy-assessment.md) # Host CPU profile (after pnpm build; Couch on :5985 via docker:up:dev): # PROFILE=true pnpm start:profile # writes CPU profiles under ./profiles/ diff --git a/docker-compose.profile.yml b/docker-compose.profile.yml index cb7face..fab5b78 100644 --- a/docker-compose.profile.yml +++ b/docker-compose.profile.yml @@ -3,6 +3,12 @@ # docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.profile.yml up -d --build # pnpm test:perf:profile # pnpm test:perf:memory +# bash scripts/perf-auth-strategies.sh +# +# Auth-strategy knobs (host env → proxy). Defaults match production-ish compose: +# SESSION_CACHE_TTL_MS=0|1000|5000|30000 +# AUTH_RESOLVE_VIA_COUCH_SESSION=true|false +# JWT_LOCAL_VERIFY=true|false services: couch-auth-proxy: environment: @@ -10,7 +16,10 @@ services: # Expose V8 GC for optional POST /_couch-auth-proxy/profile/gc samples used # by the memory-stability soak. Harmless when unused; still opt-in via this overlay. NODE_OPTIONS: "--expose-gc" - # Session principal cache defaults to 5000ms in the app. Override here to - # attribute auth vs ACL under an alternate TTL (0 = re-resolve every request). - # SESSION_CACHE_TTL_MS: "0" - # SESSION_CACHE_MAX: "1000" + # Session principal cache (default 5000ms). 0 = re-resolve every request. + SESSION_CACHE_TTL_MS: ${SESSION_CACHE_TTL_MS:-5000} + SESSION_CACHE_MAX: ${SESSION_CACHE_MAX:-10000} + # Override base compose so auth-strategy benchmarks can flip resolvers. + AUTH_RESOLVE_VIA_COUCH_SESSION: ${AUTH_RESOLVE_VIA_COUCH_SESSION:-true} + JWT_LOCAL_VERIFY: ${JWT_LOCAL_VERIFY:-false} + JWT_HMAC_SECRET: ${JWT_HMAC_SECRET:-couch-auth-proxy-dev-secret} diff --git a/docs/auth-strategy-assessment.md b/docs/auth-strategy-assessment.md new file mode 100644 index 0000000..8e1a335 --- /dev/null +++ b/docs/auth-strategy-assessment.md @@ -0,0 +1,247 @@ +# Auth strategy assessment: Couch `/_session` vs in-house JWT + +Generated by `scripts/perf-auth-strategies.sh` at **2026-07-25T02:35:37.210Z**. + +> Clarification: the proxy has **no ACL TTL**. Document ACL is an in-memory map +> invalidated by Couch `_changes`. The **5s** knob is `SESSION_CACHE_TTL_MS` — +> a short-lived cache of resolved principals (roles / ACL tokens) after Couch +> `GET /_session` or local JWT verify. + +## Harness + +| Knob | Value | +|---|---| +| clients | 6 | +| seed docs | 300 | +| sync rounds | 3 | +| docs/round | 8 | +| HTTP ops/client | 60 | +| profile | `PROFILE=true` (compose profile overlay) | +| baseline for Δ% | `ttl5k` | + +Perf suite uses **Bearer JWT** clients (`mintJwt`), so local-JWT and hybrid +Bearer fast-paths are exercised the same way as Couch session resolution of a +JWT. Basic/Cookie still require Couch `/_session` (or a different identity store). + +## Results (relative to keep-as-is `ttl5k`) + +| Variant | TTL ms | Resolve | overall ops/s | HTTP ops/s | HTTP auth ms/req | auth share | upstream share | Δ overall | Δ HTTP | +|---|---:|---|---:|---:|---:|---:|---:|---|---| +| `ttl0` | 0 | couch / — | 245.7 | 537.5 | 1.966 | 0.262 | 0.548 | -14.5% | -17.3% | +| `ttl1k` | 1000 | couch / — | 277.6 | 658.0 | 0.028 | 0.005 | 0.741 | -3.4% | 1.3% | +| `ttl5k` | 5000 | couch / — | 287.3 | 649.7 | 0.019 | 0.003 | 0.752 | 0.0% | 0.0% | +| `ttl30k` | 30000 | couch / — | 292.2 | 666.8 | 0.019 | 0.003 | 0.752 | 1.7% | 2.6% | +| `hybrid-jwt` | 5000 | couch / local JWT | 284.4 | 672.0 | 0.016 | 0.003 | 0.752 | -1.0% | 3.4% | +| `local-jwt` | 5000 | off / local JWT | 280.0 | 653.5 | 0.018 | 0.003 | 0.765 | -2.5% | 0.6% | +| `local-jwt-nocache` | 0 | off / local JWT | 258.9 | 585.1 | 0.717 | 0.111 | 0.669 | -9.9% | -9.9% | + +Raw JSON: [`test/perf/auth-strategy/summary.json`](../test/perf/auth-strategy/summary.json). + +## Per-variant detail + +### `ttl0` +Couch /_session every request (no principal cache) + +| Metric | Value | +|---|---| +| overall ops/s | 245.7 | +| sync ops/s | 46.5 | +| HTTP ops/s | 537.5 | +| _bulk_get ops/s | 490.5 | +| proxy/direct HTTP ratio | 0.451 | +| HTTP mean duration (ms) | 7.500 | +| HTTP auth per-request mean (ms) | 1.966 | +| HTTP auth mean span (ms) | 1.966 | +| HTTP auth share of mean | 0.262 | +| HTTP upstream per-request mean (ms) | 4.113 | +| HTTP upstream share of mean | 0.548 | +| sync auth per-request mean (ms) | 2.329 | +| session cache entries (end scrape) | 0 | + +### `ttl1k` +Couch /_session + 1s principal cache + +| Metric | Value | +|---|---| +| overall ops/s | 277.6 | +| sync ops/s | 50.4 | +| HTTP ops/s | 658.0 | +| _bulk_get ops/s | 546.5 | +| proxy/direct HTTP ratio | 0.559 | +| HTTP mean duration (ms) | 5.711 | +| HTTP auth per-request mean (ms) | 0.028 | +| HTTP auth mean span (ms) | 0.028 | +| HTTP auth share of mean | 0.005 | +| HTTP upstream per-request mean (ms) | 4.232 | +| HTTP upstream share of mean | 0.741 | +| sync auth per-request mean (ms) | 0.101 | +| session cache entries (end scrape) | 5 | + +### `ttl5k` +Keep-as-is: Couch /_session + default 5s cache + +| Metric | Value | +|---|---| +| overall ops/s | 287.3 | +| sync ops/s | 53.2 | +| HTTP ops/s | 649.7 | +| _bulk_get ops/s | 585.5 | +| proxy/direct HTTP ratio | 0.523 | +| HTTP mean duration (ms) | 5.728 | +| HTTP auth per-request mean (ms) | 0.019 | +| HTTP auth mean span (ms) | 0.019 | +| HTTP auth share of mean | 0.003 | +| HTTP upstream per-request mean (ms) | 4.309 | +| HTTP upstream share of mean | 0.752 | +| sync auth per-request mean (ms) | 0.038 | +| session cache entries (end scrape) | 5 | + +### `ttl30k` +Couch /_session + 30s principal cache + +| Metric | Value | +|---|---| +| overall ops/s | 292.2 | +| sync ops/s | 54.2 | +| HTTP ops/s | 666.8 | +| _bulk_get ops/s | 567.1 | +| proxy/direct HTTP ratio | 0.577 | +| HTTP mean duration (ms) | 5.706 | +| HTTP auth per-request mean (ms) | 0.019 | +| HTTP auth mean span (ms) | 0.019 | +| HTTP auth share of mean | 0.003 | +| HTTP upstream per-request mean (ms) | 4.293 | +| HTTP upstream share of mean | 0.752 | +| sync auth per-request mean (ms) | 0.067 | +| session cache entries (end scrape) | 5 | + +### `hybrid-jwt` +Bearer local HS256 fast-path + 5s cache; Basic/Cookie → Couch + +| Metric | Value | +|---|---| +| overall ops/s | 284.4 | +| sync ops/s | 51.7 | +| HTTP ops/s | 672.0 | +| _bulk_get ops/s | 556.7 | +| proxy/direct HTTP ratio | 0.584 | +| HTTP mean duration (ms) | 5.536 | +| HTTP auth per-request mean (ms) | 0.016 | +| HTTP auth mean span (ms) | 0.016 | +| HTTP auth share of mean | 0.003 | +| HTTP upstream per-request mean (ms) | 4.164 | +| HTTP upstream share of mean | 0.752 | +| sync auth per-request mean (ms) | 0.039 | +| session cache entries (end scrape) | 5 | + +### `local-jwt` +In-house Bearer JWT only + 5s cache (no /_session) + +| Metric | Value | +|---|---| +| overall ops/s | 280.0 | +| sync ops/s | 51.4 | +| HTTP ops/s | 653.5 | +| _bulk_get ops/s | 532.2 | +| proxy/direct HTTP ratio | 0.562 | +| HTTP mean duration (ms) | 5.672 | +| HTTP auth per-request mean (ms) | 0.018 | +| HTTP auth mean span (ms) | 0.018 | +| HTTP auth share of mean | 0.003 | +| HTTP upstream per-request mean (ms) | 4.340 | +| HTTP upstream share of mean | 0.765 | +| sync auth per-request mean (ms) | 0.028 | +| session cache entries (end scrape) | 5 | + +### `local-jwt-nocache` +In-house Bearer JWT every request (no principal cache) + +| Metric | Value | +|---|---| +| overall ops/s | 258.9 | +| sync ops/s | 47.7 | +| HTTP ops/s | 585.1 | +| _bulk_get ops/s | 552.5 | +| proxy/direct HTTP ratio | 0.498 | +| HTTP mean duration (ms) | 6.464 | +| HTTP auth per-request mean (ms) | 0.717 | +| HTTP auth mean span (ms) | 0.717 | +| HTTP auth share of mean | 0.111 | +| HTTP upstream per-request mean (ms) | 4.322 | +| HTTP upstream share of mean | 0.669 | +| sync auth per-request mean (ms) | 0.700 | +| session cache entries (end scrape) | 0 | + + +## Critical assessment + +### What the numbers say + +1. **Upstream Couch still dominates.** Across variants, HTTP `upstream` share of + mean request time dwarfs `auth`. Shaving auth cannot move overall throughput + much unless `/_session` is paid on every request *and* concurrent with little + credential reuse. +2. **`TTL=0` (no principal cache) is the expensive Couch-session mode.** + Overall ops/s dropped vs the 5s default (see table). That delta is the real + cost of “proxy auth to Couch session on every request” under this sticky-JWT + harness. +3. **`TTL=1s` / `5s` / `30s` cluster together.** Once the principal cache is + warm for sticky Bearers, extending TTL past 5s buys little throughput and + only widens revocation lag. +4. **Hybrid local JWT ≈ keep-as-is throughput** under sticky tokens: the 5s + session cache already avoids most `/_session` RTTs, so verifying JWT in + process is not a large win on ops/s. It *does* remove dependency on Couch + for identity on the Bearer path (useful if Couch session endpoint is slow + or you need TTL=0 freshness without the RTT). +5. **In-house JWT-only** matches hybrid/keep-as-is when cached; without + principal cache it is still far cheaper than Couch `TTL=0` because jose + verify is local CPU (µs–low ms) vs a network hop. Couch still validates the + same JWT on the upstream request — you only skip the *extra* identity GET. + +### Security risk vs performance gain + +| Approach | Revocation / freshness | Trust / ops risk | Perf vs TTL=0 | +|---|---|---|---| +| Couch session, TTL=0 | Immediate role/`_admin` changes next request | Lowest fork risk | baseline (slowest here) | +| Couch session, TTL=5s (**default**) | Up to **5s** stale roles; `DELETE /_session` invalidates that credential’s cache entry | Same trust model; small stale window | Large gain under sticky clients | +| Couch session, TTL=30s | Up to **30s** stale roles | Wider demotion lag | Marginal over 5s when hit rate already high | +| Local JWT only | Bound by JWT `exp` (role changes in `_users` ignored until re-mint) | Key sync with Couch `[jwt_keys]`; **no Basic/Cookie**; admin setup needs admin JWT | Large vs TTL=0; ≈ warm 5s cache for sticky Bearer | +| Hybrid Bearer local + Couch session | Bearer: JWT `exp`; Basic/Cookie: session TTL | Key sync for Bearer path only | Best product shape for JWT-heavy + legacy Basic | + +**Realistic risk of the default 5s session cache:** after a Couch role/`_admin` +change, the proxy may authorize with a stale principal for up to 5 seconds for +that credential hash. That is comparable to common gateway session caches and +**much shorter than typical JWT lifetimes** (minutes–hours). Logout already +drops the cached entry for those headers. + +**Realistic risk of in-house JWT as sole resolver:** claim-path drift vs Couch; +Basic/Cookie clients become anonymous at the proxy; secret leakage forges any +`sub`/roles (same blast radius as Couch JWT key leakage, but **two** places +must hold the secret). You do **not** get stronger auth — Couch still has to +trust the same keys on the upstream hop. + +**When in-house / hybrid JWT is worth enabling:** measured `auth` share stays +high after TTL=5s (poor cache locality / many distinct tokens), you need +effective TTL=0 without paying Couch RTT, or Bearer-only deployments. Otherwise +**keep Couch `/_session` + 5s cache**. + +### Recommendation + +1. **Default: Couch `/_session` + `SESSION_CACHE_TTL_MS=5000`.** Best + security/ops story for mixed auth; this harness shows it already recovers + most of the session tax vs TTL=0. +2. **Enable hybrid** (`JWT_LOCAL_VERIFY=true` with session still on) for + JWT-heavy apps that still allow Basic/Cookie — keys must match Couch. +3. **Avoid JWT-only** unless the product drops Basic/Cookie and accepts JWT + `exp` as the revocation model. +4. **Avoid TTL=0 in production** unless compliance demands instantaneous role + propagation. +5. **Do not chase 30s+ TTLs** for throughput on sticky workloads; spend + engineering on upstream Couch / ACL miss paths instead. + +## Re-run + +```bash +pnpm test:perf:auth +PERF_AUTH_VARIANTS=ttl0,ttl5k,local-jwt pnpm test:perf:auth +``` diff --git a/package.json b/package.json index 4e2f61b..273012b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:integration": "vitest run --config vitest.integration.config.ts", "test:perf": "vitest run --config vitest.perf.config.ts", "test:perf:profile": "bash scripts/perf-profile.sh", + "test:perf:auth": "bash scripts/perf-auth-strategies.sh", "test:perf:memory": "bash scripts/perf-memory.sh", "docker:up": "docker compose up -d --build", "docker:up:dev": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build", diff --git a/scripts/assemble-auth-strategy-report.mjs b/scripts/assemble-auth-strategy-report.mjs new file mode 100644 index 0000000..a6de256 --- /dev/null +++ b/scripts/assemble-auth-strategy-report.mjs @@ -0,0 +1,278 @@ +/** + * Assemble per-variant results under test/perf/auth-strategy// into + * summary.json + docs/auth-strategy-assessment.md. Scans on-disk variant dirs + * so partial re-runs still produce a full comparison table. + */ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import path from "node:path"; + +const outDir = process.env.PERF_AUTH_OUT || "test/perf/auth-strategy"; +const meta = { + ttl0: { + ttl: 0, + resolve: true, + local: false, + notes: "Couch /_session every request (no principal cache)", + }, + ttl1k: { + ttl: 1000, + resolve: true, + local: false, + notes: "Couch /_session + 1s principal cache", + }, + ttl5k: { + ttl: 5000, + resolve: true, + local: false, + notes: "Keep-as-is: Couch /_session + default 5s cache", + }, + ttl30k: { + ttl: 30000, + resolve: true, + local: false, + notes: "Couch /_session + 30s principal cache", + }, + "hybrid-jwt": { + ttl: 5000, + resolve: true, + local: true, + notes: "Bearer local HS256 fast-path + 5s cache; Basic/Cookie → Couch", + }, + "local-jwt": { + ttl: 5000, + resolve: false, + local: true, + notes: "In-house Bearer JWT only + 5s cache (no /_session)", + }, + "local-jwt-nocache": { + ttl: 0, + resolve: false, + local: true, + notes: "In-house Bearer JWT every request (no principal cache)", + }, +}; + +const harness = { + clients: Number(process.env.PERF_CLIENTS || 6), + seedDocs: Number(process.env.PERF_SEED_DOCS || 300), + rounds: Number(process.env.PERF_ROUNDS || 3), + docsPerRound: Number(process.env.PERF_DOCS_PER_ROUND || 8), + httpOps: Number(process.env.PERF_HTTP_OPS || 60), +}; + +function phase(profile, name) { + const p = profile?.phases?.[name]; + if (!p) return null; + return { + count: p.count, + meanMs: p.meanMs, + maxMs: p.maxMs, + perRequestMeanMs: p.perRequestMeanMs, + share: profile.phaseShareOfMean?.[name] ?? null, + }; +} + +const variants = []; +for (const [name, m] of Object.entries(meta)) { + const resultsPath = path.join(outDir, name, "results.json"); + const profilePath = path.join(outDir, name, "profile.json"); + if (!existsSync(resultsPath) || !existsSync(profilePath)) continue; + const results = JSON.parse(readFileSync(resultsPath, "utf8")); + const profile = JSON.parse(readFileSync(profilePath, "utf8")); + const overall = results.reports?.overall ?? {}; + const http = results.reports?.httpProxy ?? {}; + const sync = results.reports?.sync ?? {}; + const bulk = results.reports?.bulkGet ?? {}; + const compare = results.reports?.directCompare ?? {}; + const httpProf = results.profiles?.httpProxy; + const syncProf = results.profiles?.sync; + variants.push({ + name, + sessionCacheTtlMs: m.ttl, + resolveViaCouchSession: m.resolve, + jwtLocalVerify: m.local, + notes: m.notes, + overallOpsPerSec: overall.overallOpsPerSec ?? null, + syncOpsPerSec: sync.opsPerSec ?? null, + httpOpsPerSec: http.opsPerSec ?? null, + bulkGetOpsPerSec: bulk.opsPerSec ?? null, + proxyOverDirectRatio: compare.proxyOverDirectRatio ?? null, + overheadPct: compare.overheadPct ?? null, + authHttp: phase(httpProf, "auth"), + authSync: phase(syncProf, "auth"), + upstreamHttp: phase(httpProf, "upstream"), + meanDurationHttp: httpProf?.meanDurationMs ?? null, + authShareHttp: httpProf?.phaseShareOfMean?.auth ?? null, + upstreamShareHttp: httpProf?.phaseShareOfMean?.upstream ?? null, + endProfileAuth: phase(profile, "auth"), + endProfileUpstream: phase(profile, "upstream"), + sessionCacheEntries: profile.resources?.sessionCacheEntries ?? null, + }); +} + +const summary = { + at: new Date().toISOString(), + harness, + note: "Principal cache TTL is SESSION_CACHE_TTL_MS (not an ACL TTL). ACL rows are changes-fed, not time-expired.", + variants, +}; +writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); + +const baseline = variants.find((v) => v.name === "ttl5k") ?? variants[0]; +function rel(v, key) { + const b = baseline?.[key]; + const x = v[key]; + if (b == null || x == null || !Number.isFinite(b) || b === 0) return "—"; + return `${((x / b - 1) * 100).toFixed(1)}%`; +} +function fmt(n, digits = 1) { + if (n == null || !Number.isFinite(n)) return "—"; + return n.toFixed(digits); +} + +const rows = variants + .map((v) => { + const authMs = v.authHttp?.perRequestMeanMs ?? v.authHttp?.meanMs; + return `| \`${v.name}\` | ${v.sessionCacheTtlMs} | ${v.resolveViaCouchSession ? "couch" : "off"} / ${v.jwtLocalVerify ? "local JWT" : "—"} | ${fmt(v.overallOpsPerSec)} | ${fmt(v.httpOpsPerSec)} | ${fmt(authMs, 3)} | ${fmt(v.authShareHttp, 3)} | ${fmt(v.upstreamShareHttp, 3)} | ${rel(v, "overallOpsPerSec")} | ${rel(v, "httpOpsPerSec")} |`; + }) + .join("\n"); + +const detail = variants + .map( + (v) => `### \`${v.name}\` +${v.notes} + +| Metric | Value | +|---|---| +| overall ops/s | ${fmt(v.overallOpsPerSec)} | +| sync ops/s | ${fmt(v.syncOpsPerSec)} | +| HTTP ops/s | ${fmt(v.httpOpsPerSec)} | +| _bulk_get ops/s | ${fmt(v.bulkGetOpsPerSec)} | +| proxy/direct HTTP ratio | ${fmt(v.proxyOverDirectRatio, 3)} | +| HTTP mean duration (ms) | ${fmt(v.meanDurationHttp, 3)} | +| HTTP auth per-request mean (ms) | ${fmt(v.authHttp?.perRequestMeanMs, 3)} | +| HTTP auth mean span (ms) | ${fmt(v.authHttp?.meanMs, 3)} | +| HTTP auth share of mean | ${fmt(v.authShareHttp, 3)} | +| HTTP upstream per-request mean (ms) | ${fmt(v.upstreamHttp?.perRequestMeanMs, 3)} | +| HTTP upstream share of mean | ${fmt(v.upstreamShareHttp, 3)} | +| sync auth per-request mean (ms) | ${fmt(v.authSync?.perRequestMeanMs, 3)} | +| session cache entries (end scrape) | ${v.sessionCacheEntries ?? "—"} | +`, + ) + .join("\n"); + +const md = `# Auth strategy assessment: Couch \`/_session\` vs in-house JWT + +Generated by \`scripts/perf-auth-strategies.sh\` at **${summary.at}**. + +> Clarification: the proxy has **no ACL TTL**. Document ACL is an in-memory map +> invalidated by Couch \`_changes\`. The **5s** knob is \`SESSION_CACHE_TTL_MS\` — +> a short-lived cache of resolved principals (roles / ACL tokens) after Couch +> \`GET /_session\` or local JWT verify. + +## Harness + +| Knob | Value | +|---|---| +| clients | ${harness.clients} | +| seed docs | ${harness.seedDocs} | +| sync rounds | ${harness.rounds} | +| docs/round | ${harness.docsPerRound} | +| HTTP ops/client | ${harness.httpOps} | +| profile | \`PROFILE=true\` (compose profile overlay) | +| baseline for Δ% | \`${baseline?.name ?? "n/a"}\` | + +Perf suite uses **Bearer JWT** clients (\`mintJwt\`), so local-JWT and hybrid +Bearer fast-paths are exercised the same way as Couch session resolution of a +JWT. Basic/Cookie still require Couch \`/_session\` (or a different identity store). + +## Results (relative to keep-as-is \`ttl5k\`) + +| Variant | TTL ms | Resolve | overall ops/s | HTTP ops/s | HTTP auth ms/req | auth share | upstream share | Δ overall | Δ HTTP | +|---|---:|---|---:|---:|---:|---:|---:|---|---| +${rows} + +Raw JSON: [\`test/perf/auth-strategy/summary.json\`](../test/perf/auth-strategy/summary.json). + +## Per-variant detail + +${detail} + +## Critical assessment + +### What the numbers say + +1. **Upstream Couch still dominates.** Across variants, HTTP \`upstream\` share of + mean request time dwarfs \`auth\`. Shaving auth cannot move overall throughput + much unless \`/_session\` is paid on every request *and* concurrent with little + credential reuse. +2. **\`TTL=0\` (no principal cache) is the expensive Couch-session mode.** + Overall ops/s dropped vs the 5s default (see table). That delta is the real + cost of “proxy auth to Couch session on every request” under this sticky-JWT + harness. +3. **\`TTL=1s\` / \`5s\` / \`30s\` cluster together.** Once the principal cache is + warm for sticky Bearers, extending TTL past 5s buys little throughput and + only widens revocation lag. +4. **Hybrid local JWT ≈ keep-as-is throughput** under sticky tokens: the 5s + session cache already avoids most \`/_session\` RTTs, so verifying JWT in + process is not a large win on ops/s. It *does* remove dependency on Couch + for identity on the Bearer path (useful if Couch session endpoint is slow + or you need TTL=0 freshness without the RTT). +5. **In-house JWT-only** matches hybrid/keep-as-is when cached; without + principal cache it is still far cheaper than Couch \`TTL=0\` because jose + verify is local CPU (µs–low ms) vs a network hop. Couch still validates the + same JWT on the upstream request — you only skip the *extra* identity GET. + +### Security risk vs performance gain + +| Approach | Revocation / freshness | Trust / ops risk | Perf vs TTL=0 | +|---|---|---|---| +| Couch session, TTL=0 | Immediate role/\`_admin\` changes next request | Lowest fork risk | baseline (slowest here) | +| Couch session, TTL=5s (**default**) | Up to **5s** stale roles; \`DELETE /_session\` invalidates that credential’s cache entry | Same trust model; small stale window | Large gain under sticky clients | +| Couch session, TTL=30s | Up to **30s** stale roles | Wider demotion lag | Marginal over 5s when hit rate already high | +| Local JWT only | Bound by JWT \`exp\` (role changes in \`_users\` ignored until re-mint) | Key sync with Couch \`[jwt_keys]\`; **no Basic/Cookie**; admin setup needs admin JWT | Large vs TTL=0; ≈ warm 5s cache for sticky Bearer | +| Hybrid Bearer local + Couch session | Bearer: JWT \`exp\`; Basic/Cookie: session TTL | Key sync for Bearer path only | Best product shape for JWT-heavy + legacy Basic | + +**Realistic risk of the default 5s session cache:** after a Couch role/\`_admin\` +change, the proxy may authorize with a stale principal for up to 5 seconds for +that credential hash. That is comparable to common gateway session caches and +**much shorter than typical JWT lifetimes** (minutes–hours). Logout already +drops the cached entry for those headers. + +**Realistic risk of in-house JWT as sole resolver:** claim-path drift vs Couch; +Basic/Cookie clients become anonymous at the proxy; secret leakage forges any +\`sub\`/roles (same blast radius as Couch JWT key leakage, but **two** places +must hold the secret). You do **not** get stronger auth — Couch still has to +trust the same keys on the upstream hop. + +**When in-house / hybrid JWT is worth enabling:** measured \`auth\` share stays +high after TTL=5s (poor cache locality / many distinct tokens), you need +effective TTL=0 without paying Couch RTT, or Bearer-only deployments. Otherwise +**keep Couch \`/_session\` + 5s cache**. + +### Recommendation + +1. **Default: Couch \`/_session\` + \`SESSION_CACHE_TTL_MS=5000\`.** Best + security/ops story for mixed auth; this harness shows it already recovers + most of the session tax vs TTL=0. +2. **Enable hybrid** (\`JWT_LOCAL_VERIFY=true\` with session still on) for + JWT-heavy apps that still allow Basic/Cookie — keys must match Couch. +3. **Avoid JWT-only** unless the product drops Basic/Cookie and accepts JWT + \`exp\` as the revocation model. +4. **Avoid TTL=0 in production** unless compliance demands instantaneous role + propagation. +5. **Do not chase 30s+ TTLs** for throughput on sticky workloads; spend + engineering on upstream Couch / ACL miss paths instead. + +## Re-run + +\`\`\`bash +pnpm test:perf:auth +PERF_AUTH_VARIANTS=ttl0,ttl5k,local-jwt pnpm test:perf:auth +\`\`\` +`; + +writeFileSync("docs/auth-strategy-assessment.md", md); +console.log( + `Wrote ${path.join(outDir, "summary.json")} (${variants.length} variants) and docs/auth-strategy-assessment.md`, +); diff --git a/scripts/perf-auth-strategies.sh b/scripts/perf-auth-strategies.sh new file mode 100755 index 0000000..7c61e79 --- /dev/null +++ b/scripts/perf-auth-strategies.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Benchmark auth strategy variants against the ACL perf harness (PROFILE=true). +# +# Variants (recreate proxy env each run; Couch stays up): +# 1. couch-session TTL=0 — re-resolve via GET /_session every request +# 2. couch-session TTL=1000 — 1s principal cache +# 3. couch-session TTL=5000 — default / keep-as-is (5s) +# 4. couch-session TTL=30000 — 30s principal cache +# 5. hybrid-bearer-jwt TTL=5000 — local HS256 for Bearer + Couch session for Basic/Cookie +# 6. local-jwt-only TTL=5000 — AUTH_RESOLVE_VIA_COUCH_SESSION=false (Bearer only) +# 7. local-jwt-only TTL=0 — local verify every request (no principal cache) +# +# Usage: +# bash scripts/perf-auth-strategies.sh +# PERF_AUTH_VARIANTS=ttl0,ttl5k,local-jwt bash scripts/perf-auth-strategies.sh +# +# Writes: +# test/perf/auth-strategy//{results.json,profile.json} +# test/perf/auth-strategy/summary.json +# docs/auth-strategy-assessment.md +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.profile.yml) +PROXY_URL="${COUCH_AUTH_PROXY_URL:-http://127.0.0.1:8000}" +OUT_DIR="${PERF_AUTH_OUT:-test/perf/auth-strategy}" +# Slightly smaller than defaults so seven variants finish in a reasonable window. +export PERF_CLIENTS="${PERF_CLIENTS:-6}" +export PERF_SEED_DOCS="${PERF_SEED_DOCS:-300}" +export PERF_ROUNDS="${PERF_ROUNDS:-3}" +export PERF_DOCS_PER_ROUND="${PERF_DOCS_PER_ROUND:-8}" +export PERF_HTTP_OPS="${PERF_HTTP_OPS:-60}" +export PERF_MIN_OPS_PER_SEC="${PERF_MIN_OPS_PER_SEC:-10}" + +die() { echo "error: $*" >&2; exit 1; } + +wait_ready() { + for i in $(seq 1 90); do + if curl -sf "${PROXY_URL}/_couch-auth-proxy/ready" >/dev/null; then + return 0 + fi + sleep 2 + done + "${COMPOSE[@]}" logs couch-auth-proxy couchdb || true + die "proxy not ready at ${PROXY_URL}" +} + +# name|SESSION_CACHE_TTL_MS|AUTH_RESOLVE_VIA_COUCH_SESSION|JWT_LOCAL_VERIFY|notes +ALL_VARIANTS=( + "ttl0|0|true|false|Couch /_session every request (no principal cache)" + "ttl1k|1000|true|false|Couch /_session + 1s principal cache" + "ttl5k|5000|true|false|Keep-as-is: Couch /_session + default 5s cache" + "ttl30k|30000|true|false|Couch /_session + 30s principal cache" + "hybrid-jwt|5000|true|true|Bearer local HS256 fast-path + 5s cache; Basic/Cookie → Couch" + "local-jwt|5000|false|true|In-house Bearer JWT only + 5s cache (no /_session)" + "local-jwt-nocache|0|false|true|In-house Bearer JWT every request (no principal cache)" +) + +SELECTED="${PERF_AUTH_VARIANTS:-}" +should_run() { + local name="$1" + [[ -z "${SELECTED}" ]] && return 0 + [[ ",${SELECTED}," == *",${name},"* ]] +} + +mkdir -p "${OUT_DIR}" +echo "==> bringing up stack (profile overlay)" +sg docker -c "${COMPOSE[*]} up -d --build" +wait_ready + +for entry in "${ALL_VARIANTS[@]}"; do + IFS='|' read -r NAME TTL RESOLVE LOCAL NOTES <<<"${entry}" + should_run "${NAME}" || { echo "==> skip ${NAME}"; continue; } + + echo + echo "============================================================" + echo "==> variant: ${NAME}" + echo " SESSION_CACHE_TTL_MS=${TTL}" + echo " AUTH_RESOLVE_VIA_COUCH_SESSION=${RESOLVE}" + echo " JWT_LOCAL_VERIFY=${LOCAL}" + echo " ${NOTES}" + echo "============================================================" + + VARIANT_DIR="${OUT_DIR}/${NAME}" + mkdir -p "${VARIANT_DIR}" + + # Recreate only the proxy so Couch ACL state / warm caches stay comparable. + sg docker -c "SESSION_CACHE_TTL_MS='${TTL}' \ + AUTH_RESOLVE_VIA_COUCH_SESSION='${RESOLVE}' \ + JWT_LOCAL_VERIFY='${LOCAL}' \ + JWT_HMAC_SECRET='${JWT_HMAC_SECRET:-couch-auth-proxy-dev-secret}' \ + PROFILE=true \ + ${COMPOSE[*]} up -d --no-deps --force-recreate couch-auth-proxy" + + wait_ready + curl -sf -X POST "${PROXY_URL}/_couch-auth-proxy/profile/reset" >/dev/null || + die "profile endpoint unavailable" + + RESULTS_PATH="${VARIANT_DIR}/results.json" + PROFILE_PATH="${VARIANT_DIR}/profile.json" + + # Bearer-only proxy cannot authenticate admin Basic; mint _admin JWT for setup. + RUN_ENV=(PERF_RESULTS_PATH="${RESULTS_PATH}") + if [[ "${RESOLVE}" == "false" ]]; then + ADMIN_JWT="$( + JWT_HMAC_SECRET="${JWT_HMAC_SECRET:-couch-auth-proxy-dev-secret}" \ + node --input-type=module <<'MINT' +import { SignJWT } from "jose"; +const secret = process.env.JWT_HMAC_SECRET || "couch-auth-proxy-dev-secret"; +const jwt = await new SignJWT({ "_couchdb.roles": ["_admin"] }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(process.env.COUCH_ADMIN_USER || "admin") + .setExpirationTime("2h") + .sign(new TextEncoder().encode(secret)); +process.stdout.write(jwt); +MINT + )" + RUN_ENV+=(PERF_ADMIN_JWT="${ADMIN_JWT}") + fi + + env "${RUN_ENV[@]}" pnpm test:perf + + curl -sf "${PROXY_URL}/_couch-auth-proxy/profile" | tee "${PROFILE_PATH}" >/dev/null + echo "Wrote ${RESULTS_PATH} and ${PROFILE_PATH}" +done + +echo +echo "==> assembling summary + assessment doc" +PERF_AUTH_OUT="${OUT_DIR}" node scripts/assemble-auth-strategy-report.mjs +echo "==> done" diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index ddc75e2..d827d8b 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -1,10 +1,13 @@ /** * Optional local JWT verification (HS256). * - * Prefer `SessionResolver` (Couch `GET /_session`) for production parity — - * Couch validates Bearer tokens with its `[jwt_keys]` / `[jwt_auth]` config - * and couch-auth-proxy never forks JWT semantics. Use this helper only when keys are - * guaranteed identical to Couch and a round-trip must be avoided (or in tests). + * Prefer Couch `GET /_session` for production parity when clients may use + * Basic/Cookie — Couch owns those handlers. Use this helper when: + * - `AUTH_RESOLVE_VIA_COUCH_SESSION=false` (Bearer-only deployments), or + * - both flags are on and Bearer tokens should skip the `/_session` RTT + * (keys must match Couch `[jwt_keys]` / `[jwt_auth]`). + * + * Upstream Couch still independently validates the forwarded Bearer token. */ import * as jose from "jose"; import type { AppConfig } from "../config.js"; diff --git a/src/auth/session.ts b/src/auth/session.ts index 7fd07de..28ade17 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,13 +1,18 @@ /** * Resolve the caller identity the same way CouchDB would. * - * Forwards `Authorization` (Basic / Bearer JWT) and `Cookie` to Couch - * `GET /_session`, then builds a `Principal` from the response. This is the - * preferred JWT strategy — Couch validates tokens with its own keys; - * couch-auth-proxy never forks JWT semantics. + * Preferred path: forward `Authorization` (Basic / Bearer JWT) and `Cookie` + * to Couch `GET /_session`, then build a `Principal` from the response so + * JWT/cookie/basic semantics stay owned by Couch. * - * Results are cached briefly (LRU + TTL, default 5000ms) keyed by a hash of - * credentials so hot paths avoid a session round-trip on every request. + * Optional fast path: when `JWT_LOCAL_VERIFY=true` and the request carries a + * Bearer token, verify HS256 locally with the same secret Couch trusts + * (`JWT_HMAC_SECRET`). With `AUTH_RESOLVE_VIA_COUCH_SESSION=true` this skips + * the `/_session` RTT for Bearer clients only (Basic/Cookie still use Couch). + * With session resolve disabled, Bearer local-verify is the sole resolver. + * + * Couch `/_session` results (and successful local JWT principals) are cached + * briefly (LRU + TTL, default 5000ms) keyed by a hash of credentials. * Concurrent identical lookups also coalesce in-flight. Set * `SESSION_CACHE_TTL_MS=0` to re-resolve on every request. */ @@ -51,7 +56,8 @@ export class SessionResolver { /** * Resolve identity from incoming request headers. - * Missing credentials → anonymous. Couch 401 → anonymous (upstream may still reject). + * Missing credentials → anonymous. Couch 401 / invalid JWT → anonymous + * (upstream may still reject the forwarded credential). */ async resolve(headers: Headers): Promise { const auth = headers.get("authorization") ?? ""; @@ -63,38 +69,6 @@ export class SessionResolver { return anonymousPrincipal(); } - if (!this.config.auth.resolveViaCouchSession) { - if (!this.config.auth.jwt.enabled) { - log.debug("resolve local-jwt disabled; anonymous"); - return anonymousPrincipal(); - } - const token = bearerToken(auth); - if (!token) { - if (isLevelEnabled("verbose")) { - log.verbose("resolve", { reason: "local-jwt-missing-bearer", user: null }); - } - return anonymousPrincipal(); - } - try { - const principal = await verifyJwtLocally(token, this.config); - if (isLevelEnabled("verbose")) { - log.verbose("resolve", { - reason: "local-jwt", - user: principal.name, - admin: principal.admin, - roles: principal.roles, - aclTokenCount: principal.aclTokens.length, - }); - } - return principal; - } catch (err) { - // Invalid/expired JWTs are anonymous for ACL purposes. Couch will - // independently reject the forwarded credential. - log.debug("resolve local-jwt failed; anonymous", { err: String(err) }); - return anonymousPrincipal(); - } - } - const cacheKey = hashCreds(auth, cookie); const cached = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { @@ -110,6 +84,43 @@ export class SessionResolver { return cached.principal; } + // Bearer + local verify: skip Couch `/_session` when keys match Couch. + // Used as sole resolver when session resolve is off, or as a Bearer + // fast-path when both are enabled (Basic/Cookie still hit Couch). + if (this.config.auth.jwt.enabled) { + const token = bearerToken(auth); + if (token) { + try { + const principal = await verifyJwtLocally(token, this.config); + this.storeCache(cacheKey, principal); + if (isLevelEnabled("verbose")) { + log.verbose("resolve", { + reason: "local-jwt", + user: principal.name, + admin: principal.admin, + roles: principal.roles, + aclTokenCount: principal.aclTokens.length, + }); + } + return principal; + } catch (err) { + // Invalid/expired JWTs are anonymous for ACL purposes. Couch will + // independently reject the forwarded credential on the upstream hop. + log.debug("resolve local-jwt failed; anonymous", { err: String(err) }); + return anonymousPrincipal(); + } + } + if (!this.config.auth.resolveViaCouchSession) { + if (isLevelEnabled("verbose")) { + log.verbose("resolve", { reason: "local-jwt-missing-bearer", user: null }); + } + return anonymousPrincipal(); + } + } else if (!this.config.auth.resolveViaCouchSession) { + log.debug("resolve local-jwt disabled; anonymous"); + return anonymousPrincipal(); + } + const pending = this.inflight.get(cacheKey); if (pending) { if (isLevelEnabled("verbose")) { @@ -128,6 +139,16 @@ export class SessionResolver { return lookup; } + /** Store a principal when `SESSION_CACHE_TTL_MS > 0`. */ + private storeCache(cacheKey: string, principal: Principal): void { + if (this.config.couch.sessionCacheTtlMs > 0) { + this.cache.set(cacheKey, { + principal, + expiresAt: Date.now() + this.config.couch.sessionCacheTtlMs, + }); + } + } + /** One Couch `/_session` fetch + optional TTL cache store. */ private async resolveCouchSession( auth: string, @@ -152,13 +173,7 @@ export class SessionResolver { const body = (await res.json()) as SessionInfo; const principal = buildPrincipal(body); - - if (this.config.couch.sessionCacheTtlMs > 0) { - this.cache.set(cacheKey, { - principal, - expiresAt: Date.now() + this.config.couch.sessionCacheTtlMs, - }); - } + this.storeCache(cacheKey, principal); if (isLevelEnabled("verbose")) { log.verbose("resolve", { diff --git a/src/config.ts b/src/config.ts index 7022550..a270d16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -84,7 +84,9 @@ const ConfigSchema = z resolveViaCouchSession: boolFromEnv.default(true), /** * Optional local JWT verification using the same keys Couch trusts. - * Only used when resolveViaCouchSession is false, or as a future cache. + * When resolveViaCouchSession is false: sole Bearer resolver. + * When both are enabled: Bearer fast-path (skip `/_session` RTT); + * Basic/Cookie still resolve via Couch. */ jwt: z .object({ diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts index 6e6c30e..72ed7f0 100644 --- a/test/integration/helpers.ts +++ b/test/integration/helpers.ts @@ -54,8 +54,16 @@ export function authHeaders( return { Authorization: `Basic ${b64}` }; } -/** Admin Basic-auth headers for privileged setup operations. */ +/** + * Admin auth headers for privileged setup. + * + * Default: HTTP Basic (`admin`/`password`). + * When the proxy runs with `AUTH_RESOLVE_VIA_COUCH_SESSION=false` (Bearer-only + * local JWT), set `PERF_ADMIN_JWT` to a minted admin token (`roles: ["_admin"]`) + * — Basic cannot resolve a principal in that mode. + */ export function adminHeaders(): Record { + if (process.env.PERF_ADMIN_JWT) return authHeaders("jwt", process.env.PERF_ADMIN_JWT); return authHeaders("basic", ADMIN_USER, ADMIN_PASS); } diff --git a/test/perf/auth-strategy/summary.json b/test/perf/auth-strategy/summary.json new file mode 100644 index 0000000..5c92b2d --- /dev/null +++ b/test/perf/auth-strategy/summary.json @@ -0,0 +1,377 @@ +{ + "at": "2026-07-25T02:35:37.210Z", + "harness": { + "clients": 6, + "seedDocs": 300, + "rounds": 3, + "docsPerRound": 8, + "httpOps": 60 + }, + "note": "Principal cache TTL is SESSION_CACHE_TTL_MS (not an ACL TTL). ACL rows are changes-fed, not time-expired.", + "variants": [ + { + "name": "ttl0", + "sessionCacheTtlMs": 0, + "resolveViaCouchSession": true, + "jwtLocalVerify": false, + "notes": "Couch /_session every request (no principal cache)", + "overallOpsPerSec": 245.7196664260477, + "syncOpsPerSec": 46.49810124486921, + "httpOpsPerSec": 537.5202802668967, + "bulkGetOpsPerSec": 490.5175163498525, + "proxyOverDirectRatio": 0.45065219814170576, + "overheadPct": 54.934780185829425, + "authHttp": { + "count": 360, + "meanMs": 1.9662689555555795, + "maxMs": 4.590677000000142, + "perRequestMeanMs": 1.9662689555555795, + "share": 0.26216919407407724 + }, + "authSync": { + "count": 584, + "meanMs": 2.328523851027385, + "maxMs": 10.34972900000048, + "perRequestMeanMs": 2.328523851027385, + "share": 0.25285569523986484 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.1406970486111, + "maxMs": 19.546198000000004, + "perRequestMeanMs": 4.11255763888888, + "share": 0.5483410185185174 + }, + "meanDurationHttp": 7.5, + "authShareHttp": 0.26216919407407724, + "upstreamShareHttp": 0.5483410185185174, + "endProfileAuth": { + "count": 73, + "meanMs": 2.371059506849384, + "maxMs": 8.105405999999675, + "perRequestMeanMs": 2.371059506849384, + "share": 0.276056370015957 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 5.505017726027377, + "maxMs": 11.34242600000016, + "perRequestMeanMs": 5.505017726027377, + "share": 0.640935078149918 + }, + "sessionCacheEntries": 0 + }, + { + "name": "ttl1k", + "sessionCacheTtlMs": 1000, + "resolveViaCouchSession": true, + "jwtLocalVerify": false, + "notes": "Couch /_session + 1s principal cache", + "overallOpsPerSec": 277.6422990949346, + "syncOpsPerSec": 50.39173183886114, + "httpOpsPerSec": 658.0122386840105, + "bulkGetOpsPerSec": 546.5016674904425, + "proxyOverDirectRatio": 0.5586219466098145, + "overheadPct": 44.137805339018556, + "authHttp": { + "count": 360, + "meanMs": 0.02780729166668405, + "maxMs": 2.0366890000004787, + "perRequestMeanMs": 0.02780729166668405, + "share": 0.004868981031131449 + }, + "authSync": { + "count": 599, + "meanMs": 0.10071441068447816, + "maxMs": 10.089092000000164, + "perRequestMeanMs": 0.10071441068447816, + "share": 0.014319471160693667 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.290292402777788, + "maxMs": 15.707532000000356, + "perRequestMeanMs": 4.23223392222223, + "share": 0.7410526322957212 + }, + "meanDurationHttp": 5.711111111111111, + "authShareHttp": 0.004868981031131449, + "upstreamShareHttp": 0.7410526322957212, + "endProfileAuth": { + "count": 73, + "meanMs": 0.04667500000004397, + "maxMs": 2.1818670000002385, + "perRequestMeanMs": 0.04667500000004397, + "share": 0.006309768518524462 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 6.701564328767122, + "maxMs": 15.911911999999575, + "perRequestMeanMs": 6.701564328767122, + "share": 0.9059522148148147 + }, + "sessionCacheEntries": 5 + }, + { + "name": "ttl5k", + "sessionCacheTtlMs": 5000, + "resolveViaCouchSession": true, + "jwtLocalVerify": false, + "notes": "Keep-as-is: Couch /_session + default 5s cache", + "overallOpsPerSec": 287.2743044204806, + "syncOpsPerSec": 53.22038018558096, + "httpOpsPerSec": 649.65588566875, + "bulkGetOpsPerSec": 585.4874779719494, + "proxyOverDirectRatio": 0.522641297127917, + "overheadPct": 47.73587028720831, + "authHttp": { + "count": 360, + "meanMs": 0.01900438611112602, + "maxMs": 0.5868289999998524, + "perRequestMeanMs": 0.01900438611112602, + "share": 0.0033179335596534272 + }, + "authSync": { + "count": 587, + "meanMs": 0.03823473594545188, + "maxMs": 4.707155000000057, + "perRequestMeanMs": 0.03823473594545188, + "share": 0.0054888212276792015 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.386663399305551, + "maxMs": 17.363460999999916, + "perRequestMeanMs": 4.30933071944444, + "share": 0.7523564786614929 + }, + "meanDurationHttp": 5.727777777777778, + "authShareHttp": 0.0033179335596534272, + "upstreamShareHttp": 0.7523564786614929, + "endProfileAuth": { + "count": 73, + "meanMs": 0.016006191780758093, + "maxMs": 0.05975200000011682, + "perRequestMeanMs": 0.016006191780758093, + "share": 0.0023557499999906067 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 6.218924520547897, + "maxMs": 19.893677000000025, + "perRequestMeanMs": 6.218924520547897, + "share": 0.9152852620967671 + }, + "sessionCacheEntries": 5 + }, + { + "name": "ttl30k", + "sessionCacheTtlMs": 30000, + "resolveViaCouchSession": true, + "jwtLocalVerify": false, + "notes": "Couch /_session + 30s principal cache", + "overallOpsPerSec": 292.2026456323347, + "syncOpsPerSec": 54.19810659938338, + "httpOpsPerSec": 666.7567936641483, + "bulkGetOpsPerSec": 567.1188373421113, + "proxyOverDirectRatio": 0.5767840614796291, + "overheadPct": 42.32159385203709, + "authHttp": { + "count": 360, + "meanMs": 0.01918092500000057, + "maxMs": 0.3013069999997242, + "perRequestMeanMs": 0.01918092500000057, + "share": 0.0033617979552094477 + }, + "authSync": { + "count": 594, + "meanMs": 0.06689586868685293, + "maxMs": 7.097255000000132, + "perRequestMeanMs": 0.06689586868685293, + "share": 0.010105835707016949 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.366214149305547, + "maxMs": 18.545444999999745, + "perRequestMeanMs": 4.292971319444438, + "share": 0.7524195107108069 + }, + "meanDurationHttp": 5.705555555555556, + "authShareHttp": 0.0033617979552094477, + "upstreamShareHttp": 0.7524195107108069, + "endProfileAuth": { + "count": 73, + "meanMs": 0.020862273972628305, + "maxMs": 0.11229300000013609, + "perRequestMeanMs": 0.020862273972628305, + "share": 0.0030277256461269707 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 6.245699191780808, + "maxMs": 14.226880000000165, + "perRequestMeanMs": 6.245699191780808, + "share": 0.9064334811133181 + }, + "sessionCacheEntries": 5 + }, + { + "name": "hybrid-jwt", + "sessionCacheTtlMs": 5000, + "resolveViaCouchSession": true, + "jwtLocalVerify": true, + "notes": "Bearer local HS256 fast-path + 5s cache; Basic/Cookie → Couch", + "overallOpsPerSec": 284.39521988935735, + "syncOpsPerSec": 51.733200854441634, + "httpOpsPerSec": 671.9745638812285, + "bulkGetOpsPerSec": 556.6817536699931, + "proxyOverDirectRatio": 0.584367003806012, + "overheadPct": 41.5632996193988, + "authHttp": { + "count": 360, + "meanMs": 0.01646345000002738, + "maxMs": 0.12144899999930203, + "perRequestMeanMs": 0.01646345000002738, + "share": 0.002973829402915131 + }, + "authSync": { + "count": 587, + "meanMs": 0.039091850085180985, + "maxMs": 2.6672789999997804, + "perRequestMeanMs": 0.039091850085180985, + "share": 0.005591353801169892 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.205243972222231, + "maxMs": 15.626599999999598, + "perRequestMeanMs": 4.164195177777785, + "share": 0.7521877892624197 + }, + "meanDurationHttp": 5.536111111111111, + "authShareHttp": 0.002973829402915131, + "upstreamShareHttp": 0.7521877892624197, + "endProfileAuth": { + "count": 73, + "meanMs": 0.017158767123251043, + "maxMs": 0.075619999999617, + "perRequestMeanMs": 0.017158767123251043, + "share": 0.002436945525286627 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 6.46149769863016, + "maxMs": 14.62772600000062, + "perRequestMeanMs": 6.46149769863016, + "share": 0.9176835252918322 + }, + "sessionCacheEntries": 5 + }, + { + "name": "local-jwt", + "sessionCacheTtlMs": 5000, + "resolveViaCouchSession": false, + "jwtLocalVerify": true, + "notes": "In-house Bearer JWT only + 5s cache (no /_session)", + "overallOpsPerSec": 279.96482019675346, + "syncOpsPerSec": 51.43925354891222, + "httpOpsPerSec": 653.5341805075135, + "bulkGetOpsPerSec": 532.1532381960625, + "proxyOverDirectRatio": 0.5621091654563491, + "overheadPct": 43.789083454365084, + "authHttp": { + "count": 360, + "meanMs": 0.017989288888897035, + "maxMs": 0.3116549999995186, + "perRequestMeanMs": 0.017989288888897035, + "share": 0.003171471106759516 + }, + "authSync": { + "count": 573, + "meanMs": 0.028177776614269102, + "maxMs": 0.6010270000001583, + "perRequestMeanMs": 0.028177776614269102, + "share": 0.003962175705515631 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.4246295381944565, + "maxMs": 15.291150000000016, + "perRequestMeanMs": 4.339703630555565, + "share": 0.7650799740450556 + }, + "meanDurationHttp": 5.6722222222222225, + "authShareHttp": 0.003171471106759516, + "upstreamShareHttp": 0.7650799740450556, + "endProfileAuth": { + "count": 73, + "meanMs": 0.01712083561654266, + "maxMs": 0.043398000000706816, + "perRequestMeanMs": 0.01712083561654266, + "share": 0.0024651301775298114 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 6.373234287671151, + "maxMs": 16.223038000000088, + "perRequestMeanMs": 6.373234287671151, + "share": 0.917645173570008 + }, + "sessionCacheEntries": 5 + }, + { + "name": "local-jwt-nocache", + "sessionCacheTtlMs": 0, + "resolveViaCouchSession": false, + "jwtLocalVerify": true, + "notes": "In-house Bearer JWT every request (no principal cache)", + "overallOpsPerSec": 258.92440169539356, + "syncOpsPerSec": 47.72880564754593, + "httpOpsPerSec": 585.0971361443884, + "bulkGetOpsPerSec": 552.4816940562034, + "proxyOverDirectRatio": 0.4977391123414309, + "overheadPct": 50.2260887658569, + "authHttp": { + "count": 360, + "meanMs": 0.7172263944444113, + "maxMs": 8.189414000000397, + "perRequestMeanMs": 0.7172263944444113, + "share": 0.11095896089384963 + }, + "authSync": { + "count": 584, + "meanMs": 0.6999452106164191, + "maxMs": 4.359843999999612, + "perRequestMeanMs": 0.6999452106164191, + "share": 0.08934819737704672 + }, + "upstreamHttp": { + "count": 288, + "meanMs": 5.402267885416677, + "maxMs": 16.16096799999923, + "perRequestMeanMs": 4.321814308333342, + "share": 0.6686090034379042 + }, + "meanDurationHttp": 6.463888888888889, + "authShareHttp": 0.11095896089384963, + "upstreamShareHttp": 0.6686090034379042, + "endProfileAuth": { + "count": 73, + "meanMs": 1.0872129863013944, + "maxMs": 3.741559999999481, + "perRequestMeanMs": 1.0872129863013944, + "share": 0.1480719179104511 + }, + "endProfileUpstream": { + "count": 73, + "meanMs": 5.613260424657536, + "maxMs": 8.971918999999616, + "perRequestMeanMs": 5.613260424657536, + "share": 0.7644925578358212 + }, + "sessionCacheEntries": 0 + } + ] +} diff --git a/test/unit/jwt-local.test.ts b/test/unit/jwt-local.test.ts index 69b65ed..2fef6f8 100644 --- a/test/unit/jwt-local.test.ts +++ b/test/unit/jwt-local.test.ts @@ -47,6 +47,69 @@ describe("local JWT authentication", () => { couchFetch.mockRestore(); }); + it("skips Couch /_session for Bearer when local verify is enabled alongside session resolve", async () => { + const config = loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + AUTH_RESOLVE_VIA_COUCH_SESSION: "true", + JWT_LOCAL_VERIFY: "true", + JWT_HMAC_SECRET: SECRET, + RATE_LIMIT_ENABLED: "false", + }); + const resolver = new SessionResolver(config); + const couchFetch = vi.spyOn(globalThis, "fetch"); + const principal = await resolver.resolve( + new Headers({ Authorization: `Bearer ${await token("carol", ["readers"])}` }), + ); + + expect(principal.name).toBe("carol"); + expect(principal.roles).toEqual(["readers"]); + expect(couchFetch).not.toHaveBeenCalled(); + couchFetch.mockRestore(); + }); + + it("still uses Couch /_session for Basic when hybrid local JWT is enabled", async () => { + const config = loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + AUTH_RESOLVE_VIA_COUCH_SESSION: "true", + JWT_LOCAL_VERIFY: "true", + JWT_HMAC_SECRET: SECRET, + RATE_LIMIT_ENABLED: "false", + SESSION_CACHE_TTL_MS: "0", + }); + const couchFetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: true, userCtx: { name: "bob", roles: ["writers"] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const resolver = new SessionResolver(config); + const principal = await resolver.resolve( + new Headers({ Authorization: "Basic Ym9iOmJvYi1wYXNz" }), + ); + + expect(principal.name).toBe("bob"); + expect(couchFetch).toHaveBeenCalledTimes(1); + couchFetch.mockRestore(); + }); + + it("caches local JWT principals under SESSION_CACHE_TTL_MS", async () => { + const config = loadConfig({ + COUCH_URL: "http://127.0.0.1:5984", + AUTH_RESOLVE_VIA_COUCH_SESSION: "false", + JWT_LOCAL_VERIFY: "true", + JWT_HMAC_SECRET: SECRET, + RATE_LIMIT_ENABLED: "false", + }); + expect(config.couch.sessionCacheTtlMs).toBe(5000); + const resolver = new SessionResolver(config); + const headers = new Headers({ Authorization: `Bearer ${await token("alice", ["readers"])}` }); + + expect((await resolver.resolve(headers)).name).toBe("alice"); + expect(resolver.resourceStats().sessionCacheEntries).toBe(1); + expect((await resolver.resolve(headers)).name).toBe("alice"); + expect(resolver.resourceStats().sessionCacheEntries).toBe(1); + }); + it("fails closed to anonymous for invalid tokens", async () => { const resolver = new SessionResolver(localConfig()); const principal = await resolver.resolve(