Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt>"
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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/

Expand Down
17 changes: 13 additions & 4 deletions docker-compose.profile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@
# 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:
PROFILE: "true"
# 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}
247 changes: 247 additions & 0 deletions docs/auth-strategy-assessment.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading