From 921081ed88bf5f8be1f7f4714399b806d080f0fe Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Mon, 6 Jul 2026 14:32:30 +0200 Subject: [PATCH 1/3] docs: replace api-gateway specs with authenticator + gateway (nginx BFF step 01) Remove the docs/components/backend/api-gateway/ tree (umbrella PRD/DESIGN, bff/, router/) per the nginx + authorization decision: the custom Rust Router is replaced by an OpenResty gateway, and the BFF survives as a standalone authenticator service. New artifacts: - docs/components/backend/authenticator/PRD.md + DESIGN.md - OIDC login, stable-session-id / rotating-credential split, login-minted linked gateway JWT (sub=person_id, tenants[], roles, sid), /internal/authz exchange contract with Cache-Control semantics, background IdP token refresh (fail-open transport / fail-closed invalid_grant), RFC 7523 service tokens with a gitops registry, two-listener reachability, first-admin bootstrap, gear anatomy. DD-ROUTER-05 and the "no IdP refresh in v1" carve-out recorded as superseded; DD-ROUTER-03/09/10 and DD-BFF-01/02/03/09 carried over. EdDSA vs ES256 recorded as open (resolved in step 04). - docs/components/backend/gateway/DESIGN.md - nginx edge: ingress- orthogonal topology, route configurator (salvaged routes.yaml schema, minus websocket_max_lifetime_seconds), generated location hygiene block, subrequest contract, Lua exchange cache / correlation ids / RFC 9457 error shaping, failure table, reload procedure. Parent backend specs (PRD/DESIGN) updated: authentication rewritten to the token-handler model, architecture diagram and routing rules now go through the gateway, authenticator + gateway components added, first- login and analytics-query sequences updated. cypilot artifacts registry updated accordingly. Note: cfs validate --local-only currently reports "No artifacts found in registry" for this repo (pre-existing cypilot issue, reproducible on untouched artifacts from main); cfs toc and cfs check-language pass on all touched artifacts, and a manual where-used sweep found no dangling cpt-insightspec-* references. Part of EPIC #1583. Closes #1584. Co-Authored-By: Claude Fable 5 Signed-off-by: Anton Zelenov --- .cf-studio/config/artifacts.toml | 18 +- docs/components/backend/api-gateway/DESIGN.md | 189 --- docs/components/backend/api-gateway/PRD.md | 142 --- .../backend/api-gateway/bff/DESIGN.md | 959 ---------------- .../components/backend/api-gateway/bff/PRD.md | 596 ---------- .../backend/api-gateway/router/DESIGN.md | 841 -------------- .../backend/api-gateway/router/PRD.md | 479 -------- .../backend/authenticator/DESIGN.md | 1014 +++++++++++++++++ docs/components/backend/authenticator/PRD.md | 758 ++++++++++++ docs/components/backend/gateway/DESIGN.md | 435 +++++++ docs/components/backend/specs/DESIGN.md | 126 +- docs/components/backend/specs/PRD.md | 10 +- 12 files changed, 2317 insertions(+), 3250 deletions(-) delete mode 100644 docs/components/backend/api-gateway/DESIGN.md delete mode 100644 docs/components/backend/api-gateway/PRD.md delete mode 100644 docs/components/backend/api-gateway/bff/DESIGN.md delete mode 100644 docs/components/backend/api-gateway/bff/PRD.md delete mode 100644 docs/components/backend/api-gateway/router/DESIGN.md delete mode 100644 docs/components/backend/api-gateway/router/PRD.md create mode 100644 docs/components/backend/authenticator/DESIGN.md create mode 100644 docs/components/backend/authenticator/PRD.md create mode 100644 docs/components/backend/gateway/DESIGN.md diff --git a/.cf-studio/config/artifacts.toml b/.cf-studio/config/artifacts.toml index b43ac6fdb..a32250ae4 100644 --- a/.cf-studio/config/artifacts.toml +++ b/.cf-studio/config/artifacts.toml @@ -576,26 +576,20 @@ traceability = "DOCS-ONLY" [[systems.artifacts]] kind = "PRD" -path = "docs/components/backend/api-gateway/bff/PRD.md" -name = "BFF (Backend-for-Frontend) PRD" +path = "docs/components/backend/authenticator/PRD.md" +name = "Authenticator Service PRD" traceability = "DOCS-ONLY" [[systems.artifacts]] kind = "DESIGN" -path = "docs/components/backend/api-gateway/bff/DESIGN.md" -name = "BFF Architecture" -traceability = "DOCS-ONLY" - -[[systems.artifacts]] -kind = "PRD" -path = "docs/components/backend/api-gateway/router/PRD.md" -name = "API Gateway Router PRD" +path = "docs/components/backend/authenticator/DESIGN.md" +name = "Authenticator Service Design" traceability = "DOCS-ONLY" [[systems.artifacts]] kind = "DESIGN" -path = "docs/components/backend/api-gateway/router/DESIGN.md" -name = "API Gateway Router Architecture" +path = "docs/components/backend/gateway/DESIGN.md" +name = "Gateway (nginx edge) Design" traceability = "DOCS-ONLY" [[systems.artifacts]] diff --git a/docs/components/backend/api-gateway/DESIGN.md b/docs/components/backend/api-gateway/DESIGN.md deleted file mode 100644 index b8590870c..000000000 --- a/docs/components/backend/api-gateway/DESIGN.md +++ /dev/null @@ -1,189 +0,0 @@ ---- -status: proposed -date: 2026-04-28 ---- - -# DESIGN -- API Gateway - -- [ ] `p3` - **ID**: `cpt-insightspec-design-gw` - - - -- [1. Architecture Overview](#1-architecture-overview) -- [2. Module Boundary](#2-module-boundary) -- [3. Shared Infrastructure](#3-shared-infrastructure) -- [4. Deployment Topology](#4-deployment-topology) -- [5. Cross-Cutting Concerns](#5-cross-cutting-concerns) - - [5.1 Configuration Surface](#51-configuration-surface) - - [5.2 Observability](#52-observability) - - [5.3 Failure Handling](#53-failure-handling) -- [6. Traceability](#6-traceability) - - - ---- - -## 1. Architecture Overview - -The API Gateway is a single Rust binary built on cyberfabric-core ModKit. Two modules, one process, one TLS endpoint: - -```mermaid -graph TB - subgraph Browser - SPA[React SPA] - end - - subgraph Ingress - ING[K8s Ingress
TLS · HSTS] - end - - subgraph GW["API Gateway pod (single binary)"] - BFF["BFF module
/auth/*"] - ROUTER["Router module
/api/* + JWKS"] - SM["Session Manager (lib)
BFF-owned, used by both"] - end - - subgraph State - RD[(Redis
bff:* + router:*)] - CM[K8s ConfigMap
routes.yaml] - SECRET[K8s Secret
signing keys] - end - - subgraph External - OIDC[Customer OIDC Provider] - ID[Identity Service] - AU[Audit Service] - end - - subgraph DS[Downstream Services] - S1[Analytics API] - S2[Connector Manager] - S3[Identity Service API] - SX[...] - end - - SPA --> ING - ING --> BFF - ING --> ROUTER - BFF --> SM - ROUTER --> SM - SM --> RD - ROUTER --> CM - ROUTER --> SECRET - BFF <--> OIDC - OIDC -.->|back-channel logout| BFF - BFF --> ID - BFF --> AU - ROUTER -->|Bearer JWT| S1 - ROUTER -->|Bearer JWT| S2 - ROUTER -->|Bearer JWT| S3 - ROUTER -->|Bearer JWT| SX - S1 -.JWKS.-> ROUTER - S2 -.JWKS.-> ROUTER - S3 -.JWKS.-> ROUTER - SX -.JWKS.-> ROUTER -``` - -Detailed component models, sequence diagrams, and data models live in the module DESIGNs: - -- [BFF DESIGN](./bff/DESIGN.md) -- session lifecycle, OIDC, Redis layout, gateway JWT contract. -- [Router DESIGN](./router/DESIGN.md) -- JWT mint, JWKS, route table, reverse proxy. - -## 2. Module Boundary - -| Concern | Owner | Notes | -|---|---|---| -| OIDC handshake | BFF | Router never talks to the IdP | -| Session create / refresh / revoke | BFF | All ops are HMGET + MULTI/EXEC pipelines; no Lua scripts | -| Cookie issue / clear | BFF | Router never sets cookies | -| CSRF token issue & verify on `/auth/*` | BFF | Router relies on `SameSite=Strict` for `/api/*` | -| IdP access-token refresh | _(not in v1)_ | Tokens not stored or refreshed; v1 never calls IdP-protected APIs on the user's behalf. | -| Cookie validation on `/api/*` | Router (read-only via shared session manager) | Calls into the BFF-owned library | -| Gateway JWT mint + sign | Router | EdDSA, claims defined in BFF DESIGN §3.8 | -| JWKS publication | Router | `/.well-known/jwks.json` | -| Reverse proxy `/api/*` | Router | Forwards with `Authorization: Bearer ` | -| Route table + hot reload | Router | ConfigMap-driven | -| Signing key store + rotation | Router | K8s Secret-driven | -| Session manager library | BFF | Used by Router as a Rust crate | - -## 3. Shared Infrastructure - -| Resource | Used by | Notes | -|---|---|---| -| Redis client | both | Single connection pool, multiplexed; `bff:*` keys vs `router:*` keys | -| Audit emitter | both | Single Redpanda producer; auth events from BFF, key-rotation and config-reload events from Router | -| Metrics registry | both | Single Prometheus endpoint at `/metrics`; metrics prefixed `bff_*` or `router_*` | -| Logger | both | Single structured-JSON logger with `correlation_id` | -| HTTP server | both | Single `axum` router; `/auth/*` to BFF, everything else to Router | -| Config | both | Helm values surface both modules' knobs in one place | - -## 4. Deployment Topology - -- [ ] `p3` - **ID**: `cpt-insightspec-topology-gw` - -- One Helm chart, one Deployment, ≥2 replicas. -- Single ClusterIP Service; cluster Ingress is the only TLS terminator. -- Mounts: `K8s ConfigMap routes.yaml` (Router), `K8s Secret bff-signing-keys` (Router). -- Connects to Redis (cluster Service) and the K8s API for ConfigMap/Secret watch. -- Liveness probe: process up. Readiness probe: Redis reachable + signing keys loaded + non-empty route table + Identity Service reachable. - -```mermaid -graph TB - HELM[Helm chart
api-gateway] --> DEP[Deployment
≥2 replicas] - DEP --> POD[Pod
insight-api-gateway] - POD --> BIN[Binary: insight-gateway
BFF + Router] - POD -. mount .-> CFG[ConfigMap routes.yaml] - POD -. mount .-> KEYS[Secret bff-signing-keys] - POD --> SVC[Service
ClusterIP] - SVC --> ING[Ingress
HTTPS] -``` - -## 5. Cross-Cutting Concerns - -### 5.1 Configuration Surface - -Helm values that affect both modules: - -| Value | Default | Description | -|---|---|---| -| `gateway.replicas` | 2 | Pod count | -| `gateway.image` | (chart) | Container image | -| `gateway.session_ttl_seconds` | 120 | Session cookie TTL (BFF) | -| `gateway.session_refresh_safety_margin_seconds` | 30 | Base offset for `refresh_at = expires_at − safety_margin` (BFF) | -| `gateway.refresh_jitter_seconds` | 10 | Total jitter window applied to `refresh_at`; uniform random ∈ ±half this value (BFF) | -| `gateway.refresh_grace_ms` | 250 | TTL of `bff:swap:{old_sid}`; window in which a just-rotated cookie can still resolve on `/auth/refresh` (BFF) | -| `gateway.session_absolute_lifetime_seconds` | 28800 | Hard cap (BFF) | -| `gateway.jwt_ttl_seconds` | 120 | Gateway JWT TTL (Router); must be ≤300 | -| `gateway.websocket_max_lifetime_seconds` | 3600 | Global hard cap on WebSocket connection lifetime (Router). Per-route override available in `routes.yaml`. Bounds post-revoke staleness, see [Router DD-ROUTER-07](./router/DESIGN.md#dd-router-07-websocket-jwt-frozen-at-upgrade-time-bounded-by-max-lifetime). | -| `gateway.auth_rate_per_ip` | 10 | `/auth/*` token-bucket rate per source IP, requests/min (BFF) | -| `gateway.auth_burst_per_ip` | 20 | `/auth/*` token-bucket burst per source IP (BFF) | -| `gateway.auth_login_state_max` | 1000 | Per-pod cap on concurrent `bff:login_state:*` entries (BFF); excess `/auth/login` returns 429 | -| `gateway.logout_jti_clock_skew_seconds` | 60 | Tolerance for `iat` skew on OIDC `logout_token` -- inflates the replay-guard TTL by this amount (BFF) | -| `gateway.csrf_origins` | [] | Allowlist of acceptable `Origin` values for `/auth/*` mutations | -| `gateway.routes_configmap` | `gateway-routes` | ConfigMap with the route table | -| `gateway.signing_keys_secret` | `bff-signing-keys` | Secret with signing keys | -| `gateway.oidc.issuer_url` | (required) | Customer OIDC issuer | -| `gateway.oidc.client_id` | (required) | OIDC client ID | -| `gateway.oidc.client_secret` | (required) | OIDC client secret | - -### 5.2 Observability - -Metrics, logs, and audit events are described in each module's DESIGN. They share: - -- One Prometheus endpoint, metric names prefixed `bff_*` or `router_*`. -- One structured-JSON log stream with `correlation_id` propagated across modules and into downstream calls. -- One audit topic in Redpanda, consumed by Audit Service. - -### 5.3 Failure Handling - -- Redis unreachable → 503 from `/api/*`, 401 from `/auth/*` mutations, readiness fails. No local cache, no degraded mode -- see [BFF DD-BFF-06](./bff/DESIGN.md#dd-bff-06-redis-outage--no-auth-fail-closed). -- Signing-key Secret missing → readiness fails, no requests served. -- Route ConfigMap invalid at startup → readiness fails. Invalid at runtime → keep current table, alert. -- IdP unreachable during login → 502 with retry-after. Existing sessions continue to work without contacting the IdP, since v1 does not refresh IdP tokens (see [BFF DESIGN §3.6 Session Refresh](./bff/DESIGN.md#36-interactions--sequences)). - -## 6. Traceability - -- **Umbrella PRD**: [PRD.md](./PRD.md) -- **Module PRDs**: [BFF PRD](./bff/PRD.md), [Router PRD](./router/PRD.md) -- **Module DESIGNs**: [BFF DESIGN](./bff/DESIGN.md), [Router DESIGN](./router/DESIGN.md) -- **Parent**: [Backend PRD](../specs/PRD.md), [Backend DESIGN](../specs/DESIGN.md) diff --git a/docs/components/backend/api-gateway/PRD.md b/docs/components/backend/api-gateway/PRD.md deleted file mode 100644 index 1ad9b7728..000000000 --- a/docs/components/backend/api-gateway/PRD.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -status: proposed -date: 2026-04-28 ---- - -# PRD -- API Gateway - - - -- [1. Overview](#1-overview) -- [2. Composition](#2-composition) -- [3. Scope](#3-scope) - - [3.1 In Scope](#31-in-scope) - - [3.2 Out of Scope](#32-out-of-scope) -- [4. Shared Non-Functional Requirements](#4-shared-non-functional-requirements) -- [5. Public Surface](#5-public-surface) -- [6. Dependencies](#6-dependencies) -- [7. Related Documents](#7-related-documents) - - - -## 1. Overview - -The API Gateway is the single entry point between the Insight SPA and the rest of the backend. One Kubernetes pod, one TLS endpoint, one binary -- but two modules with separate concerns: - -- **BFF** -- runs OIDC, owns the user session, exposes `/auth/*`. -- **Router** -- validates the cookie, mints the gateway JWT, routes `/api/*` to internal services, publishes JWKS. - -This umbrella PRD describes only what is shared across the two modules. Each module has its own PRD and DESIGN with detailed requirements. - -## 2. Composition - -| Module | Path | Owns | -|---|---|---| -| BFF | [bff/](./bff/) | OIDC handshake, session lifecycle, session cookie, `/auth/*` API, CSRF, audit | -| Router | [router/](./router/) | Cookie validation (read-only), gateway JWT mint + cache, JWKS publication, route table, reverse proxy `/api/*`, header rewriting, hot config reload | - -```mermaid -graph LR - SPA[React SPA] -->|cookie| GW - subgraph GW[API Gateway pod] - BFF[BFF
/auth/*] - ROUTER[Router
/api/* + JWKS] - end - BFF <--> OIDC[OIDC Provider] - GW --> RD[(Redis)] - GW -->|Bearer JWT| DS[Internal Services] - DS -.JWKS.-> GW -``` - -## 3. Scope - -### 3.1 In Scope - -Everything in [BFF PRD §4.1](./bff/PRD.md#41-in-scope) and [Router PRD §4.1](./router/PRD.md#41-in-scope), plus shared concerns covered here in §4. - -### 3.2 Out of Scope - -- A separate public API gateway for M2M / partner integrations -- future v2; would live next to this module, not inside it. -- Service mesh / sidecar deployment -- the gateway is the only auth-aware entry point. -- Authorization decisions (roles, scopes, license) -- each downstream service enforces its own policies. The gateway only carries identity. - -## 4. Shared Non-Functional Requirements - -The following NFRs apply to the whole gateway and are not duplicated in the module PRDs. - -#### Single Pod, Single Binary - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-gw-single-binary` - -The BFF and Router **MUST** be packaged in one Rust binary and one Kubernetes Deployment. They **MUST** share the same TLS endpoint, the same Redis client, and the same metrics/logging/audit pipeline. - -**Threshold**: One Helm chart, one image, one replica set. - -**Rationale**: Avoids a network hop between auth and routing; lets the Router link to the BFF's session manager as a library; keeps operations simple. - -#### HTTPS Enforcement and HSTS - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-gw-https-only` - -Every response from the gateway **MUST** be served over HTTPS at the ingress with `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`. Plain HTTP **MUST** be rejected at the ingress. - -**Threshold**: Zero responses served over HTTP. - -#### Stateless Horizontal Scaling - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-gw-stateless` - -The gateway **MUST** hold no per-user state in process memory. All session and JWT-cache state lives in Redis. Adding or removing a pod **MUST NOT** affect any active session. - -**Threshold**: Killing any one pod does not log any user out. - -#### End-to-End Latency Budget - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-gw-latency` - -Combined gateway overhead (BFF cookie validation + Router JWT mint-or-cache + proxy hop) **MUST** be ≤ 15 ms p95 under nominal load. - -**Threshold**: 15 ms p95 added latency. - -#### Fail Closed - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-gw-fail-closed` - -If Redis is unreachable, signing keys are missing, or the route table is empty, the gateway **MUST** return 503 and report not-ready to Kubernetes. It **MUST NOT** serve `/api/*` with stale state, no JWT, or guessed routes. - -**Threshold**: Zero requests forwarded without a valid session and a valid signed JWT. - -## 5. Public Surface - -| Path | Owner | Purpose | -|---|---|---| -| `/auth/*` | BFF | Login, refresh, logout, session management, CSRF, OIDC back-channel | -| `/.well-known/jwks.json` | Router | Public keys for downstream JWT verification | -| `/api/**` | Router | Reverse-proxied to internal services with gateway JWT | -| `/healthz`, `/ready` | shared | Kubernetes probes | -| `/metrics` | shared | Prometheus scrape | - -Detailed contracts: - -- [BFF auth API contract](./bff/PRD.md#71-public-api-surface) -- [Router proxy and JWKS contracts](./router/PRD.md#71-public-api-surface) -- [Gateway JWT claim contract](./bff/DESIGN.md#38-gateway-jwt-claim-contract) -- defined by BFF, minted by Router, consumed by every downstream service. - -## 6. Dependencies - -| Dependency | Purpose | Used by | -|---|---|---| -| Redis | Session store, JWT cache | BFF + Router | -| Customer OIDC provider | Authentication, refresh, logout | BFF | -| Identity Service | Map IdP `sub` to internal `user_id` and `tenant_id` | BFF | -| Audit Service | Sink for auth events | BFF | -| Ingress / TLS terminator | HTTPS termination, HSTS, host routing | shared | -| K8s ConfigMap / Secret | Route table and signing keys | Router | -| Downstream services | Targets of `/api/*` forwarding; verify JWT via JWKS | Router | - -## 7. Related Documents - -- **Module PRDs**: [BFF](./bff/PRD.md), [Router](./router/PRD.md) -- **Module DESIGNs**: [BFF](./bff/DESIGN.md), [Router](./router/DESIGN.md) -- **Umbrella DESIGN**: [DESIGN.md](./DESIGN.md) -- **Parent**: [Backend PRD](../specs/PRD.md), [Backend DESIGN](../specs/DESIGN.md) diff --git a/docs/components/backend/api-gateway/bff/DESIGN.md b/docs/components/backend/api-gateway/bff/DESIGN.md deleted file mode 100644 index d823ae884..000000000 --- a/docs/components/backend/api-gateway/bff/DESIGN.md +++ /dev/null @@ -1,959 +0,0 @@ ---- -status: proposed -date: 2026-04-28 ---- - -# DESIGN -- BFF (Backend-for-Frontend) Service - -- [ ] `p3` - **ID**: `cpt-insightspec-design-bff` - - - -- [1. Architecture Overview](#1-architecture-overview) - - [1.1 Architectural Vision](#11-architectural-vision) - - [1.2 Architecture Drivers](#12-architecture-drivers) - - [1.3 Architecture Layers](#13-architecture-layers) -- [2. Principles & Constraints](#2-principles--constraints) - - [2.1 Design Principles](#21-design-principles) - - [2.2 Constraints](#22-constraints) -- [3. Technical Architecture](#3-technical-architecture) - - [3.1 Domain Model](#31-domain-model) - - [3.2 Component Model](#32-component-model) - - [3.3 API Contracts](#33-api-contracts) - - [3.4 Internal Dependencies](#34-internal-dependencies) - - [3.5 External Dependencies](#35-external-dependencies) - - [3.6 Interactions & Sequences](#36-interactions--sequences) - - [3.7 Database schemas & tables](#37-database-schemas--tables) - - [3.8 Gateway JWT Claim Contract](#38-gateway-jwt-claim-contract) - - [3.9 Boundary with the Router](#39-boundary-with-the-router) -- [4. Cross-Cutting Concerns](#4-cross-cutting-concerns) - - [4.1 Cookie Hardening](#41-cookie-hardening) - - [4.2 CSRF Defense](#42-csrf-defense) - - [4.3 Janitor for Expired Sessions](#43-janitor-for-expired-sessions) - - [4.4 Rate Limiting on `/auth/*`](#44-rate-limiting-on-auth) - - [4.5 Observability](#45-observability) -- [5. Design Decisions](#5-design-decisions) - - [DD-BFF-01: Opaque Session vs JWT Cookie](#dd-bff-01-opaque-session-vs-jwt-cookie) - - [DD-BFF-02: Explicit Session Refresh, No Sliding TTL](#dd-bff-02-explicit-session-refresh-no-sliding-ttl) - - [DD-BFF-03: ZSET (Not SET) for User-Session Index](#dd-bff-03-zset-not-set-for-user-session-index) - - [DD-BFF-04: BFF-Prefixed Redis Keys](#dd-bff-04-bff-prefixed-redis-keys) - - [DD-BFF-05: EdDSA Algorithm for Gateway JWT Contract](#dd-bff-05-eddsa-algorithm-for-gateway-jwt-contract) - - [DD-BFF-06: Redis Outage = No Auth (Fail Closed)](#dd-bff-06-redis-outage--no-auth-fail-closed) - - [DD-BFF-07: `/auth/refresh` Returns Next-Refresh Deadline](#dd-bff-07-authrefresh-returns-next-refresh-deadline) - - [DD-BFF-08: `SameSite=Strict` Today, Pluggable Cookie Classes Later](#dd-bff-08-samesitestrict-today-pluggable-cookie-classes-later) - - [DD-BFF-09: Janitor Coordinates via Redis Lock](#dd-bff-09-janitor-coordinates-via-redis-lock) - - [DD-BFF-10: Rolling Cookies on `/auth/refresh` with Grace + Jitter](#dd-bff-10-rolling-cookies-on-authrefresh-with-grace--jitter) -- [6. Traceability](#6-traceability) - - - ---- - -## 1. Architecture Overview - -### 1.1 Architectural Vision - -The BFF is the auth half of the API Gateway. It owns the OIDC handshake, the session lifecycle, and the `/auth/*` API the SPA talks to. It does **not** mint gateway JWTs and does **not** proxy `/api/*` -- both belong to the sibling [Router](../router/DESIGN.md). - -The browser-facing contract is small and explicit: one opaque session cookie with a short hard TTL, extended only by `POST /auth/refresh`. No sliding TTL, no implicit extension on activity. The SPA decides when the user is "active" by calling refresh on a cadence below the TTL. - -All session state lives in Redis under the `bff:` key prefix. The BFF process is stateless beyond its config and JWT signing keys are not its concern. - -The BFF is built on **cyberfabric-core ModKit** (same framework as the rest of the backend) and runs in the same pod as the Router. - -### 1.2 Architecture Drivers - -#### Functional Drivers - -| Requirement | Design Response | -|---|---| -| `cpt-insightspec-fr-bff-oidc-login` | Confidential OIDC client with PKCE; tokens stored in Redis only | -| `cpt-insightspec-fr-bff-session-cookie` | `__Host-`-prefixed opaque session ID with short configurable TTL, set on `/auth/callback` | -| `cpt-insightspec-fr-bff-session-refresh` | `POST /auth/refresh` runs `HMGET` + `MULTI`/`EXEC` pipeline that extends `bff:session:{id}` TTL and updates `ZADD` score | -| `cpt-insightspec-fr-bff-session-store` | `bff:session:{id}` HASH + `bff:user_sessions:{user_id}` ZSET with score = `expires_at` | -| `cpt-insightspec-fr-bff-session-list` | `ZRANGEBYSCORE bff:user_sessions:{uid} +inf` | -| `cpt-insightspec-fr-bff-session-revoke` | `HMGET` + `MULTI`/`EXEC` pipeline removes session record(s), `ZREM` from index, `SREM` from `sid_index`, and `DEL router:jwt_cache:{sid}` in one round-trip | -| `cpt-insightspec-fr-bff-gateway-jwt` | Claim contract owned here; minting performed by Router | -| `cpt-insightspec-fr-bff-logout` | `/auth/logout` for local + RP-initiated; `/auth/oidc/back-channel-logout` for IdP-initiated | -| `cpt-insightspec-fr-bff-csrf` | Double-submit token bound to session ID + `Origin` allowlist | - -#### NFR Allocation - -| NFR | Component | Verification | -|---|---|---| -| `cpt-insightspec-nfr-bff-https-only` | Ingress + middleware reject plain HTTP; HSTS header set globally | Curl over HTTP returns 308/400; `Strict-Transport-Security` present on every response | -| `cpt-insightspec-nfr-bff-session-lookup-p95` | Redis pipelined HMGET; no extra DB call on hot path | Load test, measure p95 | -| `cpt-insightspec-nfr-bff-session-ttl` | `session_ttl` and `absolute_lifetime` from Helm values; cookie `Max-Age` and Redis TTL match | Integration test sets TTLs; verify cookie + Redis expire together | -| `cpt-insightspec-nfr-bff-jwt-algorithm` | EdDSA-only contract on the JWT minted by the Router | Reject any non-EdDSA token in downstream verification tests | -| `cpt-insightspec-nfr-bff-cookie-attrs` | Single `set_session_cookie` helper; fail-closed if attribute set incomplete | Snapshot test on `Set-Cookie` headers | -| `cpt-insightspec-nfr-bff-audit` | Auth events published to Redpanda topic consumed by Audit Service | Integration test verifies event emission per auth action | - -#### Architecture Decision Records - -ADRs to be authored alongside implementation; decisions captured inline in §5 (Design Decisions) until then: - -- Opaque server-side session, not JWT, for the browser-facing cookie -- see DD-BFF-01. -- Session TTL extended only by explicit `POST /auth/refresh` -- see DD-BFF-02. -- ZSET (not SET) for `bff:user_sessions:*` -- see DD-BFF-03. - -### 1.3 Architecture Layers - -```mermaid -graph TB - subgraph Browser - SPA[React SPA] - end - - subgraph Ingress - ING[Ingress
TLS · HSTS] - end - - subgraph GW["API Gateway pod"] - BFF["BFF module
/auth/*"] - ROUTER["Router module
/api/* + JWKS"] - SM["Session Manager (lib)
shared, owned by BFF"] - end - - subgraph State - RD[(Redis
bff:* keys)] - end - - subgraph External["External Systems"] - OIDC[Customer OIDC Provider] - ID[Identity Service] - AU[Audit Service] - end - - SPA -->|cookie| ING - ING --> BFF - ING --> ROUTER - BFF --> SM - ROUTER --> SM - SM --> RD - BFF <-->|auth code + PKCE,
refresh, RP-logout| OIDC - OIDC -.->|back-channel logout| BFF - BFF -->|sub → user_id| ID - BFF -->|audit events| AU -``` - -| Layer | Responsibility | Technology | -|---|---|---| -| Edge | TLS termination, HSTS, host routing | K8s ingress | -| Auth | OIDC handshake, session lifecycle, CSRF, `/auth/*` API | ModKit + `openidconnect` Rust crate | -| State | Sessions, user-sessions ZSET, login state, sid index | Redis (cluster mode optional) | -| Sibling | Gateway JWT mint + JWKS + `/api/*` proxy | [Router](../router/DESIGN.md) | - -- [ ] `p3` - **ID**: `cpt-insightspec-tech-bff` - -## 2. Principles & Constraints - -### 2.1 Design Principles - -#### Opaque to the browser - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-bff-opaque-cookie` - -The browser only ever sees an opaque session ID. No JWTs, no IdP tokens, no claims. A stolen cookie buys access only on this host until the next TTL boundary. - -#### Explicit refresh, no sliding TTL - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-bff-explicit-refresh` - -The session TTL is short and hard. Extension only happens via `POST /auth/refresh`, called by the SPA on a fixed cadence. Regular API traffic never extends the session. This keeps the model simple, predictable, and easy to reason about. - -#### Stateless service, stateful store - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-bff-stateless` - -The BFF process holds no session state. Any pod can serve any request. All session state goes through Redis. - -#### Fail closed on auth - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-bff-fail-closed` - -If Redis is unreachable, the cookie is malformed, the session is past its absolute cap, or the IdP rejects a refresh -- return 401 and clear the cookie. Never serve a request with a guess. - -### 2.2 Constraints - -#### First-party cookie domain - -- [ ] `p2` - **ID**: `cpt-insightspec-constraint-bff-same-domain` - -The SPA and the gateway must be served from the same registrable domain. `__Host-` forbids `Domain=` and pins the cookie to one host. - -#### OIDC provider feature set - -- [ ] `p2` - **ID**: `cpt-insightspec-constraint-bff-oidc-features` - -Customer IdP must support: authorization code with PKCE, refresh tokens, RP-initiated logout, and OIDC back-channel logout. - -## 3. Technical Architecture - -### 3.1 Domain Model - -| Entity | Purpose | Storage | -|---|---|---| -| `Session` | Active browser session for one user on one device | Redis HASH `bff:session:{id}` | -| `UserSessionIndex` | All session IDs for one user, scored by expiry | Redis ZSET `bff:user_sessions:{user_id}` | -| `SidIndex` | Map (OIDC issuer, OIDC sid) → local session IDs | Redis SET `bff:sid_index:{iss}:{idp_sid}` | -| `LoginState` | Per-login transient state (PKCE verifier, nonce) | Redis HASH `bff:login_state:{state}`, TTL 5 min | - -Relationships: -- `User` (owned by Identity Service) → 0..N `Session` -- `Session` → 1 `User` -- `Session` → 0..1 entry in `SidIndex` (only if IdP supplies `sid`) - -### 3.2 Component Model - -```mermaid -graph LR - subgraph BFF - ROUTER[HTTP Router
axum] - AUTHCTL[Auth Controller
/auth/*] - SESSMGR[Session Manager
shared lib] - OIDCCLT[OIDC Client] - CSRF[CSRF Verifier] - AUDIT[Audit Emitter] - JANITOR[Expired-Session Janitor] - end - - subgraph Ext - REDIS[(Redis)] - IDP[OIDC Provider] - IDSVC[Identity Service] - REDPANDA[Redpanda] - end - - ROUTER --> AUTHCTL - AUTHCTL --> OIDCCLT - AUTHCTL --> SESSMGR - AUTHCTL --> CSRF - AUTHCTL --> AUDIT - AUTHCTL --> IDSVC - SESSMGR --> REDIS - OIDCCLT --> IDP - AUDIT --> REDPANDA - JANITOR --> REDIS -``` - -#### Auth Controller - -- [ ] `p2` - **ID**: `cpt-insightspec-component-bff-auth-controller` - -##### Why this component exists -The single owner of every endpoint under `/auth/*` -- the only place where session state changes start. Without it, session creation, refresh, and revocation would be scattered across the codebase. - -##### Responsibility scope -Login start, OIDC callback, session refresh, logout, session list / revoke (single + all), back-channel logout receiver, CSRF token issuance, `/auth/me`. - -##### Responsibility boundaries -Does not authorize business operations -- downstream services do that. Does not own user data -- Identity Service does. Does not mint gateway JWTs or proxy `/api/*` -- the Router does. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-session-manager` -- creates / reads / refreshes / revokes sessions. -- `cpt-insightspec-component-bff-oidc-client` -- runs the OIDC handshake. -- `cpt-insightspec-component-bff-csrf-verifier` -- issues and checks CSRF tokens. -- `cpt-insightspec-component-bff-audit-emitter` -- publishes auth events. - -#### Session Manager (shared library) - -- [ ] `p2` - **ID**: `cpt-insightspec-component-bff-session-manager` - -##### Why this component exists -The single entry point for every read or write of session state. Centralising Redis access here keeps atomicity guarantees and metrics uniform, and lets the Router link to the same code as a library for read-only session validation. - -##### Responsibility scope -Create, read, refresh, list-by-user, revoke (single, all-but-current, all). Keeps `bff:session:*` and `bff:user_sessions:*` consistent using the cheapest Redis primitive that meets the atomicity requirement of each op: - -- **Create** — single `MULTI`/`EXEC` pipeline (`HSET` + `EXPIREAT` + `ZADD` + `SADD`). No conditional logic, no read-then-write, so no Lua. See §3.6 Login. -- **Refresh** — `HMGET` + `MULTI`/`EXEC` pipeline that rotates the `session_id`: writes the grace key `bff:swap:{old_sid} → new_sid` (PX = `grace_ms`), `RENAME`s the session HASH, updates `expires_at` + key TTL + ZSET score + IdP-sid SET, `DEL`s the old `router:jwt_cache:*`. Stale cookie within grace resolves via `bff:swap:*` without re-rotating. Past grace → 401. See §3.6 Session Refresh. -- **Revoke (single)** — `HMGET` to read `user_id`, `idp_iss`, `idp_sid`, then a `MULTI`/`EXEC` pipeline of four deletes (`DEL bff:session` + `ZREM bff:user_sessions` + `SREM bff:sid_index` + `DEL router:jwt_cache`). Idempotent: revoking an already-revoked session is silently a no-op. See §3.6 Logout / Back-Channel. -- **Revoke (user)** — `ZRANGE bff:user_sessions:{user_id} 0 -1` to enumerate, pipelined `HMGET` on each session for `(idp_iss, idp_sid)`, then one `MULTI`/`EXEC` pipeline that `DEL`s every `bff:session:{sid}` and `router:jwt_cache:{sid}`, `SREM`s each entry from `bff:sid_index:*`, and finally `DEL`s the whole `bff:user_sessions:{user_id}` index. A parallel login during the op survives the revoke — accepted as standard "log out everywhere" semantics. See §3.6 Logout Everywhere. - -##### Responsibility boundaries -Does not call the OIDC provider. Does not authenticate requests by itself -- callers (Auth Controller, Router) do. Does not own the cookie format. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-auth-controller` -- primary writer. -- `cpt-insightspec-component-router-auth` (Router-side) -- read-only consumer. - -#### OIDC Client - -- [ ] `p2` - **ID**: `cpt-insightspec-component-bff-oidc-client` - -##### Why this component exists -Encapsulates the OIDC protocol so the rest of the BFF treats it as a small set of operations: authorize, exchange, refresh, end-session, validate logout token. - -##### Responsibility scope -Authorization code + PKCE flow, ID token validation (`iss`, `aud`, `nonce`, `exp`, signature), RP-initiated logout, back-channel logout token validation per OIDC spec. v1 does not call the IdP refresh-token endpoint -- access tokens are not stored or used inside the gateway. - -##### Responsibility boundaries -Does not store sessions. Does not maintain user-side state. Holds no IdP tokens beyond the lifetime of one operation. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-auth-controller` -- only caller. -- `cpt-insightspec-component-bff-session-manager` -- recipient of the tokens that result from each OIDC operation. - -#### CSRF Verifier - -- [ ] `p2` - **ID**: `cpt-insightspec-component-bff-csrf-verifier` - -##### Why this component exists -Provides defence-in-depth on top of `SameSite=Strict` for state-changing `/auth/*` methods. - -##### Responsibility scope -Issue per-session CSRF tokens at login (and rotate on privilege change), constant-time compare them on POST/PUT/PATCH/DELETE, fall back to verifying `Origin` against the allowlist when no token is present. - -##### Responsibility boundaries -Does not protect `/api/*` -- those rely on `SameSite=Strict` and the ingress. Does not store the token outside the session record. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-auth-controller` -- consumer. -- `cpt-insightspec-component-bff-session-manager` -- holds `csrf_token` inside `bff:session:*`. - -#### Audit Emitter - -- [ ] `p2` - **ID**: `cpt-insightspec-component-bff-audit-emitter` - -##### Why this component exists -Centralises audit event publication so every auth-relevant action lands on the Redpanda topic with the same envelope and correlation fields. - -##### Responsibility scope -Publish auth events (login OK / fail, refresh OK / fail, logout, revoke single / all / admin, back-channel logout) to the audit topic consumed by Audit Service. - -##### Responsibility boundaries -Does not run audit policy or retention -- Audit Service does. Does not log to disk -- the standard logger does. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-auth-controller` -- only caller. - -#### Expired-Session Janitor - -- [ ] `p2` - **ID**: `cpt-insightspec-component-bff-janitor` - -##### Why this component exists -Per-session Redis TTL removes the record itself, but the user-index ZSET still lists the expired `session_id` until it is explicitly removed. Without the janitor, the index grows unbounded for users who never log out. - -##### Responsibility scope -Periodic pass that elects one pod via a Redis lock, scans `bff:user_sessions:*`, and trims expired members with `ZREMRANGEBYSCORE`. Emits backlog and removed-count metrics. - -##### Responsibility boundaries -Does not delete session records (they expire on their own). Does not run on every BFF pod simultaneously -- one elected pod per pass. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-session-manager` -- shares the same key conventions; janitor reads only. - -### 3.3 API Contracts - -- [ ] `p2` - **ID**: `cpt-insightspec-design-bff-auth-api-spec` - -This section specifies the implementation of the auth API declared in [PRD §7.1](./PRD.md#71-public-api-surface) (`cpt-insightspec-interface-bff-auth-api`). - -- **Contracts**: `cpt-insightspec-contract-bff-gateway-jwt`, `cpt-insightspec-contract-bff-oidc`, `cpt-insightspec-contract-bff-jwks-url` -- **Technology**: REST / OpenAPI 3.1 -- **Location**: [openapi.yaml](./openapi.yaml) -- to be authored alongside implementation - -| Method | Path | Auth | Description | Stability | -|---|---|---|---|---| -| GET | `/auth/login` | none | Start OIDC flow; 302 to IdP | stable | -| GET | `/auth/callback` | none | OIDC redirect target; sets session cookie | stable | -| POST | `/auth/refresh` | session | Extend session TTL, re-issue cookie | stable | -| POST | `/auth/logout` | session | Revoke current session, clear cookie, return RP-logout URL | stable | -| GET | `/auth/me` | session | Return current user and tenant | stable | -| GET | `/auth/sessions` | session | List active sessions for current user | stable | -| DELETE | `/auth/sessions/{id}` | session | Revoke a specific session | stable | -| DELETE | `/auth/sessions` | session | Revoke all sessions of current user | stable | -| POST | `/auth/oidc/back-channel-logout` | OIDC `logout_token` | IdP-initiated logout receiver | stable | -| GET | `/auth/csrf` | session | Issue CSRF token for the session | stable | - -JWKS and `/api/*` reverse proxy live in the [Router](../router/DESIGN.md), not here. - -### 3.4 Internal Dependencies - -| Dependency | Interface | Purpose | -|---|---|---| -| Identity Service | REST (SDK client) | Resolve IdP `sub` → internal `user_id` and `tenant_id` | -| Audit Service | Redpanda producer | Publish auth events | -| Router (sibling) | Shared Redis (direct DEL of `router:jwt_cache:*`) | Invalidate cached gateway JWTs on session revoke. No RPC, no Redpanda -- both modules share the same Redis client. | - -### 3.5 External Dependencies - -| System | Protocol | Purpose | -|---|---|---| -| Customer OIDC provider | OIDC 1.0 (HTTPS) | Login, refresh, RP-initiated logout, back-channel logout | -| Redis | RESP (TCP/TLS) | Session store + user-sessions index | - -### 3.6 Interactions & Sequences - -#### Login (OIDC Authorization Code + PKCE) - -**ID**: `cpt-insightspec-seq-bff-login` - -**Use cases**: `cpt-insightspec-usecase-bff-login` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant B as BFF - participant R as Redis - participant I as OIDC Provider - participant ID as Identity Service - - U->>B: GET /auth/login - B->>B: generate state, nonce, PKCE verifier - B->>R: SETEX bff:login_state:{state} 5min - B-->>U: 302 to IdP authorize URL - U->>I: authorize(code_challenge, state) - I-->>U: 302 /auth/callback?code&state - U->>B: GET /auth/callback (any incoming __Host-sid IGNORED) - B->>R: GET bff:login_state:{state} - R-->>B: {verifier, nonce} - B->>I: token exchange (code, verifier) - I-->>B: id_token (+ access/refresh tokens, not stored in v1) - B->>B: validate id_token - B->>ID: resolve(sub) → user_id, tenant_id - ID-->>B: user_id, tenant_id - B->>B: generate fresh session_id (CSPRNG, ≥128 bits) - opt incoming cookie was a live session - B->>R: HMGET bff:session:{incoming_sid} user_id idp_iss idp_sid
MULTI
DEL bff:session:{incoming_sid}
ZREM bff:user_sessions:{user_id} {incoming_sid}
SREM bff:sid_index:{idp_iss}:{idp_sid} {incoming_sid}
DEL router:jwt_cache:{incoming_sid}
EXEC - Note over B,R: Incoming SID is NEVER reused or extended.
Revoked here to invalidate any planted/stale cookie. - end - B->>R: MULTI
HSET bff:session:{sid}
EXPIREAT bff:session:{sid} expires_at
ZADD bff:user_sessions:{uid} expires_at sid
SADD bff:sid_index:{iss}:{idp_sid} sid
EXEC - R-->>B: OK - B-->>U: 302 to SPA + Set-Cookie __Host-sid (new sid)
(HttpOnly,Secure,Strict, Max-Age=session_ttl) -``` - -**Session-fixation guard.** Any `__Host-sid` value present on the incoming `/auth/callback` request is treated as untrusted and **never** carried into the new session. The new `session_id` is generated server-side from a CSPRNG and bears no relation to any browser-supplied value. If the incoming cookie happens to map to a live session in Redis, that session is revoked first so an attacker who planted a known SID cannot recover it after the victim logs in. `__Host-` prefix prevents subdomain-injection on the same host, but cannot prevent a wildcard parent-domain ingress from setting cookies; the explicit revoke + regenerate covers that case. - -#### Session Refresh - -**ID**: `cpt-insightspec-seq-bff-refresh` - -**Use cases**: `cpt-insightspec-usecase-bff-refresh` - -The cookie value **is** the `session_id` and rotates on every successful refresh. Two paths: normal rotation, and a small grace window for stale cookies arriving on `/auth/refresh` itself. - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant B as BFF - participant R as Redis - - U->>B: POST /auth/refresh (cookie = old_sid) - B->>R: HMGET bff:session:{old_sid} user_id idp_iss idp_sid absolute_expires_at - alt key exists (normal path) - R-->>B: fields - B->>B: new_sid = csprng()
new_exp = min(now + session_ttl, absolute_expires_at)
refresh_at = (new_exp - safety_margin) + uniform(±jitter/2) - B->>R: MULTI
SET bff:swap:{old_sid} new_sid PX grace_ms NX
RENAME bff:session:{old_sid} → bff:session:{new_sid}
HSET bff:session:{new_sid} expires_at new_exp
EXPIREAT bff:session:{new_sid} new_exp
ZREM bff:user_sessions:{uid} old_sid
ZADD bff:user_sessions:{uid} new_exp new_sid
SREM bff:sid_index:{iss}:{idp_sid} old_sid
SADD bff:sid_index:{iss}:{idp_sid} new_sid
DEL router:jwt_cache:{old_sid}
EXEC - R-->>B: OK - B-->>U: 200 {expires_at: new_exp, refresh_at}
Set-Cookie __Host-sid = new_sid, Max-Age = new_exp - now - else key missing — try grace - B->>R: GET bff:swap:{old_sid} - alt swap exists → recently rotated - R-->>B: new_sid - B->>R: HMGET bff:session:{new_sid} expires_at - R-->>B: expires_at - B->>B: refresh_at = (expires_at - safety_margin) + uniform(±jitter/2) - B-->>U: 200 {expires_at, refresh_at}
Set-Cookie __Host-sid = new_sid, Max-Age = expires_at - now
Note: NO additional rotation - else swap missing — past grace window - R-->>B: nil - B-->>U: 401 + Set-Cookie __Host-sid Max-Age=0 - end - end -``` - -**Cookie rotation.** Each successful refresh issues a fresh `session_id`. Old session record is gone (RENAME consumed it); old `router:jwt_cache:{old_sid}` is deleted to prevent the Router from serving a JWT bound to the rotated SID. The user-sessions ZSET and the IdP-sid SET are updated in the same pipeline so listing/back-channel paths stay consistent. - -**Grace window.** `bff:swap:{old_sid} → new_sid` lives for `grace_ms` (default `250 ms`, Helm value `gateway.refresh_grace_ms`). It exists to absorb benign races: a sibling tab firing `/auth/refresh` between the leader's call and the leader's broadcast, a network retry of the same request, a page reload mid-refresh. In the grace path we resolve to the current `new_sid` and set the cookie -- but we do **not** rotate again, otherwise rapid races would churn tokens. - -**Jitter.** `refresh_at` is `(expires_at - safety_margin) + uniform(-jitter/2, +jitter/2)`. `safety_margin` defaults to 30 s (`gateway.session_refresh_safety_margin_seconds`). `jitter` defaults to 10 s (`gateway.refresh_jitter_seconds`), so the SPA's next refresh moment is randomised within a 10-second window. Defends against an attacker who sees the SPA's predictable schedule and tries to align a forged `/auth/refresh` to the same `grace_ms` window. - -**Multi-tab coordination (SPA-side contract).** With multiple tabs of the same app, the SPA **MUST** elect a single leader to fire `/auth/refresh` per window. Recommended: `BroadcastChannel` for modern browsers, `localStorage` events as a fallback. The leader broadcasts `{expires_at, refresh_at}` after a successful refresh; followers update their timers without firing their own refresh. Without this, every tab racing `/auth/refresh` would burn the grace window on its own siblings. - -**No IdP call.** The BFF stores `id_token` at login (used by `/auth/logout` for `id_token_hint`) but does not store IdP access/refresh tokens. v1 never calls IdP-protected APIs on the user's behalf, so there is nothing to refresh. If a v2 feature needs fresh IdP tokens, it will be added as a separate concern (in-process event with Redis-backed dedup), not coupled into `/auth/refresh`. - -**`/api/*` is not affected.** The Router does **not** consult `bff:swap:*` -- a stale cookie on `/api/*` is just a 401. The SPA must call `/auth/refresh` first, get the fresh cookie, and retry. Multi-tab coordination + grace on `/auth/refresh` keep this rare in practice. - -#### Logout -- Local + RP-Initiated - -**ID**: `cpt-insightspec-seq-bff-logout` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant B as BFF - participant R as Redis - participant I as OIDC Provider - - U->>B: POST /auth/logout - B->>R: HMGET bff:session:{sid} user_id idp_iss idp_sid
MULTI
DEL bff:session:{sid}
ZREM bff:user_sessions:{uid} sid
SREM bff:sid_index:{iss}:{idp_sid} sid
DEL router:jwt_cache:{sid}
EXEC - R-->>B: OK - B-->>U: 200 + Set-Cookie __Host-sid Max-Age=0 + {rp_logout_url} - U->>I: GET end_session_endpoint?id_token_hint=... - I-->>U: 302 back to SPA -``` - -#### Logout Everywhere - -**ID**: `cpt-insightspec-seq-bff-logout-all` - -**Use cases**: `cpt-insightspec-usecase-bff-logout-everywhere` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant B as BFF - participant R as Redis - - U->>B: DELETE /auth/sessions - B->>R: ZRANGE bff:user_sessions:{uid} 0 -1 - R-->>B: [sid_1, sid_2, ..., sid_N] - B->>R: pipelined HMGET bff:session:{sid_i} idp_iss idp_sid
(for each sid_i) - R-->>B: [(iss_1, idp_sid_1), ..., (iss_N, idp_sid_N)] - B->>R: MULTI
DEL bff:session:{sid_1..N}
SREM bff:sid_index:{iss_i}:{idp_sid_i} {sid_i} (per i)
DEL router:jwt_cache:{sid_1..N}
DEL bff:user_sessions:{uid}
EXEC - R-->>B: replies - B-->>U: 204 + Set-Cookie __Host-sid Max-Age=0 - Note over B: Within ≤300s all in-flight gateway JWTs expire.
A login that races this op survives — accepted as
standard "log out everywhere" semantics. -``` - -#### Back-Channel Logout from IdP - -**ID**: `cpt-insightspec-seq-bff-back-channel-logout` - -```mermaid -sequenceDiagram - autonumber - participant I as OIDC Provider - participant B as BFF - participant R as Redis - participant ID as Identity Service - - I->>B: POST /auth/oidc/back-channel-logout (logout_token) - B->>B: validate logout_token (sig, iss, aud, iat, events, jti) - B->>R: SET bff:logout_jti:{iss}:{jti} 1 NX EX (iat + clock_skew + grace - now) - R-->>B: OK | nil - alt nil (already seen → replay) - B-->>I: 200 (idempotent) - else OK (first time) - opt logout_token has sid - B->>R: SMEMBERS bff:sid_index:{iss}:{idp_sid} - R-->>B: [sid1, sid2, ...] (target list) - end - opt logout_token has only sub (no sid) - B->>ID: resolve(iss, sub) → user_id - ID-->>B: user_id - B->>R: ZRANGE bff:user_sessions:{user_id} 0 -1 - R-->>B: [all sids for this user] (target list) - Note over B,R: Spec-compliant fallback, but blast radius is
EVERY session for that user across all browsers. - end - loop for each sid in target list - B->>R: HMGET bff:session:{sid} user_id idp_iss idp_sid
MULTI
DEL bff:session:{sid}
ZREM bff:user_sessions:{user_id} {sid}
SREM bff:sid_index:{idp_iss}:{idp_sid} {sid}
DEL router:jwt_cache:{sid}
EXEC - end - B-->>I: 200 - end -``` - -**`jti` replay protection.** Every accepted `logout_token` is recorded as `bff:logout_jti:{iss}:{jti}` with a Redis `SET ... NX` (set-if-not-exists). On collision the request is treated as a replay and short-circuits to `200` without performing any revoke -- the IdP gets the same idempotent answer it would for a successful first delivery, but no session work happens. TTL on the key is `(iat + max_clock_skew + grace) - now` (defaults: `max_clock_skew = 60s`, `grace = 60s`). After the TTL the JTI may legitimately be reused by the IdP (extremely rare in practice but cheap to allow). - -**`(iss, sub)` resolution path.** The BFF does not maintain a `(iss, sub) → user_id` reverse index in Redis. Instead, the back-channel handler calls Identity Service (the same component that does `sub → user_id` mapping at login) to resolve `(iss, sub)` to the internal `user_id`. From there, `bff:user_sessions:{user_id}` lists every active session for that user. One extra synchronous call to Identity Service per back-channel logout is acceptable -- back-channel logout is a low-rate event compared to `/auth/refresh`. - -**`(iss, sub)` fallback blast radius.** When the IdP issues a `logout_token` carrying `sub` only (no `sid`), spec-compliant behaviour is to revoke every active session for that user across every browser. We do that, but operators **MUST** be aware: a misconfigured IdP that omits `sid` will turn every back-channel logout into a "log out everywhere" event, with no way for the BFF to tell the difference. Documented in [Risks](./PRD.md#12-risks). - -### 3.7 Database schemas & tables - -- [ ] `p3` - **ID**: `cpt-insightspec-db-bff-redis` - -This module's "database" is Redis. The schema below describes the keyspace. - -All BFF-owned keys carry the `bff:` prefix. The Router owns one prefix (`router:`) for its JWT cache; cross-references between the two are explicit. - -```mermaid -graph LR - USER[user_id] - IDX["bff:user_sessions:{user_id}
ZSET — score = expires_at"] - S1["bff:session:{sid_1}
HASH"] - S2["bff:session:{sid_2}
HASH"] - SN["bff:session:{sid_n}
HASH"] - JC1["router:jwt_cache:{sid_1}
STRING (signed JWT)"] - JC2["router:jwt_cache:{sid_2}
STRING"] - SIDX["bff:sid_index:{iss}:{idp_sid}
SET of session_id"] - LS["bff:login_state:{state}
HASH (5 min TTL)"] - LJTI["bff:logout_jti:{iss}:{jti}
STRING — replay guard"] - SWAP["bff:swap:{old_sid}
STRING — refresh grace (250 ms)"] - - USER --> IDX - IDX --> S1 - IDX --> S2 - IDX --> SN - S1 -.Router caches.-> JC1 - S2 -.Router caches.-> JC2 - S1 -.idp sid lookup.-> SIDX - S2 -.idp sid lookup.-> SIDX -``` - -#### Key: `bff:session:{session_id}` - -**Type**: Redis HASH - -| Field | Type | Description | -|---|---|---| -| `user_id` | String | Internal user identifier | -| `tenant_id` | String | Tenant the user logged into | -| `idp_iss` | String | OIDC issuer URL | -| `idp_sub` | String | OIDC subject | -| `idp_sid` | String | OIDC `sid` claim (for back-channel logout) | -| `id_token` | String | OIDC `id_token` from initial exchange. Used as `id_token_hint` on RP-initiated logout. Not refreshed in v1; eventual staleness tolerated by most IdPs. | -| `created_at` | Int (epoch s) | Session creation time | -| `expires_at` | Int (epoch s) | Current session expiry; advanced by `/auth/refresh`. Mirror of the key's Redis TTL, kept here so `/auth/sessions` can list all active sessions and their expiries from one HMGET batch without an extra `EXPIRETIME` per key. | -| `absolute_expires_at` | Int (epoch s) | Hard cap; cannot be extended past this. Enforced in `/auth/refresh` by `min(now + session_ttl, absolute_expires_at)`. | -| `user_agent` | String | Captured at login | -| `ip` | String | Captured at login | -| `csrf_token` | String | CSRF token bound to this session | - -**Redis TTL**: matches `expires_at`. Re-set on every refresh. - -#### Key: `bff:user_sessions:{user_id}` - -**Type**: Redis ZSET - -**Member**: `session_id` - -**Score**: `expires_at` (epoch seconds) - -**Why ZSET, not SET**: - -- `ZRANGEBYSCORE bff:user_sessions:{uid} +inf` returns active sessions (for `/auth/sessions`). -- `ZRANGEBYSCORE bff:user_sessions:{uid} 0 ` returns expired ones (for the janitor). -- `ZREMRANGEBYSCORE bff:user_sessions:{uid} 0 ` cleans them in one call. - -**Maintenance**: Mutated together with `bff:session:*` inside a single MULTI/EXEC pipeline for every op (create, refresh, revoke single, revoke user). No Lua scripts -- see §3.6 sequence diagrams. - -#### Key: `bff:sid_index:{iss}:{idp_sid}` - -**Type**: Redis SET - -**Members**: `session_id` strings - -**Purpose**: Resolve OIDC back-channel `logout_token` (`iss` + `sid`) to local sessions. SET is sufficient -- no expiry-based queries needed; entries are removed on session revoke. - -#### Key: `bff:login_state:{state}` - -**Type**: Redis HASH - -**Fields**: `pkce_verifier`, `nonce`, `redirect_to` - -**TTL**: 5 minutes. One-shot -- deleted on callback. - -#### Key: `bff:swap:{old_session_id}` - -**Type**: Redis STRING. Value: the new `session_id` that the old one rotated into. - -**Purpose**: Grace window for `/auth/refresh` cookie rotation. When a stale cookie (just-rotated) arrives within `grace_ms`, the BFF resolves it to the current `session_id` and re-issues the cookie without performing another rotation. - -**TTL**: `gateway.refresh_grace_ms`, default `250 ms`. Set with `PX` (millisecond precision). - -**Why a separate key, not a HASH field**: `bff:session:*` is keyed by current `session_id`; the swap key is the only way to look up a rotation by *previous* `session_id`. Lifetime is much shorter than the session itself, so a separate key with PX TTL is the right shape. Read-only by the BFF, never written by the Router. - -#### Key: `bff:logout_jti:{iss}:{jti}` - -**Type**: Redis STRING (value: `1`, semantically a presence flag). - -**Purpose**: Replay protection for OIDC back-channel `logout_token`. Set with `NX` on first valid delivery; subsequent deliveries with the same `(iss, jti)` short-circuit to a `200` without performing any revoke. - -**TTL**: `(iat + max_clock_skew + grace) - now` (defaults: `max_clock_skew = 60s`, `grace = 60s`). After TTL the `jti` may legitimately be reissued by the IdP. - -#### Note on `router:jwt_cache:{session_id}` - -Owned by the Router, not by the BFF. The BFF deletes these keys as part of revoke operations to invalidate cached gateway JWTs immediately. See [Router DESIGN §3.4](../router/DESIGN.md#34-redis-keys-read-only-and-jwt-cache). - -### 3.8 Gateway JWT Claim Contract - -- [ ] `p2` - **ID**: `cpt-insightspec-design-bff-jwt-claim-spec` - -This section is the technical specification for the contract `cpt-insightspec-contract-bff-gateway-jwt` declared in [PRD §7.2](./PRD.md#72-external-integration-contracts). The BFF defines the contract; the Router mints; downstream services verify. - -**Header**: - -```json -{ - "alg": "EdDSA", - "typ": "JWT", - "kid": "" -} -``` - -**Required JWT claims (RFC 7519)**: - -| Claim | Type | Description | -|---|---|---| -| `iss` | String | `https:///` | -| `aud` | String | `internal-services` | -| `sub` | String | Internal `user_id` | -| `iat` | Int | Issued at (epoch seconds) | -| `exp` | Int | `iat + 60..300` | -| `jti` | String | UUID v7 -- traceable, monotonic | - -**Insight custom claims**: - -| Claim | Type | Description | -|---|---|---| -| `tid` | String | `tenant_id` | -| `sid` | String | BFF session ID (opaque to downstream, used for tracing only) | - -**Out of scope for v1**: `lic`, `roles`, `scopes`. Authorization is performed inside each downstream service against its own data sources. These claims may be added in a later major version of the contract. - -**JWKS distribution**: each downstream service is configured (Helm value `gateway.jwks_url`, env `GATEWAY_JWKS_URL`) with the absolute URL of the gateway's JWKS endpoint. Services fetch on startup, cache for 1 h, and re-fetch on unknown `kid`. There is no service discovery; the URL is explicit. - -**Verification at downstream**: - -```mermaid -flowchart LR - A[Receive JWT] --> B{Header parses?} - B -- no --> R[401] - B -- yes --> C{kid in JWKS cache?} - C -- no --> D[Fetch GATEWAY_JWKS_URL] - D --> E{kid found?} - E -- no --> R - E -- yes --> F - C -- yes --> F[Verify EdDSA signature] - F -- bad --> R - F -- ok --> G{iss == gateway
aud == internal-services?} - G -- no --> R - G -- yes --> H{exp > now?} - H -- no --> R - H -- yes --> I[Apply RBAC on sub + tid] - I --> Z[200 / 403] -``` - -### 3.9 Boundary with the Router - -| Concern | Owner | Notes | -|---|---|---| -| OIDC handshake | BFF | Router never talks to the IdP | -| Session create / refresh / revoke | BFF | All ops are HMGET + MULTI/EXEC pipelines; no Lua. See §3.6 sequence diagrams. | -| Cookie issue / clear | BFF | Router never sets cookies | -| CSRF token issue | BFF | Router enforces nothing CSRF-related on `/api/*` (relies on `SameSite=Strict`) | -| IdP access-token refresh | _(not in v1)_ | Tokens stored at login are not refreshed; v1 never calls IdP-protected APIs on the user's behalf. | -| Gateway JWT mint + sign | Router | Reads claims from session via shared session manager | -| JWKS publication | Router | Endpoint `/.well-known/jwks.json` | -| Reverse proxy `/api/*` | Router | Forwards with `Authorization: Bearer ` | -| Session manager library | BFF | Used by Router as a Rust crate | -| `bff:*` Redis keys | BFF | Router has read-only access to `bff:session:*` | -| `router:jwt_cache:*` Redis keys | Router | BFF deletes them as part of revoke | - -## 4. Cross-Cutting Concerns - -### 4.1 Cookie Hardening - -A single helper sets every session cookie. Attributes are hard-coded in code, only `Max-Age` is from config: - -- Name: `__Host-sid` (forces Secure + Path=/ + no Domain). -- `HttpOnly`. -- `Secure`. -- `SameSite=Strict`. -- `Path=/`. -- `Max-Age` = configured `session_ttl` (default 120 s) or `0` for clears. - -A unit test asserts the exact `Set-Cookie` header for set and clear cases. Any other code path setting cookies fails review. - -### 4.2 CSRF Defense - -Primary: `SameSite=Strict`. - -Secondary, on POST/PUT/PATCH/DELETE on `/auth/*`: - -1. Read `X-CSRF-Token` header. -2. Compare against `session.csrf_token` (constant-time). -3. If absent or mismatched, check `Origin` against the configured SPA origin allowlist. -4. If both fail, return 403. - -**Rotation cadence.** The CSRF token is generated once per session, on login (`/auth/callback`). It is **not** rotated on every refresh -- the cookie SID rotation already isolates the post-login session from any pre-login attacker state, and the CSRF token is bound to the session record, so it dies with the session. We deliberately do **not** rotate on "privilege change" today: the BFF's claim contract carries only `sub`, `tid`, `sid` (no `roles`/`license`/`scopes` in v1), and the BFF receives no privilege-change events from Identity Service. If a future version adds richer claims plus a notification channel, this section is the place to add a per-session bump on those events. - -**Empty `csrf_origins`.** When `gateway.csrf_origins` is left empty (default), the `Origin` fallback never matches and any state-changing `/auth/*` request without a valid `X-CSRF-Token` is rejected with `403`. This is intentional fail-closed behaviour. Operators who set `csrf_origins` opt into a more permissive mode where `Origin` alone is sufficient on requests that legitimately drop the CSRF header. - -**SPA contract.** The SPA fetches the CSRF token once via `GET /auth/csrf` after login (or on receiving a `403` with `WWW-Authenticate: csrf-required`) and caches it for the rest of the session. `/auth/me` echoes the same token so a fresh SPA load can prime the cache without an extra round-trip. - -### 4.3 Janitor for Expired Sessions - -A background task on every BFF pod (one elected leader via Redis lock) runs every `janitor_interval` seconds (default 30 s): - -1. `SCAN MATCH bff:user_sessions:*` to enumerate user index keys. -2. For each, `ZREMRANGEBYSCORE key 0 ` to drop expired entries. -3. Emit `bff_janitor_removed_total` metric and `bff_janitor_backlog_size` (entries removed in last pass). - -Per-session Redis TTL on `bff:session:{id}` already removes the record itself. The janitor exists only to keep the index clean so `/auth/sessions` and revoke-all stay fast. - -```mermaid -flowchart LR - T[Tick] --> L{Acquire
Redis lock?} - L -- no --> Skip[Skip pass] - L -- yes --> Scan[SCAN bff:user_sessions:*] - Scan --> Loop{For each key} - Loop --> Trim[ZREMRANGEBYSCORE 0 now] - Trim --> Metric[Emit metric] - Metric --> Loop - Loop --> Done[Release lock] -``` - -### 4.4 Rate Limiting on `/auth/*` - -Two layers, both in-process (axum middleware), both fail-closed on Redis loss: - -1. **Per-IP token bucket** on `/auth/login`, `/auth/callback`, `/auth/refresh`. Backed by a fixed-window counter in Redis (`bff:rl:auth:{ip}`, TTL = window). Default: `10 req/min` (`auth_rate_per_ip`) with burst `20` (`auth_burst_per_ip`). Helm-tunable. -2. **Global cap on active login attempts.** A counter (`bff:rl:login_state_count`) tracks the number of live `bff:login_state:*` entries. `/auth/login` increments-and-checks; if the count exceeds `auth_login_state_max` (default `1000` per pod), the request is rejected `429` before any Redis HASH is written. Counter is decremented on successful callback or on key expiry (via a Redis keyspace notification listener; if notifications are not enabled the counter drifts down via the janitor's reconcile pass). - -Both layers run before any expensive work (Redis HASH write, IdP redirect, token exchange). Both emit metrics so alerts can fire on sustained pressure. - -**Why not just rely on the ingress.** The cluster ingress can rate-limit by IP, but the *global* cap on login-state entries is BFF-specific data the ingress cannot see. The two layers compose: ingress catches the volumetric flood; the BFF catches the slower-trickle Redis-exhaustion attack. - -### 4.5 Observability - -Metrics (Prometheus): - -- `bff_auth_login_total{result}` -- ok / fail / state_mismatch -- `bff_auth_refresh_total{result}` -- ok / expired / past_cap -- `bff_session_active` -- gauge from periodic Redis sample -- `bff_session_lookup_duration_seconds` -- histogram -- `bff_back_channel_logout_total{result}` -- `bff_janitor_removed_total` -- `bff_janitor_backlog_size` - -Logs (structured JSON): every auth event with `correlation_id`, `session_id` (hashed), `user_id`, `tenant_id`. Never log cookies, raw tokens, or refresh tokens. - -Audit (via Audit Service): login OK, login fail, refresh, logout, revoke (single / all / admin), back-channel logout. - -## 5. Design Decisions - -### DD-BFF-01: Opaque Session vs JWT Cookie - -**Decision**: Opaque session ID + Redis lookup, not a cookie-borne JWT. - -**Why**: -- Revocation must be instant (offboarding, suspected compromise) -- only possible with a server-side store. -- Opaque IDs leak less on theft -- they only work on this host, not as a portable bearer. -- Cookie size stays tiny. - -**Consequences**: Hard dependency on Redis. No local cache, no degraded mode (see DD-BFF-06). - -### DD-BFF-02: Explicit Session Refresh, No Sliding TTL - -**Decision**: Session TTL is short and hard. Only `POST /auth/refresh` extends it. - -**Why**: -- Sliding TTL on every API call requires a Redis write on the hot path. Explicit refresh moves writes off the hot path. -- The SPA already knows when the user is active; making refresh explicit gives it control and keeps the BFF predictable. -- Hard TTL bounds the window for stolen-cookie reuse. - -**Consequences**: SPA must implement a refresh loop. Documented as part of the SPA contract; the BFF returns 401 on expiry, and the SPA redirects to `/auth/login`. - -### DD-BFF-03: ZSET (Not SET) for User-Session Index - -**Decision**: `bff:user_sessions:{user_id}` is a Redis sorted set with score = `expires_at`. - -**Why**: -- Plain SET cannot answer "which of these sessions are expired" without reading every member's record. -- ZSET makes both "active sessions" and "expired entries" O(log N) lookups via `ZRANGEBYSCORE`. -- Janitor cleanup is a single `ZREMRANGEBYSCORE` per user. - -**Consequences**: Slightly more Redis memory per entry (score + element vs element only). Negligible at our scale. - -### DD-BFF-04: BFF-Prefixed Redis Keys - -**Decision**: Every Redis key owned by the BFF starts with `bff:`. Router uses `router:`. Future modules pick their own prefix. - -**Why**: -- Single Redis instance is shared across modules. Owner-prefixed names eliminate collisions. -- Operators reading Redis keys can identify the owner from the prefix. -- Migration of one module's data is a single prefix scan. - -**Consequences**: Slightly longer keys. Worth it. - -### DD-BFF-05: EdDSA Algorithm for Gateway JWT Contract - -**Decision**: The gateway JWT contract mandates EdDSA (Ed25519). No other algorithms. - -**Why**: -- Small signatures (~64 bytes) keep header size down. -- Fast verification (~10× faster than RS256). -- Single private/public key pair, no padding choices. - -**Consequences**: Verifier libraries in downstream services must support EdDSA. Documented as a constraint on adding new downstream services. - -### DD-BFF-06: Redis Outage = No Auth (Fail Closed) - -**Decision**: When Redis is unreachable, the BFF returns 401 on `/auth/*` mutations and 503 on the readiness probe. There is no local in-memory session cache and no degraded read-only mode. - -**Why**: -- A local cache is expensive to keep coherent across pods -- a revoke on pod A would have to invalidate the cache on every other pod. -- Coherent session state is the whole reason we keep sessions in Redis. A "best-effort" local cache contradicts that. -- The Router also fails closed on Redis loss, so a degraded mode in the BFF would not actually let users do anything useful. - -**Consequences**: Redis availability is the auth availability. HA Redis is mandatory in the production Helm values; documented in the operator runbook. - -### DD-BFF-07: `/auth/refresh` Returns Next-Refresh Deadline - -**Decision**: `POST /auth/refresh` returns `200` with a JSON body: - -```json -{ - "expires_at": 1714320120, - "refresh_at": 1714320060 -} -``` - -`expires_at` is the absolute moment the session will expire if not refreshed again. `refresh_at` is the recommended next-refresh moment (`expires_at - safety_margin`, default `safety_margin` = 30 s, configurable). - -**Why**: -- The SPA needs to know when to call refresh next; computing it client-side from `Max-Age` plus a hard-coded fudge factor is fragile and silently goes wrong if the operator changes `session_ttl`. -- A server-supplied deadline lets the operator tune the cadence without an SPA change. - -**Consequences**: SPA implements a single timer `setTimeout(refresh, refresh_at - now)`. On 401, it redirects to `/auth/login`. The same body is returned by `/auth/me` so the SPA can prime the timer at startup. - -### DD-BFF-08: `SameSite=Strict` Today, Pluggable Cookie Classes Later - -**Decision**: The session cookie stays `SameSite=Strict` for v1. We accept the deep-link drawback (external links land users on the login page). - -**Why**: -- Strict gives the strongest CSRF baseline. -- Mixing a Lax companion cookie now would complicate revoke / refresh logic for marginal UX gain at this stage. - -**Future**: When we have a real need (e.g. shareable dashboard links, embedded views, or a public read-only token class), we'll introduce a second cookie class with its own attributes and lifecycle, kept separate from the primary session. - -### DD-BFF-09: Janitor Coordinates via Redis Lock - -**Decision**: A single distributed Redis lock (`bff:lock:janitor`, TTL slightly longer than the pass interval) elects one pod per pass. No external CronJob. - -**Why**: -- One process inside the BFF binary keeps deployment simple -- no extra K8s object to manage or version. -- Redis is already a hard dependency; reusing it for coordination adds no new failure mode. -- Pass interval (default 30 s) is short enough that a missed pass costs little. - -**Consequences**: Pod with the lock takes the work; others skip. Backlog metric alerts if no pod runs the pass for more than `2 × interval`. - -### DD-BFF-10: Rolling Cookies on `/auth/refresh` with Grace + Jitter - -**Decision**: The session cookie is rotated on every successful `/auth/refresh`. A small grace window (`bff:swap:{old_sid} → new_sid`, default 250 ms) absorbs benign races on `/auth/refresh` only. The server-supplied `refresh_at` is jittered within a 10 s window. The SPA is contractually required to coordinate `/auth/refresh` across browser tabs (BroadcastChannel / localStorage). - -**Why**: -- Rotating cookies turns long-lived stolen credentials into short-lived ones. After a rotation, the previous cookie is rejected past the grace window -- a thief who snapshotted the cookie sees `401` on the next refresh attempt. -- Without grace, even a 100 ms multi-tab race results in 401 → user logged out unnecessarily. 250 ms is small enough that an attacker can't reasonably align to it across the network, large enough to absorb realistic SPA races. -- Without jitter, an attacker who knows `refresh_at` can fire their own `/auth/refresh` in the exact same 250 ms window. ±5 s jitter (10 s window) means the attacker would need to refresh continuously, raising audit / rate-limit signal. -- Tab coordination eliminates the most common legit cause of parallel refreshes (multiple tabs of the same SPA), so the grace window is essentially reserved for true edge cases (page reload mid-refresh, network retry). -- `/api/*` does **not** consult `bff:swap:*`. Keeps the Router's hot path unchanged: stale cookie on `/api/*` is just `401`, the SPA refreshes and retries. The same SPA discipline that handles tab coordination handles this transparently. - -**Consequences**: -- Detection of token theft is probabilistic, not deterministic — a sophisticated attacker who races every refresh stays in sync. The intent is to make stolen-credential reuse expensive and noisy, not impossible. Stronger detection (e.g. revoke-all-on-stale-token-past-grace) is a future option. -- One additional Redis key per active rotation, with sub-second TTL. Storage cost negligible. -- SPA implementation needs the multi-tab coordination layer; documented in the SPA contract section of §3.6. - -## 6. Traceability - -- **PRD**: [PRD.md](./PRD.md) -- **Sibling**: [Router PRD](../router/PRD.md), [Router DESIGN](../router/DESIGN.md) -- gateway JWT minting, JWKS, `/api/*` proxy, route config -- **Parent**: [API Gateway PRD](../PRD.md), [API Gateway DESIGN](../DESIGN.md) -- umbrella docs -- **Backend**: [Backend PRD](../../specs/PRD.md), [Backend DESIGN](../../specs/DESIGN.md) -- **ADRs**: [ADR/](./ADR/) -- to be authored alongside implementation. Decisions captured inline as DD-BFF-01..10 in §5 until then. diff --git a/docs/components/backend/api-gateway/bff/PRD.md b/docs/components/backend/api-gateway/bff/PRD.md deleted file mode 100644 index 492b51647..000000000 --- a/docs/components/backend/api-gateway/bff/PRD.md +++ /dev/null @@ -1,596 +0,0 @@ ---- -status: proposed -date: 2026-04-28 ---- - -# PRD -- BFF (Backend-for-Frontend) Service - - - -- [1. Overview](#1-overview) - - [1.1 Purpose](#11-purpose) - - [1.2 Background / Problem Statement](#12-background--problem-statement) - - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) - - [1.4 Glossary](#14-glossary) -- [2. Actors](#2-actors) - - [2.1 Human Actors](#21-human-actors) - - [2.2 System Actors](#22-system-actors) -- [3. Operational Concept & Environment](#3-operational-concept--environment) - - [3.1 Module-Specific Environment Constraints](#31-module-specific-environment-constraints) -- [4. Scope](#4-scope) - - [4.1 In Scope](#41-in-scope) - - [4.2 Out of Scope](#42-out-of-scope) -- [5. Functional Requirements](#5-functional-requirements) - - [5.1 OIDC Login Flow](#51-oidc-login-flow) - - [5.2 Session Cookie](#52-session-cookie) - - [5.3 Session Refresh](#53-session-refresh) - - [5.4 Session Store](#54-session-store) - - [5.5 Gateway JWT (Downstream Token)](#55-gateway-jwt-downstream-token) - - [5.6 Session Management](#56-session-management) - - [5.7 Logout](#57-logout) - - [5.8 CSRF Protection](#58-csrf-protection) -- [6. Non-Functional Requirements](#6-non-functional-requirements) - - [6.1 NFR Inclusions](#61-nfr-inclusions) - - [6.2 NFR Exclusions](#62-nfr-exclusions) -- [7. Public Library Interfaces](#7-public-library-interfaces) - - [7.1 Public API Surface](#71-public-api-surface) - - [7.2 External Integration Contracts](#72-external-integration-contracts) -- [8. Use Cases](#8-use-cases) -- [9. Acceptance Criteria](#9-acceptance-criteria) -- [10. Dependencies](#10-dependencies) -- [11. Assumptions](#11-assumptions) -- [12. Risks](#12-risks) - - - -## 1. Overview - -### 1.1 Purpose - -The BFF is the auth half of the Insight API Gateway. It runs the OIDC login flow against the customer's identity provider, holds the user session server-side, and exposes a small `/auth/*` API that the SPA uses to log in, refresh, list devices, and log out. - -The browser only ever holds an opaque session cookie with a short TTL. The IdP token never reaches the browser. The session is extended only by an explicit `POST /auth/refresh` from the SPA -- never by passive activity. - -Request forwarding to internal services and gateway JWT minting are owned by the sibling [Router](../router/PRD.md), not by the BFF. - -### 1.2 Background / Problem Statement - -The current frontend stores the OIDC access token in `localStorage`. Any XSS leaks every active token. Storage-bound tokens are also visible to browser extensions and developer tools. - -Moving the token to an `HttpOnly`, `Secure`, `SameSite=Strict` cookie alone is not enough. The token in the cookie still grants long-lived access, cannot be revoked without a denylist, and exposes IdP claims to the browser tier. We want: - -1. The browser to hold only an opaque session ID -- nothing usable if leaked off-host. -2. The session to be revocable instantly, including "log out everywhere" for one user. -3. Internal services to verify caller identity statelessly via a short-lived JWT signed by the gateway, not by the IdP. - -### 1.3 Goals (Business Outcomes) - -- Remove all IdP and access tokens from browser storage. -- Make sessions revocable per-session and per-user from a single store. -- Give every internal service a verifiable, short-lived identity claim per request. -- Keep the SPA simple -- no token handling code in the browser. - -### 1.4 Glossary - -| Term | Definition | -|------|------------| -| OIDC token | Tokens issued by the customer's identity provider. The BFF stores only `id_token` (used as `id_token_hint` on RP-initiated logout); access and refresh tokens are received at login but not stored or used in v1. The browser never sees any of them. | -| Session cookie | Opaque, random session ID set on the browser by the BFF. Short, hard TTL. No claims, no meaning outside Redis. | -| Session record | Server-side object in Redis keyed by session ID. Holds user, tenant, IdP linkage (`iss`, `sub`, `sid`), `id_token` for logout hint, expiries, and CSRF token. | -| Session refresh | Explicit `POST /auth/refresh` call from the SPA that extends the session TTL. The session does **not** extend automatically on regular API calls. | -| User session index | Redis sorted set keyed by user ID. Members are session IDs, score is `expires_at`. Lets the BFF list active sessions and find expired ones in O(log N). | -| Gateway JWT | Short-lived EdDSA-signed JWT minted by the [Router](../router/PRD.md) for each upstream call. Verified by internal services via JWKS. | -| Downstream service | Any internal Insight service behind the gateway (Analytics API, Connector Manager, Identity Service, etc.). | - -## 2. Actors - -### 2.1 Human Actors - -#### Browser User - -**ID**: `cpt-insightspec-actor-browser-user` - -**Role**: Any authenticated end user (Viewer, Analyst, Admin) accessing Insight through the SPA. -**Needs**: Log in, stay logged in across requests, log out, see their active sessions, revoke a session from another device. - -#### Tenant Administrator - -**ID**: `cpt-insightspec-actor-tenant-admin` - -**Role**: Already defined in the parent backend PRD. Additionally needs to revoke any user's sessions (forced logout on role change, offboarding, suspected compromise). - -### 2.2 System Actors - -#### OIDC Provider - -**ID**: `cpt-insightspec-actor-oidc-provider` - -**Role**: Customer identity provider. Runs the authorization code + PKCE flow. May call back-channel logout. - -#### Downstream Service - -**ID**: `cpt-insightspec-actor-downstream-service` - -**Role**: Any internal Insight service that receives the gateway JWT from the API gateway and authorizes the request based on its claims. The gateway is one service split into BFF and Router logic parts; from the downstream service's perspective the JWT comes from the gateway. - -#### Redis - -**ID**: `cpt-insightspec-actor-redis` - -**Role**: Stores session records and the user-to-sessions index. The single source of truth for "who is logged in". - -## 3. Operational Concept & Environment - -### 3.1 Module-Specific Environment Constraints - -- Single deployment per Insight installation, fronted by the cluster ingress. -- Public hostname terminates TLS at the ingress; the BFF refuses requests received as plain HTTP. -- The BFF and the SPA are served from the same registrable domain so cookies are first-party. -- Stateless horizontally scalable -- all session state is in Redis. - -## 4. Scope - -### 4.1 In Scope - -- OIDC authorization code + PKCE login flow as a confidential client. -- Opaque session cookie issuance with short hard TTL and hardened attributes. -- Explicit session refresh endpoint (`POST /auth/refresh`). -- Session record storage in Redis (BFF-prefixed keys) with a sorted-set per-user index keyed by `expires_at`. -- Session listing and revocation API (single, all-but-current, all). -- Logout: local, RP-initiated to OIDC provider, and OIDC back-channel logout receiver. -- CSRF defense for state-changing requests. -- Periodic cleanup of expired session entries from the user index. - -### 4.2 Out of Scope - -- Gateway JWT minting and signing -- owned by the [Router](../router/PRD.md). -- JWKS endpoint -- served by the Router. -- Reverse-proxying `/api/*` requests -- owned by the Router. -- Authorization decisions inside downstream services (each service still enforces RBAC and visibility). -- User registration, password management, MFA -- handled by the customer OIDC provider. -- License / role / scope claims in the gateway JWT -- not needed for v1; the contract carries the required JWT claims (`iss`, `aud`, `sub`, `iat`, `exp`, `jti`) plus `tid` and `sid`. See §5.5 / §7.2. -- Mobile or third-party API clients (v1 serves only the bundled SPA). - -## 5. Functional Requirements - -### 5.1 OIDC Login Flow - -#### Authorization Code with PKCE - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-oidc-login` - -The system **MUST** implement OIDC authorization code flow with PKCE as a confidential client. The BFF **MUST** generate `state`, `nonce`, and PKCE verifier per login attempt and validate them on callback. The browser **MUST NOT** receive or transmit the IdP code, ID token, or access token at any point. - -The new `session_id` issued at the end of a successful callback **MUST** be generated server-side from a CSPRNG and **MUST NOT** be derived from, or equal to, any value present in the incoming request (cookies, headers, query). Any `__Host-sid` cookie present on the `/auth/callback` request **MUST** be ignored; if its value maps to a live session in Redis, that session **MUST** be revoked before the new session is created. This prevents session-fixation where an attacker plants a known SID before the victim logs in. - -**Rationale**: The whole point of this redesign -- IdP tokens never leave the server. - -**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-oidc-provider` - -### 5.2 Session Cookie - -#### Session Cookie Issuance - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-session-cookie` - -After a successful OIDC callback, the system **MUST** issue an opaque session cookie with these attributes: - -- `__Host-` prefix (forces host-only + Secure + Path=/). -- `HttpOnly`. -- `Secure`. -- `SameSite=Strict`. -- Random value with at least 128 bits of entropy. -- Short hard TTL. The TTL **MUST** be configurable; default is 120 seconds. The cookie `Max-Age` **MUST** match the session record TTL in Redis. -- The TTL **MUST NOT** be extended automatically by activity. Only an explicit `POST /auth/refresh` extends it (see 5.3). -- An absolute hard cap (e.g. 8h, configurable) **MUST** apply across refreshes -- once `created_at + max_lifetime` is reached, refresh fails and the user must log in again. - -The cookie value **MUST** be opaque -- no claims, no JWT, no user-identifying data. - -**Rationale**: Short TTL plus explicit refresh limits the window for stolen-cookie reuse and gives the SPA explicit control over session lifetime. The absolute cap forces re-authentication on a known schedule. - -**Actors**: `cpt-insightspec-actor-browser-user` - -### 5.3 Session Refresh - -#### Explicit Session Refresh Endpoint - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-session-refresh` - -The system **MUST** expose `POST /auth/refresh`. The cookie value is the `session_id` and **rotates** on every successful refresh. Behaviour: - -1. **Stale cookie / no session in Redis** → 401, clear the cookie. -2. **Cookie value found in `bff:session:*`** (normal path): - 1. Generate a fresh `session_id` (`new_sid`) from a CSPRNG, ≥128 bits entropy. - 2. Compute `new_exp = min(now + session_ttl, absolute_expires_at)`. - 3. Compute `refresh_at = (new_exp − safety_margin) + jitter`, where `jitter` is a uniform random offset in the range `[−jitter_window/2, +jitter_window/2]`. `safety_margin` defaults to 30 s. `jitter_window` defaults to 10 s. - 4. **Atomically** rotate the session: write the grace mapping `bff:swap:{old_sid} → new_sid` with a TTL of `grace_ms` (default `250 ms`); rename `bff:session:{old_sid}` to `bff:session:{new_sid}` and update `expires_at` + Redis TTL to `new_exp`; replace the user-index ZSET entry; replace the IdP-sid index entry; delete `router:jwt_cache:{old_sid}`. All of this in one batch. - 5. Re-issue the session cookie with the new `session_id` and `Max-Age = new_exp − now`. - 6. Return `200 {expires_at, refresh_at}`. -3. **Cookie value found in `bff:swap:*` (grace path)**: - 1. Resolve the swap to `new_sid`. The session is already current; do **not** rotate again. - 2. Read `expires_at` from `bff:session:{new_sid}`. - 3. Compute a freshly jittered `refresh_at`. - 4. Set the cookie to `new_sid` and return `200 {expires_at, refresh_at}`. - -The grace window (`grace_ms`, default 250) **MUST** be small. Outside it, the old `session_id` is unrecoverable: 401 + clear cookie. - -The system **MUST NOT** extend the session on any other endpoint or proxied API call. Regular `/api/*` traffic does not slide the TTL and does **not** rotate the cookie. A stale cookie on `/api/*` returns 401 immediately — the SPA must call `/auth/refresh` first. - -The system **MUST NOT** call the IdP refresh-token endpoint as part of `/auth/refresh`. v1 does not store or use IdP access/refresh tokens after login. Hard-cap behaviour is delegated to the Redis TTL: once `new_exp` reaches `absolute_expires_at`, the next `EXPIREAT` either keeps the key alive briefly or evicts it; the user is then forced to log in again. - -`GET /auth/me` **MUST** return the same `{expires_at, refresh_at}` fields (with a fresh `refresh_at` jitter) so the SPA can prime its refresh timer at page load. - -**SPA contract.** The SPA **MUST** coordinate `/auth/refresh` calls across browser tabs of the same session, so only one tab fires the refresh per window. The recommended mechanism is `BroadcastChannel` with a `localStorage` fallback: a leader tab calls `/auth/refresh`, broadcasts the result, follower tabs read it without firing their own request. The SPA **MUST** use the server-supplied `refresh_at` (which is already jittered) as the scheduling target; an additional client-side jitter is permitted but not required. - -**Rationale**: Cookie rotation detects token theft -- two parties holding the same cookie eventually diverge, and the one out of sync hits 401. The grace window absorbs benign races (page reload mid-refresh, parallel refresh from siblings during the brief leader-election window). Server-side jitter prevents an attacker from aligning a forged `/auth/refresh` to the predictable grace window. Multi-tab coordination eliminates the most common legit cause of parallel refreshes. - -**Actors**: `cpt-insightspec-actor-browser-user` - -### 5.4 Session Store - -#### Redis-Backed Session Storage - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-session-store` - -The system **MUST**: - -1. **Persist sessions** -- record every active session server-side with all fields needed to validate, refresh, and revoke it (user, tenant, IdP linkage, timestamps, hard cap, CSRF token, request fingerprint). The store is Redis; key family `bff:session:*`. -2. **Maintain a per-user session index** that lets the BFF look up every active session for a given user in sub-linear time. Used by the "list my devices" and "log out everywhere" flows. Key family `bff:user_sessions:*`. -3. **Maintain an IdP-sid lookup** that resolves an `(iss, idp_sid)` pair (carried in OIDC back-channel logout tokens) to the matching local session(s). Key family `bff:sid_index:*`. -4. **Make create / refresh / revoke atomic** -- a partial failure **MUST NOT** leave the session record and the indexes out of sync. -5. **Run a periodic janitor** that removes expired entries from the user-session index and emits a metric on any drift between the index and the underlying session records. - -The exact Redis schema (field list, sorted-set scoring, atomicity mechanism, janitor interval) is specified in [DESIGN §3.7](./DESIGN.md#37-database-schemas--tables). The PRD only states *what* must hold; *how* it is implemented is the DESIGN's job. - -**Rationale**: Server-side storage is what makes sessions revocable. The per-user index is what makes "list devices" and "revoke all" fast. The IdP-sid index is what makes back-channel logout work. Atomicity prevents zombie sessions. The janitor keeps the index honest. - -**Actors**: `cpt-insightspec-actor-redis` - -### 5.5 Gateway JWT (Downstream Token) - -#### Gateway JWT Claim Contract - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-gateway-jwt` - -The gateway JWT is **minted by the Router** (see [Router PRD §5.2](../router/PRD.md#52-gateway-jwt-mint-and-cache)). The BFF defines the **claim contract** that the Router fills. - -The gateway JWT **MUST** be signed with **EdDSA (Ed25519)** and **MUST** carry exactly the following claims, separated as required JWT claims and Insight-specific custom claims: - -**Required JWT claims (RFC 7519)**: - -- `iss` -- gateway issuer URL. -- `aud` -- `internal-services`. -- `sub` -- internal `user_id`. -- `iat` -- issued at (epoch seconds). -- `exp` -- `iat + 60..300` (hard bounds). -- `jti` -- UUID v7 for traceability. - -**Insight custom claims**: - -- `tid` -- `tenant_id`. -- `sid` -- BFF session ID (opaque, useful for tracing only). - -**Out of scope for v1**: license tier (`lic`), roles, scopes. Authorization is performed inside each downstream service against its own data. These claims may be added in a later major version of the contract; until then, downstream services **MUST NOT** rely on them being present. - -The Router **MUST** publish the public verification key at `/.well-known/jwks.json` on the gateway. **JWKS distribution to downstream services**: each downstream service is configured (Helm value `gateway.jwks_url`, env `GATEWAY_JWKS_URL`) with the absolute URL of the JWKS endpoint. Services fetch and cache the key set at startup and re-fetch on unknown `kid`. There is no service discovery; the URL is explicit. - -Downstream services **MUST** verify the signature, `iss`, `aud`, and `exp` against the cached JWKS -- no shared secrets. - -**Rationale**: Short TTL kills the need for a JWT denylist. EdDSA signatures are small and fast to verify. Explicit JWKS URL avoids guessing or service-mesh dependencies. - -**Actors**: `cpt-insightspec-actor-downstream-service` - -### 5.6 Session Management - -#### List Active Sessions - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-session-list` - -The system **MUST** expose an authenticated endpoint that returns the active sessions for the calling user (created_at, expires_at, user_agent, ip, current=true/false). The endpoint **MUST** read from the user-sessions sorted set with `ZRANGEBYSCORE`, returning only entries with score > now. - -**Actors**: `cpt-insightspec-actor-browser-user` - -#### Revoke Sessions - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-session-revoke` - -The system **MUST** support three revocation operations: - -1. Revoke the current session (logout). -2. Revoke a specific other session by ID. -3. Revoke all sessions for a user (self "log out everywhere", or admin-initiated). - -Each operation **MUST** delete the session record(s) and remove them from `bff:user_sessions:{user_id}` (`ZREM`) atomically, and (when feasible) call the OIDC provider's RP-initiated logout. After revocation, any in-flight gateway JWT **MUST** become invalid within one JWT TTL (≤ 300 s). - -**Rationale**: Instant revocation is the main reason to keep the session opaque + server-side. Without "all-sessions" revocation, offboarding and compromise response are broken. - -**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-tenant-admin` - -### 5.7 Logout - -#### Logout (Local, RP-Initiated, Back-Channel) - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-logout` - -The system **MUST** provide `POST /auth/logout` that revokes the current session, clears the cookie (`Max-Age=0`), and redirects (or returns a redirect URL) to the OIDC `end_session_endpoint` for RP-initiated logout. - -The system **MUST** accept OIDC back-channel logout tokens at a dedicated endpoint, validate the `logout_token` per spec, locate sessions by `(iss, sid)` (direct lookup via `bff:sid_index:*`) or `(iss, sub)` (resolve `sub` to internal `user_id` via Identity Service, then walk `bff:user_sessions:{user_id}`), and revoke them. - -The system **MUST** protect the back-channel endpoint against replay: every accepted `logout_token` **MUST** be recorded by `(iss, jti)` with a TTL of at least `iat + max_clock_skew`, and any subsequent delivery of the same `(iss, jti)` **MUST** short-circuit to a successful response without performing another revoke. - -The system **MUST** document and accept that a `logout_token` carrying only `sub` (no `sid`) will revoke every active session for that user across all browsers ("log out everywhere"). This is OIDC-spec-compliant fallback behaviour, but operators **MUST** be informed in the runbook so a misconfigured IdP does not silently widen blast radius. - -**Rationale**: Without back-channel logout, IdP-side session termination does not propagate. Without RP-initiated logout, users stay signed in to the IdP after pressing "log out". Without `jti` replay protection, a captured valid `logout_token` is replayable for as long as its signature verifies, enabling repeated forced revocations / DoS for reconnecting users. - -**Actors**: `cpt-insightspec-actor-oidc-provider`, `cpt-insightspec-actor-browser-user` - -### 5.8 CSRF Protection - -#### CSRF Defense - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-bff-csrf` - -For state-changing methods (POST, PUT, PATCH, DELETE) on `/auth/*`, the system **MUST** require either: - -1. A double-submit CSRF token sent in `X-CSRF-Token` matching a value bound to the session, or -2. A verified `Origin` header matching the configured SPA origin. - -`SameSite=Strict` is the primary defense; this requirement is the second line. - -**Rationale**: `SameSite=Strict` mitigates most CSRF, but defense in depth is cheap and protects against same-site-but-different-path attack vectors. - -**Actors**: `cpt-insightspec-actor-browser-user` - -## 6. Non-Functional Requirements - -### 6.1 NFR Inclusions - -#### HTTPS Enforcement - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-bff-https-only` - -The system **MUST** reject all plain-HTTP requests at the ingress. The system **MUST** set `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` on every response. - -**Threshold**: Zero responses served over HTTP. - -#### Session Lookup Latency - -- [ ] `p2` - **ID**: `cpt-insightspec-nfr-bff-session-lookup-p95` - -Session validation against Redis **MUST** complete within 5 ms p95 under normal load. - -**Threshold**: 5 ms p95 Redis read. - -#### Session TTL Bounds - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-bff-session-ttl` - -The session TTL **MUST** be configurable. Allowed range: 30 seconds to 1 hour. Default: 120 seconds. The absolute lifetime cap **MUST** be configurable; default 8 hours; minimum 1 hour; maximum 24 hours. - -**Threshold**: Operator can set both knobs via Helm values without code change. - -#### Gateway JWT Algorithm - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-bff-jwt-algorithm` - -The gateway JWT **MUST** be signed with EdDSA (Ed25519). No other algorithm is permitted. - -**Threshold**: 100% of issued JWTs use `alg: EdDSA`. Any other algorithm in JWKS or in a token is rejected by downstream services. - -#### Cookie Hardening - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-bff-cookie-attrs` - -Every session cookie response **MUST** include `__Host-` prefix, `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`, and no `Domain` attribute. A request that would set a session cookie without all of these **MUST** fail closed. - -**Threshold**: 100% of session-cookie responses match the attribute set. - -#### Audit of Auth Events - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-bff-audit` - -Every login, logout, session refresh, session revocation, and back-channel logout **MUST** emit an audit event consumed by the Audit Service (see parent PRD `cpt-insightspec-fr-be-audit-trail`). - -**Threshold**: 100% coverage of auth events. - -#### Rate Limiting on `/auth/*` - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-bff-rate-limit-auth` - -The BFF **MUST** rate-limit `/auth/login`, `/auth/callback`, and `/auth/refresh` per source IP (token bucket). Defaults: `auth_rate_per_ip = 10 req/min`, `auth_burst_per_ip = 20`. The BFF **MUST** also enforce a global cap on concurrent active `bff:login_state:*` entries (default `1000` per pod) and reject new `/auth/login` requests with `429` once the cap is hit, to prevent Redis exhaustion via a flood of unfinished login attempts. - -**Threshold**: under sustained 100 req/s/IP attack, login does not consume more than `1000` `bff:login_state:*` entries per pod and CPU is bounded. - -**Rationale**: an attacker can otherwise flood `/auth/login` with concurrent `state` UUIDs, each writing a 5-minute Redis HASH, and exhaust Redis memory or BFF event-loop CPU. - -### 6.2 NFR Exclusions - -- **Per-route rate limiting on `/api/*`**: Handled by the surrounding ingress and per-service middleware. The BFF rate-limits only `/auth/*` per `cpt-insightspec-nfr-bff-rate-limit-auth`. - -## 7. Public Library Interfaces - -### 7.1 Public API Surface - -#### Auth API - -- [ ] `p1` - **ID**: `cpt-insightspec-interface-bff-auth-api` - -**Type**: REST API - -**Stability**: stable - -**Endpoints**: - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/auth/login` | Start OIDC flow; 302 to IdP. | -| GET | `/auth/callback` | OIDC callback; sets session cookie; 302 to SPA. | -| POST | `/auth/refresh` | Extend session TTL; re-issue cookie; return `{expires_at, refresh_at}`. SPA schedules next call from `refresh_at`. | -| POST | `/auth/logout` | Revoke current session; clear cookie; return RP-logout URL. | -| GET | `/auth/me` | Return current user, tenant, plus `{expires_at, refresh_at}` so the SPA can prime its refresh timer at page load. | -| GET | `/auth/sessions` | List active sessions for current user. | -| DELETE | `/auth/sessions/{id}` | Revoke a specific session. | -| DELETE | `/auth/sessions` | Revoke all sessions of current user. | -| POST | `/auth/oidc/back-channel-logout` | Receive IdP back-channel logout tokens. | -| GET | `/auth/csrf` | Issue CSRF token bound to current session. | - -JWKS publication and `/api/*` reverse proxy live on the [Router](../router/PRD.md), not on the BFF, but they share the same hostname and TLS endpoint. - -### 7.2 External Integration Contracts - -#### Gateway JWT Claim Contract - -- [ ] `p1` - **ID**: `cpt-insightspec-contract-bff-gateway-jwt` - -**Direction**: defined by BFF, minted by Router, consumed by every downstream service. - -**Format**: EdDSA-signed JWT. - -**Required JWT claims**: `iss`, `aud`, `sub`, `iat`, `exp`, `jti`. - -**Insight custom claims**: `tid`, `sid`. - -**Compatibility**: Additive custom claims only without a major version. Removing or changing the meaning of any claim requires a major version bump and coordinated rollout. License / role / scope claims are deliberately not present in v1. - -#### JWKS Distribution Contract - -- [ ] `p1` - **ID**: `cpt-insightspec-contract-bff-jwks-url` - -**Direction**: configuration -- each downstream service is given the JWKS URL. - -**Mechanism**: Helm value `gateway.jwks_url` and matching env `GATEWAY_JWKS_URL` injected into each downstream service. Default value points at the gateway's `/.well-known/jwks.json`. Services fetch on startup, cache 1 h, refetch on unknown `kid`. - -**Compatibility**: URL is stable across minor releases. Schema follows RFC 7517 JWKS. - -#### OIDC Provider Contract - -- [ ] `p1` - **ID**: `cpt-insightspec-contract-bff-oidc` - -**Direction**: required from customer. - -**Protocol**: OIDC Authorization Code + PKCE; RP-initiated logout (`end_session_endpoint`); back-channel logout per OIDC spec. The BFF does not use IdP refresh tokens in v1. - -**Compatibility**: Standard OIDC. Customer IdP must support all four. - -## 8. Use Cases - -#### Login - -- [ ] `p1` - **ID**: `cpt-insightspec-usecase-bff-login` - -**Actor**: `cpt-insightspec-actor-browser-user` - -**Preconditions**: SPA loaded; no valid session cookie. - -**Main Flow**: -1. SPA calls a protected API; Router returns 401 with login URL. -2. Browser requests `/auth/login`. BFF generates `state`, `nonce`, PKCE verifier; stores them in `bff:login_state:{state}`; redirects to IdP. -3. User authenticates at IdP. IdP redirects browser to `/auth/callback` with code. -4. BFF validates `state`, exchanges code (with PKCE verifier) for tokens, validates ID token (`nonce`, `iss`, `aud`, signature, expiry). -5. BFF resolves IdP `sub` to internal user (Identity Service). -6. BFF creates `bff:session:{id}` and `ZADD bff:user_sessions:{user_id} {expires_at} {sid}`. -7. BFF sets the session cookie (short TTL) and redirects to the SPA's original target. - -**Postconditions**: Browser holds a session cookie. Redis holds the session record and a sorted-set entry whose score is the session's `expires_at`. Audit event recorded. - -**Alternative Flows**: -- **State or nonce mismatch**: BFF returns 400 and aborts. No session created. -- **IdP-resolved user not found**: BFF returns 403; audit event records the failed login. - -#### Session Refresh - -- [ ] `p1` - **ID**: `cpt-insightspec-usecase-bff-refresh` - -**Actor**: `cpt-insightspec-actor-browser-user` - -**Preconditions**: Valid session cookie; current time before `absolute_expires_at`. - -**Main Flow** (cookie rotation): -1. SPA leader tab calls `POST /auth/refresh` at the server-supplied (jittered) `refresh_at`. -2. BFF reads the cookie value `old_sid`, fetches `bff:session:{old_sid}`. -3. BFF generates a fresh `new_sid` (CSPRNG, ≥128 bits) and computes `new_exp = min(now + session_ttl, absolute_expires_at)`. -4. BFF runs a single MULTI/EXEC pipeline: write `bff:swap:{old_sid} → new_sid` (PX = `grace_ms`); rename `bff:session:{old_sid}` to `bff:session:{new_sid}` and update `expires_at` + Redis TTL; replace ZSET entry in `bff:user_sessions:{user_id}`; replace SET entry in `bff:sid_index:{iss}:{idp_sid}`; `DEL router:jwt_cache:{old_sid}`. -5. BFF re-issues the cookie with `new_sid` and `Max-Age = new_exp − now`. Body: `{expires_at: new_exp, refresh_at: jittered}`. -6. SPA leader broadcasts the result to follower tabs via `BroadcastChannel`/`localStorage`; followers do not fire their own refresh. - -**Postconditions**: Cookie value rotated. Old `bff:session:{old_sid}` is gone; `bff:swap:{old_sid}` lives for `grace_ms`. ZSET score, key TTL, and IdP-sid index all reference the new `session_id`. - -**Alternative Flows**: -- **Stale cookie within grace window**: BFF resolves `bff:swap:{old_sid} → new_sid`, returns `200` with `Set-Cookie new_sid`; **no** further rotation. Used when a sibling tab fires `/auth/refresh` between the leader's call and broadcast. -- **Stale cookie past grace window**: BFF returns `401` and clears the cookie. SPA redirects to `/auth/login`. -- **Past absolute cap**: `EXPIREAT` with a past timestamp evicts the key; the SPA's next request returns 401. - -#### Log Out Everywhere - -- [ ] `p1` - **ID**: `cpt-insightspec-usecase-bff-logout-everywhere` - -**Actor**: `cpt-insightspec-actor-browser-user` - -**Main Flow**: -1. User triggers "log out everywhere" in the SPA. -2. SPA calls `DELETE /auth/sessions`. -3. BFF reads `bff:user_sessions:{user_id}` (`ZRANGEBYSCORE 0 +inf`), deletes every `bff:session:{sid}`, deletes the sorted set, instructs the Router to drop matching `router:jwt_cache:{sid}` entries. -4. BFF clears the current cookie. Audit events recorded for each session. - -**Postconditions**: All cookies still in browsers point to nonexistent sessions; next request from any device returns 401. Within one gateway-JWT TTL, all in-flight requests fail. - -**Alternative Flows**: -- **Admin-initiated**: Tenant Admin calls the same operation against a target user; permission check enforces admin scope. - -#### Back-Channel Logout - -- [ ] `p1` - **ID**: `cpt-insightspec-usecase-bff-back-channel-logout` - -**Actor**: `cpt-insightspec-actor-oidc-provider` - -**Main Flow**: -1. IdP terminates a user's IdP session and POSTs a `logout_token` to `/auth/oidc/back-channel-logout`. -2. BFF validates the logout token (signature, `iss`, `aud`, `iat`, `events` claim). -3. BFF resolves `(iss, sid)` (or `sub`) to the matching session(s) and revokes them. - -**Postconditions**: User's session is gone. Next browser request returns 401. - -## 9. Acceptance Criteria - -- [ ] `cpt-insightspec-fr-bff-oidc-login`, `cpt-insightspec-fr-bff-session-cookie`: After login, no IdP token is present in any cookie, header, or response body delivered to the browser. The only auth artifact in the browser is the opaque session cookie with `__Host-`, `HttpOnly`, `Secure`, `SameSite=Strict`, and a `Max-Age` matching the configured session TTL. -- [ ] `cpt-insightspec-fr-bff-session-refresh`: Without `/auth/refresh` calls, a session expires after `session_ttl` seconds regardless of `/api/*` activity. With periodic refresh, sessions live until the absolute cap. -- [ ] `cpt-insightspec-fr-bff-session-store`: Every active session appears at `bff:session:{id}` and as a member of `bff:user_sessions:{user_id}` with score = `expires_at`. A revocation removes both atomically. The janitor reduces drift to zero on each pass. -- [ ] `cpt-insightspec-fr-bff-session-list`, `cpt-insightspec-fr-bff-session-revoke`: A user can list their active sessions (only entries with score > now) and revoke one, all-but-current, or all. After "revoke all", every device returns 401 within one gateway-JWT TTL. -- [ ] `cpt-insightspec-fr-bff-gateway-jwt`: The Router-issued gateway JWT carries exactly `iss`, `aud`, `sub`, `iat`, `exp`, `jti`, `tid`, `sid` -- nothing else. Signed with EdDSA. Verifiable by downstream services against the JWKS URL configured in their Helm values. -- [ ] `cpt-insightspec-fr-bff-logout`: Local logout, RP-initiated logout, and back-channel logout all converge on session deletion plus user-index cleanup. -- [ ] `cpt-insightspec-fr-bff-csrf`: State-changing `/auth/*` requests without a valid CSRF token or matching `Origin` are rejected with 403. -- [ ] `cpt-insightspec-nfr-bff-https-only`: No HTTP request reaches application code; HSTS is set on every response. -- [ ] `cpt-insightspec-nfr-bff-session-ttl`: Operator can set `session_ttl` and `absolute_lifetime` via Helm values; values within the documented ranges take effect on rolling restart. - -## 10. Dependencies - -| Dependency | Description | Criticality | -|------------|-------------|-------------| -| Redis | Session records and user-sessions sorted set | `p1` | -| Customer OIDC provider | Authentication (auth-code + PKCE), RP-initiated logout, back-channel logout | `p1` | -| Identity Service | Map IdP `sub` to internal `user_id` and tenant | `p1` | -| Audit Service | Sink for auth events | `p1` | -| Ingress / TLS terminator | HTTPS termination, HSTS, request routing | `p1` | -| Router (sibling) | Gateway JWT minting, JWKS, `/api/*` reverse proxy | `p1` | - -## 11. Assumptions - -- The customer OIDC provider supports authorization code + PKCE, RP-initiated logout, and back-channel logout. (Refresh-token support is not required in v1; the BFF does not call the IdP refresh endpoint.) -- The SPA and BFF are served from the same registrable domain (first-party cookies). -- The SPA schedules `/auth/refresh` from the server-supplied (jittered) `refresh_at`, coordinates a single leader tab via `BroadcastChannel` / `localStorage` so siblings do not fire parallel refreshes, and handles 401 by redirecting to `/auth/login`. -- Redis is deployed in HA mode; session loss requires re-login for affected users -- acceptable. - -## 12. Risks - -| Risk | Impact | Mitigation | -|------|--------|------------| -| SPA stops calling `/auth/refresh` (bug, throttled tab) | User is logged out mid-flow | Default TTL of 120 s with 60 s refresh cadence gives 60 s of slack; document recommended cadence; SPA must handle 401 cleanly | -| Redis outage | All users effectively logged out; logins blocked | HA Redis; degraded mode policy deferred to DESIGN | -| BFF on the auth-critical path | Single point of failure for all UI traffic | Stateless horizontal scaling; readiness probes; ingress retries | -| `SameSite=Strict` breaks deep links from external sites | User lands logged out when following email/Slack links | Documented behavior; fall back to `Lax` only if UX requires it | -| Janitor falls behind | `bff:user_sessions:*` accumulates expired entries | Metric on backlog size; alert when above threshold; pass interval is shorter than session TTL | -| Back-channel logout endpoint abuse | Spoofed logout tokens trigger session revocation | Strict OIDC `logout_token` validation: signature, `iss`, `aud`, `iat`, `events`. `jti` replay protection via `bff:logout_jti:{iss}:{jti}` SET-NX with TTL ≥ `iat + max_clock_skew`. | -| `logout_token` without `sid` widens blast radius | A misconfigured IdP that omits `sid` causes every back-channel logout to behave as "log out everywhere" for the named `sub` | Runbook callout; operator-facing log line on every `(iss, sub)`-only fallback so the pattern is detectable | -| User-sessions index drift | Index lists sessions that no longer exist (or vice versa) | Atomic ops via MULTI/EXEC pipeline; janitor reconciles | -| Multi-tab without coordination | Sibling tabs fire parallel `/auth/refresh`, one wins, the others miss the grace window and 401 → unnecessary user logout | SPA contract requires `BroadcastChannel`/`localStorage` leader election; grace window absorbs the rare residual race | -| Sophisticated attacker stays in sync with rotation | Stolen cookie remains usable as long as the attacker races every refresh | Server-side jitter on `refresh_at` raises timing-attack bar; rate limits on `/auth/*` raise volumetric noise; future option: revoke-all-on-stale-token-past-grace | diff --git a/docs/components/backend/api-gateway/router/DESIGN.md b/docs/components/backend/api-gateway/router/DESIGN.md deleted file mode 100644 index b5e1ba600..000000000 --- a/docs/components/backend/api-gateway/router/DESIGN.md +++ /dev/null @@ -1,841 +0,0 @@ ---- -status: proposed -date: 2026-04-28 ---- - -# DESIGN -- API Gateway Router - -- [ ] `p3` - **ID**: `cpt-insightspec-design-router` - - - -- [1. Architecture Overview](#1-architecture-overview) - - [1.1 Architectural Vision](#11-architectural-vision) - - [1.2 Architecture Drivers](#12-architecture-drivers) - - [1.3 Architecture Layers](#13-architecture-layers) -- [2. Principles & Constraints](#2-principles--constraints) - - [2.1 Design Principles](#21-design-principles) - - [2.2 Constraints](#22-constraints) -- [3. Technical Architecture](#3-technical-architecture) - - [3.1 Domain Model](#31-domain-model) - - [3.2 Component Model](#32-component-model) - - [3.3 API Contracts](#33-api-contracts) - - [3.4 Internal Dependencies](#34-internal-dependencies) - - [3.5 External Dependencies](#35-external-dependencies) - - [3.6 Interactions & Sequences](#36-interactions--sequences) - - [3.7 Database schemas & tables](#37-database-schemas--tables) - - [3.8 Route Configuration Schema](#38-route-configuration-schema) - - [3.9 Redis Keys (read-only and JWT cache)](#39-redis-keys-read-only-and-jwt-cache) - - [3.10 Boundary with the BFF](#310-boundary-with-the-bff) -- [4. Cross-Cutting Concerns](#4-cross-cutting-concerns) - - [4.1 Caching](#41-caching) - - [4.2 Failure Handling](#42-failure-handling) - - [4.3 Observability](#43-observability) -- [5. Design Decisions](#5-design-decisions) - - [DD-ROUTER-01: Same Pod as BFF](#dd-router-01-same-pod-as-bff) - - [DD-ROUTER-02: ConfigMap Routes Over Service Discovery](#dd-router-02-configmap-routes-over-service-discovery) - - [DD-ROUTER-03: Redis-backed JWT Cache (not in-memory)](#dd-router-03-redis-backed-jwt-cache-not-in-memory) - - [DD-ROUTER-04: arc-swap for Route Table](#dd-router-04-arc-swap-for-route-table) - - [DD-ROUTER-05: Gateway Does Basic Auth Checks Only](#dd-router-05-gateway-does-basic-auth-checks-only) - - [DD-ROUTER-06: No Request Body Size Limits in v1](#dd-router-06-no-request-body-size-limits-in-v1) - - [DD-ROUTER-07: WebSocket JWT Frozen at Upgrade Time, Bounded by Max Lifetime](#dd-router-07-websocket-jwt-frozen-at-upgrade-time-bounded-by-max-lifetime) - - [DD-ROUTER-08: Header Strip List = Hardcoded + Config](#dd-router-08-header-strip-list--hardcoded--config) - - [DD-ROUTER-09: JWT Cache Size Cap](#dd-router-09-jwt-cache-size-cap) - - [DD-ROUTER-10: Cache Fill on Miss Uses `SET … NX EX`](#dd-router-10-cache-fill-on-miss-uses-set--nx-ex) -- [6. Traceability](#6-traceability) - - - ---- - -## 1. Architecture Overview - -### 1.1 Architectural Vision - -The Router is the hot path of the API Gateway. Every browser request to `/api/*` goes through it and nothing else. It does five things in order on every request: - -1. Match the path to a route. -2. Read the session from Redis. -3. Get a gateway JWT (from cache or fresh mint). -4. Rewrite headers. -5. Stream to the upstream and back. - -It is stateless, hot-reloadable, and built on the same `axum` + `hyper` stack as the BFF. It shares the same process, the same Redis client, and the same ModKit framework. It does not duplicate session logic -- it links to the BFF's session manager as a library. - -### 1.2 Architecture Drivers - -#### Functional Drivers - -| Requirement | Design Response | -|---|---| -| `cpt-insightspec-fr-router-session-validate` | Read-only access to BFF's session manager; Redis hit on every request unless cookie is absent | -| `cpt-insightspec-fr-router-jwt-mint` | EdDSA signer + Redis-backed cache `jwt_cache:{sid}`, TTL ≤ 60 s | -| `cpt-insightspec-fr-router-route-resolve` | In-memory longest-prefix trie rebuilt from ConfigMap | -| `cpt-insightspec-fr-router-proxy` | `hyper` reverse proxy with body streaming and WebSocket upgrade support | -| `cpt-insightspec-fr-router-header-rewrite` | Single `RequestRewriter` middleware; whitelist for cookies and headers | -| `cpt-insightspec-fr-router-jwks` | Static handler over the public keys held by `KeyStore` | -| `cpt-insightspec-fr-router-config-load` | Schema-validated YAML deserialization at startup; readiness gate | -| `cpt-insightspec-fr-router-config-reload` | K8s API watch; atomic swap via `arc-swap` | -| `cpt-insightspec-fr-router-key-rotation` | Same `KeyStore` watching the signing-key Secret; JWKS overlap | - -#### NFR Allocation - -| NFR | Component | Verification | -|---|---|---| -| `cpt-insightspec-nfr-router-latency` | Cache-first JWT, in-memory route table, no extra hops | Load test, p95 measured at the gateway | -| `cpt-insightspec-nfr-router-cache-hit` | 60 s JWT cache + per-session keying | Metric `router_jwt_mint_total{cache="hit"}` | -| `cpt-insightspec-nfr-router-reload-time` | K8s watch + atomic swap | Integration test: write ConfigMap, time first request hitting new route | -| `cpt-insightspec-nfr-router-fail-closed` | Readiness probe checks Redis + key presence + non-empty route table | Kill dependencies; verify 503 + not-ready | - -### 1.3 Architecture Layers - -```mermaid -graph TB - subgraph Browser - SPA[React SPA] - end - - subgraph Ingress - ING[Ingress
TLS] - end - - subgraph GW["API Gateway pod"] - BFF["BFF module
/auth/* + session lifecycle"] - ROUTER["Router module
/api/* + JWKS"] - SM["Session Manager (lib)
shared by BFF and Router"] - KS["Key Store
signing keys"] - RT["Route Table
arc-swap of trie"] - end - - subgraph State - REDIS[(Redis)] - CM[K8s ConfigMap
routes.yaml] - SECRET[K8s Secret
signing keys] - end - - subgraph DS[Downstream Services] - S1[Analytics API] - S2[Connector Manager] - S3[Identity Service] - SX[...] - end - - SPA --> ING - ING --> BFF - ING --> ROUTER - BFF --> SM - ROUTER --> SM - SM --> REDIS - ROUTER --> KS - ROUTER --> RT - KS --> SECRET - RT --> CM - ROUTER -->|Bearer JWT| S1 - ROUTER -->|Bearer JWT| S2 - ROUTER -->|Bearer JWT| S3 - ROUTER -->|Bearer JWT| SX - S1 -.JWKS.-> ROUTER - S2 -.JWKS.-> ROUTER - S3 -.JWKS.-> ROUTER - SX -.JWKS.-> ROUTER -``` - -| Layer | Responsibility | Technology | -|---|---|---| -| Edge | TLS termination, HSTS | K8s Ingress | -| Routing | Path-prefix match, hot reload | `axum` Router + `arc-swap` | -| Auth read | Cookie validation via shared session manager | `modkit-auth` (BFF-owned) | -| Crypto | EdDSA signing + JWKS | `jsonwebtoken` + K8s Secret watch | -| Proxy | Body streaming, WebSocket upgrade | `hyper` | - -- [ ] `p3` - **ID**: `cpt-insightspec-tech-router` - -## 2. Principles & Constraints - -### 2.1 Design Principles - -#### Hot path stays small - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-router-hot-path` - -Every per-request operation is in-memory or one Redis call. No database, no external HTTP except the proxy hop itself, no serialization beyond JWT signing. - -#### Read-only on session state - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-router-read-only-sessions` - -The Router never writes to `session:*` or `user_sessions:*`. Sliding-TTL updates and refresh handling are the BFF's job. This keeps the boundary clean and makes Router behavior predictable. - -#### Hot reload, never restart for config - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-router-hot-reload` - -Adding a service or rotating a key must not require a redeploy. The Router watches K8s objects and swaps state atomically. - -#### Reject before forwarding - -- [ ] `p2` - **ID**: `cpt-insightspec-principle-router-reject-early` - -Rejection (404 unmatched, 401 no session, 503 not-ready) happens before any upstream call. Internal services see only valid, signed traffic. - -### 2.2 Constraints - -#### Same-pod with the BFF - -- [ ] `p2` - **ID**: `cpt-insightspec-constraint-router-same-pod` - -The Router runs in the same Rust binary as the BFF. It links the BFF's session manager as a library, not over RPC. Splitting the modules into separate processes is not supported in v1. - -#### Routes loaded from K8s ConfigMap - -- [ ] `p2` - **ID**: `cpt-insightspec-constraint-router-configmap` - -Route configuration lives in a single K8s ConfigMap watched by the pod. No service discovery, no Consul, no service mesh. Adding a service requires a ConfigMap edit. - -## 3. Technical Architecture - -### 3.1 Domain Model - -The Router holds no business entities. The runtime objects it owns are: - -| Entity | Purpose | Storage | -|---|---|---| -| `RouteTable` | In-memory longest-prefix trie of routes | `arc-swap` in-process | -| `SigningKey` | EdDSA key pair (current + optional previous) | K8s Secret + in-process cache | -| `JwtCacheEntry` | Last minted gateway JWT for a session | Redis `router:jwt_cache:{sid}` | - -It reads the BFF-owned `Session` entity ([BFF DESIGN §3.1](../bff/DESIGN.md#31-domain-model)) read-only. - -### 3.2 Component Model - -```mermaid -graph LR - subgraph Router - ROUTE[Route Matcher
arc-swap trie] - AUTH[Cookie Auth Middleware] - MINT[JWT Minter + Cache] - REWRITE[Request Rewriter] - PROXY[Hyper Proxy] - JWKS[JWKS Handler] - CFGW[ConfigMap Watcher] - KEYW[Secret Watcher] - KS[Key Store] - end - - subgraph Shared - SM[Session Manager
BFF-owned lib] - end - - AUTH --> SM - MINT --> KS - MINT -.cache.-> REDIS[(Redis)] - SM --> REDIS - CFGW --> ROUTE - KEYW --> KS - JWKS --> KS - BFF[(BFF revoke flow)] -.DEL router:jwt_cache:sid.-> REDIS - - ROUTE --> AUTH - AUTH --> MINT - MINT --> REWRITE - REWRITE --> PROXY -``` - -#### Route Matcher - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-matcher` - -##### Why this component exists -First gate on every request. Without it, no other component knows which upstream to forward to. - -##### Responsibility scope -Holds an `arc-swap`. Performs longest-prefix match. Returns matched route or 404. - -##### Responsibility boundaries -Does not enforce auth. Does not call upstreams. Does not parse cookies. - -##### Related components (by ID) -- `cpt-insightspec-component-router-cfgwatcher` -- supplies the table. -- `cpt-insightspec-component-router-auth` -- runs after match. - -#### Cookie Auth Middleware - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-auth` - -##### Why this component exists -The Router cannot mint a JWT for a non-user. This middleware is the gate that ensures every forwarded request is tied to a valid session. - -##### Responsibility scope -Reads `__Host-sid` cookie, calls `SessionManager::lookup`, attaches the session record to the request extensions or returns 401. - -##### Responsibility boundaries -No write operations on session state. Does not handle CSRF -- `SameSite=Strict` covers `/api/*`; CSRF tokens are a `/auth/*` concern owned by the BFF. - -##### Related components (by ID) -- `cpt-insightspec-component-bff-session-manager` -- read-only consumer of this BFF-owned library. -- `cpt-insightspec-component-router-jwt-minter` -- runs next, with the resolved session in hand. - -#### JWT Minter + Cache - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-jwt-minter` - -##### Why this component exists -Internal services must receive a fresh, signed identity claim per request without round-tripping to a central authz service. This component produces that claim. - -##### Responsibility scope -On each request, fetch `router:jwt_cache:{sid}` from Redis. On miss, build claims (`iss`, `aud`, `sub`, `tid`, `sid`, `iat`, `exp`, `jti`) from the session record, sign with `KeyStore.current`, then `SET router:jwt_cache:{sid} NX EX `. If `NX` returns `nil` (a parallel request already filled the cache), re-`GET` and serve the winner's JWT instead of the freshly-minted one. See DD-ROUTER-10. - -##### Responsibility boundaries -Does not refresh IdP access tokens. Does not validate JWTs (downstream services do that). Does not include `lic`, `roles`, or `scopes` -- those are not part of the v1 contract. - -##### Related components (by ID) -- `cpt-insightspec-component-router-keystore` -- supplies the signing key. -- `cpt-insightspec-component-bff-auth-controller` -- invalidates this cache by `DEL router:jwt_cache:{sid}` on session revoke (shared Redis, same MULTI/EXEC pipeline as the rest of the BFF revoke). - -#### Request Rewriter - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-rewriter` - -##### Why this component exists -Browser-supplied `Authorization` headers and gateway-internal cookies must never reach internal services. This middleware is the boundary. - -##### Responsibility scope -Strip browser `Authorization` and gateway-reserved cookies. Strip any client-supplied `X-Correlation-Id` and regenerate it as a fresh UUID v7. Inject `Authorization: Bearer ...`, the regenerated `X-Correlation-Id`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`. Apply operator-configured `strip_request_headers`. Apply `strip_prefix` if the route says so. - -##### Responsibility boundaries -Does not modify request body. Does not modify response headers (apart from stripping reserved `Set-Cookie`). Does not enforce header allowlists. - -##### Related components (by ID) -- `cpt-insightspec-component-router-jwt-minter` -- supplier of the JWT to inject. -- `cpt-insightspec-component-router-proxy` -- next stage. - -#### Hyper Proxy - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-proxy` - -##### Why this component exists -The terminal stage of the request path -- everything else feeds into the upstream call. - -##### Responsibility scope -Open the upstream connection (pooled `hyper::Client`), stream request body, await response, stream response body back. Enforce `timeout_ms`. Handle WebSocket upgrade for routes flagged `websocket: true`. For each upgraded socket, enforce the route's effective max lifetime: per-route `websocket_max_lifetime_seconds` if present, otherwise the global `gateway.websocket_max_lifetime_seconds` (see DD-ROUTER-07) -- close the socket with a normal-closure code when the deadline is reached. Each open WebSocket is registered in an in-process table keyed by `(route_prefix, socket_id)` so the ConfigMap Watcher can close sockets bound to a removed route on hot reload. - -##### Responsibility boundaries -No retries. No payload transformation. No per-tenant rate limiting -- that's the ingress and per-service middleware. - -##### Related components (by ID) -- `cpt-insightspec-component-router-rewriter` -- previous stage. - -#### JWKS Handler - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-jwks` - -##### Why this component exists -Internal services need a stable, cacheable URL to fetch the public keys used to verify gateway JWTs. - -##### Responsibility scope -Serve `GET /.well-known/jwks.json` from the `KeyStore` snapshot. Set `Cache-Control: public, max-age=3600`. - -##### Responsibility boundaries -Stateless read-only handler. Does not authenticate clients (the endpoint is public by design). - -##### Related components (by ID) -- `cpt-insightspec-component-router-keystore` -- source of the keys. - -#### ConfigMap Watcher - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-cfgwatcher` - -##### Why this component exists -Adding a new internal service or changing a route's timeout must not require a redeploy. This watcher applies ConfigMap changes hot. - -##### Responsibility scope -Watch the route ConfigMap via the K8s API. On change: parse + validate, then **diff against the previous table**: - -1. Build the new `RouteTable`. -2. Compute the set of route prefixes that disappeared (removed entirely or whose `upstream` changed). -3. Atomically swap the live `RouteTable` via `arc-swap`. -4. For every removed/changed prefix, walk the Hyper Proxy's open-WebSocket registry and close every socket whose `(route_prefix)` matches with a normal-closure code. Clients reconnect against the new table. - -If validation fails, keep the old table, emit an alert, do not touch active sockets. - -##### Responsibility boundaries -Does not match routes itself (that's the matcher). Does not validate signing keys (separate watcher). Does not close HTTP requests on reload -- they hold an `Arc` to the previous table for their lifetime, which is bounded by per-route `timeout_ms`. - -##### Related components (by ID) -- `cpt-insightspec-component-router-matcher` -- consumer of the table it builds. -- `cpt-insightspec-component-router-proxy` -- maintains the open-WebSocket registry the watcher walks on reload. - -#### Key Store - -- [ ] `p2` - **ID**: `cpt-insightspec-component-router-keystore` - -##### Why this component exists -Signing keys must be rotatable without downtime. This component holds and swaps them safely. - -##### Responsibility scope -Hold `current` and optional `previous` EdDSA keys. Provide signing handles to the JWT minter. Provide public-key view to JWKS. Reload on Secret change. Refuse to start if no keys are present. - -##### Responsibility boundaries -Does not run rotation policy itself -- the operator triggers rotation by editing the Secret. - -##### Related components (by ID) -- `cpt-insightspec-component-router-jwt-minter` -- consumer of signing keys. -- `cpt-insightspec-component-router-jwks` -- consumer of public keys. - -> **Note on cache invalidation**: There is no Router-side subscriber. The Router and BFF share the same Redis instance, so the BFF's revoke flow performs `DEL router:jwt_cache:{sid}` directly inside the same MULTI/EXEC pipeline that drops the session record. No Redpanda, no in-process callback, no eventual-consistency window beyond the single Redis round-trip. - -### 3.3 API Contracts - -The Router exposes the **Reverse Proxy** and **JWKS** interfaces declared in [PRD §7.1](./PRD.md#71-public-api-surface) (`cpt-insightspec-interface-router-proxy`, `cpt-insightspec-interface-router-jwks`). It owns the contracts declared in PRD §7.2 (`cpt-insightspec-contract-router-gateway-jwt`, `cpt-insightspec-contract-router-config`). - -| Path | Implementation | -|---|---| -| `GET /.well-known/jwks.json` | `JWKS Handler` over `Key Store` | -| `ANY /api/**` | Route Matcher → Cookie Auth → JWT Minter → Request Rewriter → Hyper Proxy | - -### 3.4 Internal Dependencies - -| Dependency | Interface | Purpose | -|---|---|---| -| BFF Session Manager (sibling) | Rust crate | Read-only session validation; no RPC | -| BFF Auth Controller | Shared Redis | Invalidates `router:jwt_cache:{sid}` directly inside the BFF revoke MULTI/EXEC pipeline. No RPC, no Redpanda. | -| Audit Service | Redpanda producer | Emit config-reload, key-rotation, suspicious-event audit records | - -### 3.5 External Dependencies - -| System | Protocol | Purpose | -|---|---|---| -| Redis | RESP (TCP/TLS) | Session reads (`bff:session:*`), JWT cache (`router:jwt_cache:*`) | -| K8s API | watch | ConfigMap and Secret hot reload | -| Downstream services | HTTP/1.1 + HTTP/2 + WebSocket | Targets of `/api/*` forwarding | - -### 3.6 Interactions & Sequences - -#### Request flow (cache hit) - -**ID**: `cpt-insightspec-seq-router-request-hit` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant R as Router - participant RD as Redis - participant S as Upstream Service - - U->>R: GET /api/analytics/... (cookie __Host-sid) - R->>R: Route Matcher → upstream - R->>RD: HMGET bff:session:{sid} - RD-->>R: session - R->>RD: GET router:jwt_cache:{sid} - RD-->>R: - R->>R: rewrite headers - R->>S: GET /api/... + Bearer - S-->>R: response (streamed) - R-->>U: response -``` - -#### Request flow (cache miss) - -**ID**: `cpt-insightspec-seq-router-request-miss` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant R as Router - participant RD as Redis - participant K as Key Store - participant S as Upstream Service - - U->>R: GET /api/... - R->>RD: HMGET bff:session:{sid} - RD-->>R: session (user_id, tenant_id) - R->>RD: GET router:jwt_cache:{sid} - RD-->>R: nil - R->>K: get current signing key - K-->>R: (kid, secret) - R->>R: build claims (iss, aud, sub, tid, sid, iat, exp, jti)
sign EdDSA - R->>RD: SET router:jwt_cache:{sid} NX EX - alt NX OK (we won) - Note over R,RD: Use the JWT we just minted. - else NX nil (someone else won) - R->>RD: GET router:jwt_cache:{sid} - RD-->>R: - Note over R,RD: Serve the winner's JWT, discard our own.
One canonical JWT per (sid, cache window).
See DD-ROUTER-10. - end - R->>S: request + Bearer - S-->>R: response - R-->>U: response -``` - -#### Config reload - -**ID**: `cpt-insightspec-seq-router-config-reload` - -```mermaid -sequenceDiagram - autonumber - participant K8s as K8s API - participant W as ConfigMap Watcher - participant V as Validator - participant T as Route Table (arc-swap) - participant P as Hyper Proxy
(WS registry) - participant HTTP as Live HTTP handlers - - K8s-->>W: ConfigMap changed - W->>V: parse + validate new YAML - alt valid - V-->>W: ok (new RouteTable, removed_prefixes) - W->>T: store(new) - Note over HTTP: New requests use new table.
In-flight HTTP requests keep their existing
match (bounded by route timeout_ms). - loop for each prefix in removed_prefixes - W->>P: close all sockets registered for prefix - P-->>W: closed N sockets (normal-closure) - end - Note over P: Clients reconnect; they either land on the
renamed/replaced upstream or get a 404. - else invalid - V-->>W: errors - W->>W: emit alert, keep old table - Note over P: WebSocket sweep is NOT performed on
validation failure -- existing sockets stay open. - end -``` - -#### Signing key rotation - -**ID**: `cpt-insightspec-seq-router-key-rotate` - -```mermaid -sequenceDiagram - autonumber - participant Op as Operator (GitOps) - participant K8s as K8s API - participant W as Secret Watcher - participant KS as Key Store - participant J as JWKS - participant RD as Redis - participant DS as Downstream - - Op->>K8s: update Secret bff-signing-keys
(promote new → current, demote → previous) - K8s-->>W: Secret changed - W->>KS: load(current, previous) - KS->>J: publish both kids - KS->>RD: DEL all router:jwt_cache:* (FLUSH on rotation) - Note over RD: Optional but recommended.
Cache size cap (see DD-ROUTER-09)
also bounds the residue. - Note over DS: On unknown kid in token,
refetch JWKS, accept either. - Note over KS: Overlap window MUST be ≥
jwt_max_ttl + downstream_jwks_max_age
= 300 s + 3600 s ≈ 65 minutes
before the operator removes 'previous'. - Op->>K8s: update Secret (drop previous) - K8s-->>W: Secret changed - W->>KS: load(current only) - KS->>J: publish current only -``` - -**Overlap window math.** A downstream service caches JWKS up to `Cache-Control: max-age=3600` (1 h). After `previous` is removed from the Router's JWKS, a downstream service that has not yet refetched JWKS still has the old `kid` in its in-process cache and continues to verify old-key tokens. But a downstream service that *did* refetch -- because it saw an unknown `kid` from the new key -- now has only the new key, and any cached gateway JWT still signed with `previous` (TTL ≤ 60 s) is rejected. Worst-case window the operator runbook **MUST** wait before removing `previous` is therefore: - -```text -overlap_min = gateway.jwt_ttl_seconds (≤ 300) + downstream JWKS max-age (3600) - ≈ 65 minutes -``` - -`gateway.websocket_max_lifetime_seconds` (default 3600 s, see DD-ROUTER-07) is *not* an additional addend here -- WebSocket connections retain the JWT minted at upgrade and never re-verify against fresh JWKS, so they are unaffected by JWKS-cache eviction. They are bounded separately by their own lifetime cap. - -**Optional but recommended on rotation.** Flushing `router:jwt_cache:*` (a single Redis-side `SCAN + UNLINK` or, if a future DD-ROUTER-09 caps cache size, just clearing the cap'd structure) eliminates the residue of JWTs signed under the previous key. Cost: a transient mint storm for active sessions, mitigated by the `SET ... NX` cache-fill from DD-ROUTER-10. - -#### Cache busting on session revoke - -**ID**: `cpt-insightspec-seq-router-cache-bust` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant B as BFF - participant RD as Redis (shared) - participant R as Router - - U->>B: DELETE /auth/sessions/{sid} - B->>RD: HMGET bff:session:{sid} user_id idp_iss idp_sid
MULTI
DEL bff:session:{sid}
ZREM bff:user_sessions:{uid} sid
SREM bff:sid_index:{iss}:{idp_sid} sid
DEL router:jwt_cache:{sid}
EXEC - RD-->>B: OK - Note over R,RD: Next /api/* request for {sid}
finds router:jwt_cache:{sid} missing,
but bff:session:{sid} also missing,
so Cookie Auth returns 401 first. -``` - -### 3.7 Database schemas & tables - -This module's "database" is Redis, shared with the BFF. The Router reads keys defined and owned by the BFF (see [BFF DESIGN §3.7](../bff/DESIGN.md#37-database-schemas--tables)) and writes one key family of its own. The full layout is in §3.9 below. - -### 3.8 Route Configuration Schema - -- [ ] `p2` - **ID**: `cpt-insightspec-design-router-config-schema` - -This section is the technical specification for the contract `cpt-insightspec-contract-router-config` declared in [PRD §7.2](./PRD.md#72-external-integration-contracts). ConfigMap key: `routes.yaml`. - -```yaml -version: 1 -defaults: - timeout_ms: 30000 - strip_prefix: false - websocket: false - # Operator-extensible deny-list of request headers. The hardcoded - # gateway-reserved set (Authorization, X-Correlation-Id, - # X-Forwarded-*, gateway cookies) is always stripped in addition. - strip_request_headers: - - X-Real-IP - - Forwarded -routes: - - prefix: /api/v1/analytics - upstream: http://analytics.insight.svc.cluster.local:8080 - timeout_ms: 60000 - strip_prefix: false - - - prefix: /api/v1/connectors - upstream: http://connector-manager.insight.svc.cluster.local:8080 - - - prefix: /api/v1/identity - upstream: http://identity-service.insight.svc.cluster.local:8080 - - - prefix: /api/v1/identity-resolution - upstream: http://identity-resolution.insight.svc.cluster.local:8080 - - - prefix: /api/v1/transforms - upstream: http://transform-service.insight.svc.cluster.local:8080 - - - prefix: /api/v1/alerts - upstream: http://alerts-service.insight.svc.cluster.local:8080 - - - prefix: /api/v1/audit - upstream: http://audit-service.insight.svc.cluster.local:8080 - - - prefix: /api/v1/stream - upstream: http://analytics.insight.svc.cluster.local:8080 - websocket: true - timeout_ms: 0 - # Per-route override for the global gateway.websocket_max_lifetime_seconds. - # Tighter ceiling for high-sensitivity streams; bounds post-revoke staleness. - websocket_max_lifetime_seconds: 600 -``` - -Validation rules (enforced on load and on every reload): - -- `version` must be a known schema version. -- `prefix` unique across the table. -- `prefix` must start with `/api/`. -- `upstream` must be a valid URL with hostname and port. -- `timeout_ms ≥ 0`. `0` only allowed when `websocket: true`. -- No two routes share an exact prefix. -- `strip_request_headers` entries must be valid HTTP header names; reserved gateway headers (`Authorization`, `X-Correlation-Id`, `X-Forwarded-*`, gateway cookies) **MUST NOT** appear in this list -- they are stripped unconditionally. -- `websocket_max_lifetime_seconds` (per-route) is permitted only when `websocket: true`. Must be `>= 30` and `<=` the global `gateway.websocket_max_lifetime_seconds`. Falls back to the global value if absent. - -### 3.9 Redis Keys (read-only and JWT cache) - -The Router reads keys defined and owned by the BFF; see [BFF DESIGN §3.7](../bff/DESIGN.md#37-database-schemas--tables). It writes only to one key family of its own (`router:jwt_cache:*`). - -| Key | Type | Owner | Router access | -|---|---|---|---| -| `bff:session:{sid}` | HASH | BFF | read | -| `bff:user_sessions:{user_id}` | ZSET (score = `expires_at`) | BFF | none on the hot path | -| `bff:sid_index:{iss}:{idp_sid}` | SET | BFF | none | -| `bff:login_state:{state}` | HASH | BFF | none | -| `router:jwt_cache:{sid}` | STRING | Router | read + write | - -`router:jwt_cache:{sid}` value is the full signed JWT, TTL = `min(60, jwt_remaining)`. The BFF deletes these keys as part of session-revoke flows so revocations propagate within one TTL. - -### 3.10 Boundary with the BFF - -| Concern | Owner | Notes | -|---|---|---| -| OIDC handshake | BFF | Router never talks to the IdP | -| Session create / extend / revoke | BFF | Router calls only `SessionManager::lookup` | -| Cookie issue / clear | BFF | Router never sets cookies | -| CSRF token issue | BFF | Router enforces nothing CSRF-related on `/api/*`; CSRF is a BFF concern on `/auth/*`. State-changing `/api/*` calls rely on `SameSite=Strict`. | -| Refresh of IdP access token | BFF | Router does not see IdP tokens | -| Gateway JWT mint + sign | Router | Was in BFF DESIGN; ownership moves here | -| JWKS publication | Router | Was in BFF DESIGN; ownership moves here | -| Reverse proxy | Router | Was in BFF DESIGN; ownership moves here | -| Session manager library | BFF | Used by Router as a Rust crate; no network call | - -A note on the parent BFF DESIGN: the JWT minter, JWT cache, JWKS endpoint, and reverse proxy described there are now implemented in the Router. The BFF DESIGN's claim schema (section 3.8) and key-rotation diagram still describe the contract; ownership is what changes. - -## 4. Cross-Cutting Concerns - -### 4.1 Caching - -Three caches, all bounded: - -- **Route table**: in-process `arc-swap`; replaced atomically on ConfigMap change. -- **JWT cache**: Redis, TTL ≤ 60 s, keyed by session ID. -- **JWKS at downstream services**: 1 h TTL with kid-driven refresh on miss. - -No per-request cache for sessions (they change too often -- TTL slides on use). - -### 4.2 Failure Handling - -| Failure | Behavior | -|---|---| -| No cookie | 401, no upstream call | -| Cookie present but session not in Redis | 401 + clear cookie | -| Redis unreachable | 503, readiness probe fails | -| ConfigMap missing or invalid at startup | Pod stays unready | -| ConfigMap update invalid at runtime | Keep old table, emit alert | -| Signing key Secret missing at startup | Pod stays unready | -| `current` key removed at runtime | Refuse to mint; 503 + alert | -| Upstream connection refused | 502 | -| Upstream timeout | 504 with `Retry-After` | -| Upstream 5xx | Pass through | -| WebSocket upgrade target dead | 502 | - -### 4.3 Observability - -Metrics: - -- `router_request_total{route, status}` -- `router_request_duration_seconds{route}` (histogram) -- `router_jwt_mint_total{cache="hit"|"miss"}` -- `router_jwt_mint_duration_seconds` -- `router_session_lookup_duration_seconds` -- `router_config_reload_total{result="ok"|"invalid"}` -- `router_key_rotation_total` -- `router_route_count` (gauge) - -Logs (structured JSON): one line per request with `correlation_id`, route prefix, upstream, status, duration, cache result. Never log cookies, JWTs, or session IDs in clear -- session ID hashed if needed. - -Audit (via Audit Service): config reload (with diff), key rotation, JWKS fetch failures from downstream (treated as suspicious). - -## 5. Design Decisions - -### DD-ROUTER-01: Same Pod as BFF - -**Context**: Could deploy the Router as a separate Deployment in front of the BFF. - -**Decision**: One pod, one binary, two modules. - -**Why**: -- Avoids a network hop for `/auth/*` routing. -- Lets the Router link to the session manager as a library, not over RPC. -- One signing-key Secret, one set of metrics, one log stream. - -**Consequences**: Scaling is coupled (BFF and Router scale together). Acceptable -- BFF is also stateless. - -### DD-ROUTER-02: ConfigMap Routes Over Service Discovery - -**Context**: Could use Consul, K8s service discovery + label selectors, or a service mesh. - -**Decision**: Explicit YAML route table in a ConfigMap. - -**Why**: -- Tiny number of internal services (<20). Discovery is overkill. -- Reviewable in Git, validated on load, easy to audit. -- No new runtime dependency. - -**Consequences**: New services need a ConfigMap edit. Documented in operator runbook. - -### DD-ROUTER-03: Redis-backed JWT Cache (not in-memory) - -**Context**: Could cache minted JWTs per process in memory. - -**Decision**: Cache in Redis under `jwt_cache:{sid}`. - -**Why**: -- Multi-pod deployment -- in-memory cache hit rate degrades with replicas. -- Cache invalidation is a single `DEL` from the BFF on shared Redis -- works regardless of replica count, reaches every pod's view immediately, no per-pod fan-out. -- Cache miss cost is one EdDSA sign (~50 µs), so even with no cache the system would work; Redis cache mainly cuts pressure under bursts. - -**Consequences**: One extra Redis call per request. Measured at <1 ms p99, well inside the latency budget. - -### DD-ROUTER-04: arc-swap for Route Table - -**Context**: Need atomic, lock-free reads of the route table on every request. - -**Decision**: `arc_swap::ArcSwap`. - -**Why**: -- Lock-free reads -- the hot path never blocks. -- `store` is atomic, so no half-applied table. -- Old table is reclaimed once all in-flight requests release their `Arc`. - -**Consequences**: In-flight requests may finish under the previous table; that's the desired behavior. - -### DD-ROUTER-05: Gateway Does Basic Auth Checks Only - -**Decision**: The Router validates that the request has a valid session and a freshly-minted gateway JWT. It does **not** check license tier, roles, scopes, or tenant access. Those are downstream-service responsibilities. - -**Why**: -- Authorization belongs next to the data. Each downstream service already enforces RBAC and visibility against its own model -- duplicating it at the gateway would create two sources of truth. -- The JWT carries `sub` and `tid`. Anything richer (roles, license, scopes) is intentionally absent from the v1 claim contract; see [BFF DESIGN §3.8](../bff/DESIGN.md#38-gateway-jwt-claim-contract). -- The gateway must stay a thin, fast hot-path component. Adding policy here adds latency and a redeploy surface for every authz change. - -**Consequences**: A user with no permission for a feature still reaches the downstream service, which returns 403. That's the right place for the decision. - -### DD-ROUTER-06: No Request Body Size Limits in v1 - -**Decision**: The Router does not enforce `max_body_bytes`. No per-route upload caps. - -**Why**: -- v1 has no end-user upload features. CSV exports are downloads (response body), not uploads. Connector configuration payloads are tiny. -- Adding a knob with no real consumer is YAGNI and risks misconfiguration that silently breaks a future feature. - -**Consequences**: When an upload feature lands, this gets revisited as a normal config addition. - -### DD-ROUTER-07: WebSocket JWT Frozen at Upgrade Time, Bounded by Max Lifetime - -**Decision**: A WebSocket connection carries the JWT minted at upgrade. The Router does **not** re-mint or re-inject during the connection's lifetime. To bound the post-revoke staleness window, the Router enforces a configurable **max socket lifetime** with a global default and a per-route override: - -- Global default: Helm value `gateway.websocket_max_lifetime_seconds`, default `3600` = 1 hour. -- Per-route override: `websocket_max_lifetime_seconds` in the route entry of the ConfigMap (see §3.8). Must be `>= 30` and `<=` the global value. Used for high-sensitivity streams (admin operations, live pipeline events) that need a tighter ceiling than the dashboard default. - -When the effective deadline is reached, the Router closes the socket with a normal-closure code; the client reconnects and re-authenticates, picking up the current session state (or 401 if the session is gone). Open WebSocket connections are also closed by the ConfigMap Watcher when their matched route is removed or its upstream changes (see §3.6 Config reload). - -**Why**: -- v1 has no plan for in-band JWT refresh on a live socket -- complicates the protocol, complicates client code, and the only real benefit is faster claim freshness which the JWT TTL bound (≤ 300 s) already covers for non-WebSocket traffic. -- Downstream services are inside the trust boundary -- a stale `tid`/`sub` on a long-lived socket is not a security concern in itself, and authorization is enforced against current data on each operation anyway. -- A bounded max lifetime caps the worst case for sockets opened just before a revoke. Without it, a malicious or buggy client could hold the connection open indefinitely after offboarding. -- Implementing the cap in the **Router** keeps the policy in one place. The **BFF** still owns session revoke; the Router does not need to subscribe to revoke events for this control to work. - -**Consequences**: -- Worst-case post-revoke window for an open socket = `websocket_max_lifetime_seconds`. Default (1 h) is acceptable for analytics dashboard streams; tighten via Helm if a deployment needs faster turnaround. -- Clients on long-lived sockets see a connection close roughly every hour and **MUST** reconnect cleanly. This is documented in the SPA WebSocket client guidelines. -- A revocation-triggered disconnect is **not** part of v1. If a future deployment needs near-zero post-revoke staleness on sockets, the design path is: BFF publishes `(sid)` on a Redis pub/sub channel inside the revoke MULTI/EXEC pipeline (still shared Redis, still no Redpanda), and the Router subscribes per-pod and closes any socket bound to that `sid`. That mechanism is deferred until a real driver appears. - -### DD-ROUTER-08: Header Strip List = Hardcoded + Config - -**Decision**: The Router strips two categories of headers before forwarding: - -1. **Hardcoded gateway-reserved**: `Authorization` (always replaced with the minted JWT), `X-Correlation-Id` (always set by the gateway), `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, plus the gateway's session and CSRF cookies (`__Host-sid`, CSRF cookie). -2. **Operator-configured**: an explicit list in the route ConfigMap (`defaults.strip_request_headers`) that callers want stripped for cluster hygiene (e.g. `X-Real-IP`, `Forwarded`, anything that conflicts with internal conventions). - -Everything else passes through. - -**Why**: -- Hardcoded list covers the security-critical headers that must never be operator-removable (a misconfig that lets a browser-supplied `Authorization` reach a downstream service is a breach). -- Config list lets operators harden the deployment without a code change. -- A pure allowlist would force a config update for every new debugging header used by an internal service -- friction with no real safety gain inside the trust boundary. - -**Consequences**: New downstream services can rely on any non-reserved header passing through. The config-driven strip list is reviewed at deploy time alongside the route table. - -### DD-ROUTER-09: JWT Cache Size Cap - -**Decision**: `router:jwt_cache:*` is bounded both by per-entry TTL (≤ 60 s) and by an upper bound on total active session count enforced through Redis `maxmemory-policy=allkeys-lru` on the Redis instance (or a dedicated logical DB if the operator wants strict isolation). The Router does not maintain its own LRU. - -**Why**: -- An upper bound on cache size prevents an attacker who can rapidly create sessions from inflating the JWT cache to memory pressure. -- Relying on Redis eviction policy is operationally simple. The cache is a true cache -- losing entries only costs an EdDSA sign on the next request. -- Per-key TTL alone is not enough to bound steady-state memory if active session count grows. - -**Consequences**: Operators must size the Redis instance for `(active_sessions × avg_jwt_size) + (active_sessions × avg_session_record_size)` plus headroom; `allkeys-lru` evicts cache entries before session records when the JWT cache key has shorter idle time, which is the desired behaviour. - -### DD-ROUTER-10: Cache Fill on Miss Uses `SET … NX EX` - -**Decision**: On JWT cache miss, the Router fills with `SET router:jwt_cache:{sid} NX EX `. On `nil` return (someone else won the race), the Router re-`GET`s and serves the winner's JWT instead of using its own freshly-minted one. - -**Why**: -- A SPA opening N parallel API calls right after login otherwise causes N parallel cache misses, N EdDSA signs, and N `SETEX` writes (last-writer-wins). All but one of the minted JWTs are immediately stale in the cache, each with a unique `jti`. -- `SET ... NX` is atomic at Redis. Cost of the conflict path is one extra `GET`. Cost of the happy path is unchanged. -- Removes a future foot-gun: a `jti` denylist (out of scope today, but plausible) would have to track every minted JWT regardless of cache outcome. With NX-fill there is one canonical JWT per session per cache window. - -**Consequences**: Single canonical cached JWT per session per cache window. Transient `SET-NX` collisions visible in metrics on traffic bursts; expected and benign. - -## 6. Traceability - -- **PRD**: [PRD.md](./PRD.md) -- **Sibling**: [BFF PRD](../bff/PRD.md), [BFF DESIGN](../bff/DESIGN.md) -- session lifecycle, OIDC, gateway JWT schema (3.8), Redis data model (3.7) -- **Parent**: [Backend PRD](../../specs/PRD.md), [Backend DESIGN](../../specs/DESIGN.md) -- **ADRs**: [ADR/](./ADR/) -- to be authored alongside implementation. Decisions captured inline as DD-ROUTER-01..10 in §5 until then. diff --git a/docs/components/backend/api-gateway/router/PRD.md b/docs/components/backend/api-gateway/router/PRD.md deleted file mode 100644 index 23df05150..000000000 --- a/docs/components/backend/api-gateway/router/PRD.md +++ /dev/null @@ -1,479 +0,0 @@ ---- -status: proposed -date: 2026-04-28 ---- - -# PRD -- API Gateway Router - - - -- [1. Overview](#1-overview) - - [1.1 Purpose](#11-purpose) - - [1.2 Background / Problem Statement](#12-background--problem-statement) - - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) - - [1.4 Glossary](#14-glossary) -- [2. Actors](#2-actors) - - [2.1 Human Actors](#21-human-actors) - - [2.2 System Actors](#22-system-actors) -- [3. Operational Concept & Environment](#3-operational-concept--environment) - - [3.1 Module-Specific Environment Constraints](#31-module-specific-environment-constraints) -- [4. Scope](#4-scope) - - [4.1 In Scope](#41-in-scope) - - [4.2 Out of Scope](#42-out-of-scope) -- [5. Functional Requirements](#5-functional-requirements) - - [5.1 Session Validation](#51-session-validation) - - [5.2 Gateway JWT Mint and Cache](#52-gateway-jwt-mint-and-cache) - - [5.3 Route Resolution](#53-route-resolution) - - [5.4 Reverse Proxy](#54-reverse-proxy) - - [5.5 Header Rewriting](#55-header-rewriting) - - [5.6 JWKS Publication](#56-jwks-publication) - - [5.7 Config Management](#57-config-management) - - [5.8 Signing Key Rotation](#58-signing-key-rotation) -- [6. Non-Functional Requirements](#6-non-functional-requirements) - - [6.1 NFR Inclusions](#61-nfr-inclusions) - - [6.2 NFR Exclusions](#62-nfr-exclusions) -- [7. Public Library Interfaces](#7-public-library-interfaces) - - [7.1 Public API Surface](#71-public-api-surface) - - [7.2 External Integration Contracts](#72-external-integration-contracts) -- [8. Use Cases](#8-use-cases) -- [9. Acceptance Criteria](#9-acceptance-criteria) -- [10. Dependencies](#10-dependencies) -- [11. Assumptions](#11-assumptions) -- [12. Risks](#12-risks) -- [13. Related Documents](#13-related-documents) - - - -## 1. Overview - -### 1.1 Purpose - -The Router is the part of the API Gateway that handles every non-auth request. It validates the session cookie, mints (or fetches from cache) a short-lived gateway JWT carrying the caller's identity, then forwards the request to the right internal service over plain HTTP. - -It also publishes the JWKS used by internal services to verify those JWTs, and owns the route table that maps URL prefixes to upstream services. - -The Router is the sibling of the BFF inside the API Gateway. The BFF owns the session; the Router uses it. See [BFF PRD](../bff/PRD.md). - -### 1.2 Background / Problem Statement - -The BFF creates and stores user sessions, but it does not forward requests on its own. Requests to `/api/*` need to be: - -1. Tied to a real user via the session cookie. -2. Stamped with a short-lived, signed identity token so internal services can authorize without trusting the network. -3. Routed to the correct service based on URL prefix. -4. Reconfigured without redeployment when a new service is added or a route changes. - -Doing this in the BFF would mix browser session concerns with cluster routing concerns. Splitting them gives a small, focused, hot-path component (Router) and a larger session-aware component (BFF) with their own change cadences. - -### 1.3 Goals (Business Outcomes) - -- Add no more than 15 ms p95 latency between the browser and the internal service. -- Make every internal service receive a fresh, verifiable identity claim per request. -- Allow operators to add or change routes via config -- no code change, no full restart. -- Rotate signing keys without downtime. - -### 1.4 Glossary - -| Term | Definition | -|---|---| -| Session cookie | Opaque cookie issued by the BFF (`__Host-sid`). Read-only from the Router's point of view. | -| Session record | Server-side state in Redis, owned by the BFF. The Router only reads it. | -| Gateway JWT | Short-lived JWT signed by the Router and consumed by internal services. Same token described in the BFF DESIGN; ownership moves here. | -| Route | A mapping `path-prefix → upstream-service-base-url` plus per-route options. | -| Route table | The full set of routes loaded from config. | -| JWKS | Public keys served at `/.well-known/jwks.json` for downstream services to verify gateway JWTs. | - -## 2. Actors - -### 2.1 Human Actors - -#### Operator - -**ID**: `cpt-insightspec-actor-operator` - -**Role**: Platform engineer who deploys and configures Insight on the customer cluster. -**Needs**: Add a new internal service to the route table, change a route's timeout, rotate signing keys -- all without writing Rust. - -### 2.2 System Actors - -#### BFF (sibling component) - -**ID**: `cpt-insightspec-actor-bff` - -**Role**: Owns the session record. The Router reads sessions through the shared session manager interface owned by the BFF. - -#### Browser User - -**ID**: `cpt-insightspec-actor-browser-user` - -**Role**: Already defined in the BFF PRD. From the Router's perspective, the browser is the source of cookies and forwarded requests. - -#### Downstream Service - -**ID**: `cpt-insightspec-actor-downstream-service` - -**Role**: Receiver of forwarded requests with the gateway JWT. Verifies the JWT against JWKS and applies its own RBAC. - -#### Redis - -**ID**: `cpt-insightspec-actor-redis` - -**Role**: Read-only access to `bff:session:*`; read/write on `router:jwt_cache:*`. The Router never writes session records. - -## 3. Operational Concept & Environment - -### 3.1 Module-Specific Environment Constraints - -- Same process and pod as the BFF -- the API Gateway is one binary with two modules. Single ingress entry, single TLS endpoint. -- Stateless. Any pod can serve any request. Hot path uses Redis only. -- Route config and signing keys are loaded from K8s ConfigMap and Secret on startup, then watched for changes. - -## 4. Scope - -### 4.1 In Scope - -- Cookie-based session validation against Redis. -- Gateway JWT mint, signing (EdDSA), and per-session caching in Redis. -- Route table loading from ConfigMap. -- Hot reload of route config and signing keys without restart. -- HTTP reverse proxy (request and response streaming, including chunked + WebSocket upgrades). -- Header rewriting (strip browser-supplied auth, inject `Authorization: Bearer ...`, always strip and regenerate `X-Correlation-Id` as UUID v7). -- JWKS endpoint with key rotation overlap. -- Per-route timeout enforcement. -- Health and readiness probes for K8s. - -### 4.2 Out of Scope - -- OIDC handshake, session creation, session revocation, logout -- handled by the [BFF](../bff/PRD.md). -- Per-tenant rate limiting -- handled by the surrounding ingress and per-service middleware (see parent backend NFR `cpt-insightspec-nfr-be-rate-limiting`). -- M2M API -- future -- Public M2M API for services -- future -- Service discovery (Consul, mesh) -- routes are explicit, ConfigMap-driven. -- Authorization decisions (role / scope / tenant filtering) -- every downstream service does its own. - -## 5. Functional Requirements - -### 5.1 Session Validation - -#### Cookie-Based Session Validation - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-session-validate` - -For every request matched by the route table, the system **MUST** read the session cookie and look up the BFF-owned key `bff:session:{id}` in Redis. The request **MUST** be rejected with 401 if the cookie is missing, malformed, expired, or not present in Redis. The cookie value **MUST NOT** be logged or echoed back in any response, header, or log line. - -The system **MUST NOT** modify session state. All writes (refresh, revoke) belong to the BFF. - -**Rationale**: The Router cannot mint a JWT for a non-user. It also cannot duplicate session lifecycle code -- that belongs to the BFF. - -**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-redis` - -### 5.2 Gateway JWT Mint and Cache - -#### EdDSA-Signed Gateway JWT Per Request - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-jwt-mint` - -For every forwarded request, the system **MUST** attach a gateway JWT signed with the current EdDSA key. The JWT **MUST**: - -- Carry exactly the claims defined in the contract: required JWT claims `iss`, `aud`, `sub`, `iat`, `exp`, `jti`; Insight custom claims `tid`, `sid`. No `lic` / `roles` / `scopes` in v1. See [BFF DESIGN §3.8](../bff/DESIGN.md#38-gateway-jwt-claim-contract). -- Have `exp - iat` between 60 and 300 seconds. - -The system **MUST** cache the minted JWT in Redis (`router:jwt_cache:{session_id}`) with TTL = `min(60s, jwt_remaining)`. Cache hits **MUST** skip the signing step. - -The cache **MUST** be invalidated by the BFF deleting `router:jwt_cache:{sid}` on shared Redis as part of the session-revoke MULTI/EXEC pipeline. The Router itself runs no subscriber and uses no event stream for this purpose. v1 has no other invalidation source -- the JWT carries only `sub`, `tid`, and `sid`, none of which change during an active session. - -**Rationale**: A signed JWT per request is the zero-trust contract. Caching keeps mint cost low under load. Direct Redis DEL by the BFF makes revoke-driven invalidation a single Redis round-trip with no eventual-consistency window. - -**Actors**: `cpt-insightspec-actor-downstream-service`, `cpt-insightspec-actor-redis` - -### 5.3 Route Resolution - -#### Longest-Prefix Route Match - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-route-resolve` - -The system **MUST** resolve every incoming request path to one upstream service using a longest-prefix match against the route table. Unmatched paths **MUST** return 404 without contacting any upstream. - -Each route entry **MUST** specify at minimum: - -- `prefix` (path prefix to match). -- `upstream` (base URL of the internal service). -- `timeout_ms` (per-request timeout). -- `strip_prefix` (boolean). -- `websocket` (boolean). - -**Rationale**: Explicit routes are simpler and easier to audit than service discovery for a fixed-deployment product. - -**Actors**: `cpt-insightspec-actor-operator` - -### 5.4 Reverse Proxy - -#### Streaming Reverse Proxy - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-proxy` - -The system **MUST** forward the matched request to the resolved upstream and stream the response back. Body streaming **MUST** be supported in both directions to keep memory bounded for large CSV exports and uploads. WebSocket upgrades **MUST** be supported on routes flagged `websocket: true`. - -The per-route `timeout_ms` **MUST** be enforced on upstream connect, write, and idle read. - -**Rationale**: Analytics exports and pipeline status streams need streaming. Browser-to-backend WebSockets are needed for live dashboard updates. - -**Actors**: `cpt-insightspec-actor-downstream-service` - -### 5.5 Header Rewriting - -#### Strip + Inject on Forward - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-header-rewrite` - -Before forwarding, the system **MUST** strip headers in two categories and pass everything else through: - -**Hardcoded gateway-reserved (always stripped, then re-set by the gateway)**: -- `Authorization` -- replaced with `Bearer `. -- `X-Correlation-Id` -- always stripped from the incoming request and regenerated as UUID v7 by the gateway. Client-supplied values **MUST NOT** be propagated; this prevents browser-supplied values from poisoning correlation logs across tenants. -- `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host` -- set by the gateway per RFC. -- Gateway-reserved cookies (`__Host-sid`, CSRF cookie) -- stripped from `Cookie` header. - -**Operator-configured (stripped only)**: any header name listed in `defaults.strip_request_headers` in the route ConfigMap. Reserved gateway header names **MUST NOT** appear in this list (validation rejects the config). - -Response headers **MUST** be passed through with no modification except for stripping any `Set-Cookie` that uses a reserved cookie name. - -**Rationale**: Browser-supplied `Authorization` headers must never reach internal services -- only the gateway's signed JWT does. Operator-configurable strip list lets deployments harden header hygiene without code changes; security-critical strips stay hardcoded so a misconfig cannot expose them. - -**Actors**: `cpt-insightspec-actor-downstream-service` - -### 5.6 JWKS Publication - -#### JWKS Endpoint - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-jwks` - -The system **MUST** serve `GET /.well-known/jwks.json` returning the current and previous public verification keys with stable `kid` values. The response **MUST** include `Cache-Control: public, max-age=3600`. - -**Rationale**: Internal services verify gateway JWTs using JWKS and cache the result. Stable `kid` values let them refresh on unknown `kid` only. - -**Actors**: `cpt-insightspec-actor-downstream-service` - -### 5.7 Config Management - -#### ConfigMap Load and Validation - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-config-load` - -The system **MUST** load route configuration from a K8s ConfigMap on startup and validate it against a schema (unique prefixes, valid URLs, sane timeouts). Validation failure **MUST** prevent the service from becoming ready -- never start with a partially valid table. - -#### Atomic Hot Reload - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-config-reload` - -The system **MUST** detect ConfigMap changes and apply the new route table without restart. Reload **MUST** be atomic -- no request **MUST** see a half-applied table. If the new config fails validation, the running config **MUST** be retained and an alert emitted. - -In-flight requests **MUST** continue to use the route they were matched against; only new requests use the updated table. - -**Rationale**: Adding a new internal service should be a config push, not a redeploy. Failed reloads must never break a running gateway. - -**Actors**: `cpt-insightspec-actor-operator` - -### 5.8 Signing Key Rotation - -#### Hot Key Rotation with Overlap - -- [ ] `p1` - **ID**: `cpt-insightspec-fr-router-key-rotation` - -The system **MUST** load EdDSA signing keys from a K8s Secret with at least `current` and optional `previous` entries. The system **MUST** detect Secret changes and apply them without restart. JWKS **MUST** publish both keys when both are present so downstream services can verify tokens minted under the old key during the overlap window. - -The system **MUST** sign new JWTs only with `current`. - -**Rationale**: Operators must be able to rotate keys without a deployment. - -**Actors**: `cpt-insightspec-actor-operator`, `cpt-insightspec-actor-downstream-service` - -## 6. Non-Functional Requirements - -### 6.1 NFR Inclusions - -#### Latency Budget - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-router-latency` - -Router overhead per forwarded request (cookie validate + JWT mint-or-cache + proxy hop) **MUST** be ≤ 15 ms p95 under nominal load (1k rps per pod). - -**Threshold**: 15 ms p95 added latency. - -#### JWT Cache Hit Rate - -- [ ] `p2` - **ID**: `cpt-insightspec-nfr-router-cache-hit` - -Under sustained traffic, the gateway-JWT cache hit rate **MUST** be ≥ 80%. - -**Threshold**: ≥ 80% hits over a 5-minute window for any session active for more than 60 s. - -#### Config Reload Time - -- [ ] `p2` - **ID**: `cpt-insightspec-nfr-router-reload-time` - -A valid ConfigMap change **MUST** take effect within 30 s of being written. - -**Threshold**: 30 s p95 from ConfigMap update to first request using new route. - -#### Fail-Closed Behavior - -- [ ] `p1` - **ID**: `cpt-insightspec-nfr-router-fail-closed` - -If Redis is unreachable, signing keys are missing, or the route table is empty, the Router **MUST** return 503 for `/api/*` and report not-ready to K8s. It **MUST NOT** serve requests with stale keys, no JWT, or guessed routes. - -**Threshold**: Zero requests forwarded without a valid session and a valid signed JWT. - -### 6.2 NFR Exclusions - -- **Per-tenant rate limiting**: Inherited from parent backend NFR; ingress and per-service middleware handle it. -- **Distributed tracing**: Inherited as out-of-scope from the parent backend PRD; correlation_id only. - -## 7. Public Library Interfaces - -### 7.1 Public API Surface - -#### Reverse Proxy - -- [ ] `p1` - **ID**: `cpt-insightspec-interface-router-proxy` - -**Type**: HTTP reverse proxy - -**Stability**: stable - -**Description**: Any `/api/**` path matching a configured route is forwarded to its upstream with `Authorization: Bearer ` injected. - -#### JWKS - -- [ ] `p1` - **ID**: `cpt-insightspec-interface-router-jwks` - -**Type**: REST endpoint - -**Stability**: stable - -**Description**: `GET /.well-known/jwks.json` -- public keys for gateway JWT verification. See [BFF DESIGN section 3.8](../bff/DESIGN.md) for the JWT schema. - -### 7.2 External Integration Contracts - -#### Gateway JWT Contract - -- [ ] `p1` - **ID**: `cpt-insightspec-contract-router-gateway-jwt` - -**Direction**: provided by Router, consumed by every downstream service. - -**Format**: same schema as `cpt-insightspec-contract-bff-gateway-jwt`. Ownership of the contract moves from BFF to Router with this PRD. - -#### Route Configuration Contract - -- [ ] `p1` - **ID**: `cpt-insightspec-contract-router-config` - -**Direction**: required from operator. - -**Format**: YAML in a K8s ConfigMap. Schema validated on load and reload. - -**Compatibility**: Additive fields permitted in any minor version. Removing or renaming a field requires a major version bump. - -## 8. Use Cases - -#### Forwarding an Authenticated Request - -- [ ] `p1` - **ID**: `cpt-insightspec-usecase-router-forward` - -**Actor**: `cpt-insightspec-actor-browser-user` - -**Preconditions**: Browser has a valid session cookie issued by the BFF. - -**Main Flow**: -1. Browser sends `GET /api/analytics/...` with cookie. -2. Router matches the path prefix to an upstream. -3. Router validates the cookie against `bff:session:{id}` in Redis (read-only). -4. Router reads `router:jwt_cache:{sid}`. On miss, mints a new JWT and caches it. -5. Router rewrites headers and forwards to the upstream. -6. Router streams the response back. - -**Postconditions**: Downstream service received a request with a fresh signed JWT. Browser got the response. - -**Alternative Flows**: -- **No / bad / expired cookie**: 401, no upstream call. -- **Path matches no route**: 404, no upstream call. -- **Upstream timeout**: 504 with retry-after. - -#### Adding a New Internal Service - -- [ ] `p2` - **ID**: `cpt-insightspec-usecase-router-add-route` - -**Actor**: `cpt-insightspec-actor-operator` - -**Main Flow**: -1. Operator edits the gateway ConfigMap, adds a new route entry pointing to the new service's ClusterIP URL. -2. Operator commits and applies via ArgoCD. -3. Router detects the ConfigMap change, validates the new table. -4. Router atomically swaps the active route table. -5. Next request to the new prefix is forwarded to the new service. - -**Postconditions**: New route live, no pod restart, no in-flight request affected. - -**Alternative Flows**: -- **Validation fails**: Old table stays active, alert fires, operator fixes the YAML. - -#### Rotating Signing Keys - -- [ ] `p1` - **ID**: `cpt-insightspec-usecase-router-rotate-key` - -**Actor**: `cpt-insightspec-actor-operator` - -**Main Flow**: -1. Operator generates a new EdDSA key pair. -2. Operator updates the K8s Secret: previous current → `previous`, new key → `current`. -3. Router reloads keys; JWKS now serves both. -4. New JWTs are signed with the new key. Existing JWTs (≤300 s old) verify against `previous`. -5. After overlap window (≥ JWT max TTL + downstream JWKS cache TTL), operator removes `previous` from the Secret. - -**Postconditions**: All gateway JWTs are signed with the new key; old key is gone. - -## 9. Acceptance Criteria - -- [ ] `cpt-insightspec-fr-router-session-validate`, `cpt-insightspec-fr-router-jwt-mint`: Every request reaching a downstream service carries a freshly-signed JWT verifiable against `/.well-known/jwks.json`. No request reaches downstream without a valid session. -- [ ] `cpt-insightspec-fr-router-route-resolve`, `cpt-insightspec-fr-router-proxy`: Adding a new route via ConfigMap and waiting ≤30 s makes the new service reachable through the gateway with no restart. -- [ ] `cpt-insightspec-fr-router-header-rewrite`: An incoming request with a forged `Authorization` header reaches downstream with that header replaced by the gateway JWT, never preserved. -- [ ] `cpt-insightspec-fr-router-jwks`, `cpt-insightspec-fr-router-key-rotation`: Key rotation completes with overlap; downstream services accept tokens minted by either key during the overlap window and only the new key after the previous key is removed. -- [ ] `cpt-insightspec-nfr-router-latency`: Load test shows ≤15 ms p95 router overhead at 1k rps per pod. -- [ ] `cpt-insightspec-nfr-router-fail-closed`: Killing Redis returns 503 with not-ready probe; never serves a request with no JWT or stale data. - -## 10. Dependencies - -| Dependency | Description | Criticality | -|---|---|---| -| BFF (sibling) | Owner of session creation/destruction; provides the session manager library used by the Router | `p1` | -| Redis | Session reads + JWT cache | `p1` | -| K8s ConfigMap | Route table source | `p1` | -| K8s Secret | Signing keys source | `p1` | -| Downstream services | Targets of forwarded requests | `p1` | - -## 11. Assumptions - -- BFF and Router are deployed in the same pod (same process, separate modules). -- All internal services trust the Router as the only legitimate issuer of gateway JWTs. -- Operator workflow is GitOps -- ConfigMap and Secret changes flow through Git → ArgoCD; no out-of-band kubectl edits. -- The route table is small (tens of entries), so longest-prefix lookup is in-memory and fast. - -## 12. Risks - -| Risk | Impact | Mitigation | -|---|---|---| -| Bad ConfigMap reload silently breaks routes | Some routes return 404 unexpectedly | Strict schema validation on load and reload; retain previous table on validation failure; emit alert | -| JWT cache stampede during signing-key rotation | Mint-rate spike when all caches are invalidated together | Stagger cache invalidation per session; signing is fast (EdDSA), but verify under load | -| Misconfigured `strip_prefix` exposes wrong path to upstream | Downstream service returns 404 or, worse, hits the wrong handler | Integration tests per route; alert on sustained 4xx from a route after change | -| Header rewrite bug leaks browser `Authorization` downstream | Internal service accepts a forged identity | Snapshot tests on outbound headers; fuzz tests with malicious cookie/header combinations | -| Redis blip blocks all `/api/*` traffic | Whole product unavailable | Inherits BFF mitigation: HA Redis and fail-closed behavior; no degraded-read mode (see BFF DD-BFF-06) | -| Operator deletes `current` without overlap | All gateway JWTs invalid until reload | Documented runbook; admission-controller-style validation on the Secret if feasible | -| Operator removes `previous` signing key before overlap window elapses | Cached JWTs signed with `previous` are rejected by downstream services that already refetched JWKS | Runbook enforces minimum overlap = `jwt_ttl + downstream_jwks_max_age` ≈ 65 min. Optional `router:jwt_cache:*` flush on rotation eliminates residue. See key-rotation flow in DESIGN §3.6. | -| WebSocket revocation lag | Sessions revoked while a WS is open continue receiving traffic up to `websocket_max_lifetime_seconds`. Default 1 h global ceiling can be too lax for high-sensitivity streams. | Per-route `websocket_max_lifetime_seconds` override in the route ConfigMap (see DESIGN §3.8); tighten globally via Helm; future revocation-triggered disconnect via shared Redis pub/sub if needed (see DD-ROUTER-07). | -| ConfigMap reload leaves WS connections on a removed route | Decommissioned upstream keeps receiving traffic via still-open WebSockets up to the lifetime cap | ConfigMap Watcher walks the open-WebSocket registry on reload and closes sockets whose matched route was removed or had its upstream changed. See DESIGN §3.6 Config reload. | - -## 13. Related Documents - -- [BFF PRD](../bff/PRD.md) -- session creation, OIDC, logout, CSRF, refresh -- [BFF DESIGN](../bff/DESIGN.md) -- session storage model, gateway JWT schema (section 3.8) -- [Backend PRD](../../specs/PRD.md) -- parent platform PRD -- [Backend DESIGN](../../specs/DESIGN.md) -- parent platform DESIGN diff --git a/docs/components/backend/authenticator/DESIGN.md b/docs/components/backend/authenticator/DESIGN.md new file mode 100644 index 000000000..bf8b632b1 --- /dev/null +++ b/docs/components/backend/authenticator/DESIGN.md @@ -0,0 +1,1014 @@ +--- +status: proposed +date: 2026-07-06 +--- + +# DESIGN -- Authenticator Service + +- [ ] `p3` - **ID**: `cpt-insightspec-design-auth` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Database schemas & tables](#37-database-schemas--tables) + - [3.8 Gateway JWT Claim Contract](#38-gateway-jwt-claim-contract) + - [3.9 Configuration Surface](#39-configuration-surface) + - [3.10 Gear Anatomy](#310-gear-anatomy) +- [4. Cross-Cutting Concerns](#4-cross-cutting-concerns) + - [4.1 Cookie Hardening](#41-cookie-hardening) + - [4.2 CSRF Defense](#42-csrf-defense) + - [4.3 Janitor and Leader Election](#43-janitor-and-leader-election) + - [4.4 Rate Limiting](#44-rate-limiting) + - [4.5 Key Rotation](#45-key-rotation) + - [4.6 Bootstrap Guardrails](#46-bootstrap-guardrails) + - [4.7 Observability](#47-observability) +- [5. Design Decisions](#5-design-decisions) + - [Carried over from the deleted API Gateway specs](#carried-over-from-the-deleted-api-gateway-specs) + - [Superseded decisions](#superseded-decisions) + - [DD-AUTH-01: JWT Minted at Login, Linked 1:1 to the Session](#dd-auth-01-jwt-minted-at-login-linked-11-to-the-session) + - [DD-AUTH-02: Session Identity / Credential Split](#dd-auth-02-session-identity--credential-split) + - [DD-AUTH-03: Background IdP Token Refresh](#dd-auth-03-background-idp-token-refresh) + - [DD-AUTH-04: Tenants and Roles in the JWT](#dd-auth-04-tenants-and-roles-in-the-jwt) + - [DD-AUTH-05: Service Tokens via RFC 7523 Assertions and a Public-Key Registry](#dd-auth-05-service-tokens-via-rfc-7523-assertions-and-a-public-key-registry) + - [DD-AUTH-06: Two Listeners for Two Internal Surfaces](#dd-auth-06-two-listeners-for-two-internal-surfaces) + - [DD-AUTH-07: Access-Control Claims Fetched Once, at Login](#dd-auth-07-access-control-claims-fetched-once-at-login) + - [DD-AUTH-08: Empty-Table First-Admin Bootstrap plus INSTALLER](#dd-auth-08-empty-table-first-admin-bootstrap-plus-installer) + - [OPEN: EdDSA vs ES256 for the Gateway JWT](#open-eddsa-vs-es256-for-the-gateway-jwt) +- [6. Traceability](#6-traceability) + + + +--- + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The authenticator is the BFF half of the deleted API Gateway spec, kept as a standalone service, minus proxying, plus the two things the deleted Router owned that are auth (not proxy) work: the cookie-to-JWT exchange and JWKS publication. nginx (see [Gateway DESIGN](../gateway/DESIGN.md)) does the routing; the authenticator answers its `auth_request` subrequests. + +Three deliberate changes against the old BFF spec shape the design. First, the gateway JWT is **born at login together with the session** and stored linked 1:1 to it -- not minted lazily per request; the hot path serves a stored token and reissues it ahead of expiry. Second, the session's **identity is split from its credential**: a stable `session_id` (UUIDv7) keys everything server-side, while the cookie value is a rotating mapping to it -- rotation is one write plus one expiring key. Third, **IdP tokens are refreshed in the background**, so a session can never outlive the IdP's willingness to vouch for the user. + +The authenticator is a plain HTTP service: no proxying, no K8s API access, no streaming -- deliberately small and testable, because it is the security-critical core. It is an idiomatic gears-rust toolkit gear (see 3.10), stateless across pods, with all state in Redis and background workers behind a Redis leader lock. The `auth_request` pattern (Envoy `ext_authz`, Traefik `forwardAuth` are the same contract) keeps it edge-agnostic: if nginx is ever replaced, the authenticator survives untouched. + +### 1.2 Architecture Drivers + +#### Functional Drivers + +| Requirement | Design Response | +|---|---| +| `cpt-insightspec-fr-auth-oidc-login` | Confidential OIDC client with PKCE; IdP tokens stored in the session record only; session-fixation revoke on callback | +| `cpt-insightspec-fr-auth-session-model` | Stable `session_id` (UUIDv7) keys everything; `asm:token:{token}` mapping is the only rotating artifact | +| `cpt-insightspec-fr-auth-session-cookie` | `__Host-`-prefixed opaque cookie, TTL 600 s default, set on `/auth/callback` | +| `cpt-insightspec-fr-auth-session-refresh` | New mapping write + old-mapping grace TTL; `refresh_at` with 90 s margin and 120 s jitter window | +| `cpt-insightspec-fr-auth-session-store` | Redis keyspace in 3.7: session HASH, token mapping, linked JWT, ZSET index, sid index, refresh-due ZSET; MULTI/EXEC pipelines | +| `cpt-insightspec-fr-auth-linked-jwt` | JWT Issuer mints at login; exchange serves stored JWT; reissue-ahead with `SET NX EX` (stampede-safe) | +| `cpt-insightspec-fr-auth-authz-exchange` | `GET /internal/authz`: two Redis reads on the hot path; `Cache-Control` computed per response | +| `cpt-insightspec-fr-auth-jwks` | Key Store over a mounted secret; static JWKS handler | +| `cpt-insightspec-fr-auth-session-list`, `cpt-insightspec-fr-auth-session-revoke` | ZSET index reads; one revoke pipeline shared by logout, back-channel, admin, and `invalid_grant` paths | +| `cpt-insightspec-fr-auth-logout` | Local + RP-initiated + back-channel receiver with `jti` replay guard | +| `cpt-insightspec-fr-auth-csrf` | Double-submit token bound to the session + `Origin` allowlist | +| `cpt-insightspec-fr-auth-idp-refresh` | Leader-elected refresher over `asm:idp_refresh_due`; per-session rotation lock; fail-open transport / fail-closed verdict | +| `cpt-insightspec-fr-auth-service-tokens` | Token listener + RFC 7523 verification against the registry; same signer, same JWKS | +| `cpt-insightspec-fr-auth-bootstrap` | Empty-table guard in the login path; INSTALLER as the production path | +| `cpt-insightspec-fr-auth-internal-reachability` | Two listeners (3.10); NetworkPolicies; credential checks on every endpoint | + +#### NFR Allocation + +| NFR ID | NFR Summary | Allocated To | Design Response | Verification Approach | +|--------|-------------|--------------|-----------------|----------------------| +| `cpt-insightspec-nfr-auth-exchange-p95` | Exchange within 5 ms p95 | Token Exchange | Two pipelined Redis reads; no IdP, no DB on the hot path | Load test, p95 at `/internal/authz` | +| `cpt-insightspec-nfr-auth-session-ttl` | TTL knobs via Helm | Config struct | All values in 3.9 deserialize from the gear config section | Integration test with overridden values | +| `cpt-insightspec-nfr-auth-cookie-attrs` | Hardened cookie attributes | Auth Controller | Single cookie helper; attributes hard-coded | Snapshot test on `Set-Cookie` | +| `cpt-insightspec-nfr-auth-audit` | 100% auth-event audit | Audit Emitter | One emitter called from every state change | Integration test per auth action | +| `cpt-insightspec-nfr-auth-rate-limit` | Layer-2 precise limits | Auth Controller | Redis token bucket by session/user + login-state cap | Flood test: 429 at cap, bounded Redis entries | +| `cpt-insightspec-nfr-auth-fail-closed` | No auth without Redis | Session Manager | No local cache; readiness = Redis + keys loaded | Kill Redis; verify 401/503 + not-ready | + +**ADRs**: to be authored alongside implementation; decisions captured inline in [section 5](#5-design-decisions) until then, including the carried-over and superseded decisions from the deleted API Gateway spec tree. + +### 1.3 Architecture Layers + +```mermaid +graph TB + subgraph Browser + SPA[React SPA] + end + + subgraph Edge + ING[Ingress - TLS] + GW[nginx gateway
auth_request + exchange cache] + end + + subgraph AUTH["authenticator (gear)"] + CTL["Auth Controller /auth/*"] + EXC["Token Exchange /internal/authz"] + TOK["Service Token Issuer /internal/token
(second listener)"] + JWT["JWT Issuer + Key Store + JWKS"] + SM["Session Manager"] + REF["IdP Refresher (leader)"] + JAN["Janitor (leader)"] + end + + subgraph State + RD[(Redis - asm:* keys)] + SEC[Mounted Secret
signing keys] + REG[Service registry
gitops config] + end + + subgraph External + OIDC[Customer OIDC Provider] + ID[Identity Service] + AU[Audit Service] + end + + SPA --> ING --> GW + GW -->|/auth/* plain proxy| CTL + GW -->|subrequest| EXC + DS[Downstream services] -->|RFC 7523 assertion| TOK + CTL --> SM + EXC --> SM + EXC --> JWT + TOK --> JWT + TOK --> REG + JWT --> SEC + SM --> RD + REF --> RD + REF --> OIDC + JAN --> RD + CTL <--> OIDC + OIDC -.back-channel logout.-> CTL + CTL --> ID + CTL --> AU +``` + +| Layer | Responsibility | Technology | +|-------|---------------|------------| +| Edge | TLS, HSTS, routing, exchange cache | ingress + nginx gateway (separate artifact) | +| Auth API | OIDC handshake, session lifecycle, CSRF, `/auth/*` | toolkit gear, operation-builder routes | +| Exchange | Cookie-to-JWT subrequest target, JWKS | same gear, main listener | +| Service tokens | RFC 7523 verification, registry | same gear, second listener | +| Workers | IdP refresher, janitor | `stateful` capability, Redis leader lock | +| State | Sessions, mappings, JWTs, indexes, schedule | Redis | + +- [ ] `p3` - **ID**: `cpt-insightspec-tech-auth` + +## 2. Principles & Constraints + +### 2.1 Design Principles + +#### Opaque to the browser + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-auth-opaque-cookie` + +The browser only ever sees an opaque session token. No JWTs, no IdP tokens, no claims. The JWT never reaches the browser -- it exists only between the gateway and downstream services. + +#### One stable identity, one rotating credential + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-auth-identity-credential-split` + +The `session_id` is the identity: born at login, dead at logout, the key for every server-side structure and the JWT `sid` claim. The cookie value is a credential: a TTL-bounded mapping that rotates freely without touching anything else. Audit and tracing correlate on one id from login to logout. + +#### Eager JWT, reissue ahead of expiry + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-auth-eager-jwt` + +The linked JWT is created with the session and refreshed before it grows stale, guaranteeing every served JWT at least the travel margin (default 60 s) of remaining validity. The hot path never signs; it reads. + +#### Network position is never authentication + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-auth-zero-trust` + +Every endpoint authenticates its caller with a credential (cookie, assertion, or gateway JWT) regardless of where the call came from. NetworkPolicies are defense-in-depth, not the mechanism. The authenticator's own admin surface verifies gateway JWTs exactly like any downstream service. + +#### Fail closed on auth + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-auth-fail-closed` + +Redis unreachable, malformed cookie, session past cap, definitive IdP refusal: 401 (or 503) and clear the cookie. Never serve a request with a guess. Transient IdP transport failures are the one deliberate fail-open (sessions survive an IdP blip). + +#### One verification path for user and service traffic + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-auth-one-verification-path` + +Service tokens are normal gateway JWTs signed with the same key and published in the same JWKS. A downstream service implements exactly one check. + +### 2.2 Constraints + +#### First-party cookie domain + +- [ ] `p2` - **ID**: `cpt-insightspec-constraint-auth-same-host` + +The SPA and the gateway share one hostname. `__Host-` forbids `Domain=` and pins the cookie to that host; consequently the SPA is routed through the gateway. + +#### OIDC provider feature set + +- [ ] `p2` - **ID**: `cpt-insightspec-constraint-auth-oidc-features` + +Customer IdP must support authorization code with PKCE and RP-initiated logout, and is expected to issue refresh tokens to this client (`offline_access` where required). Back-channel logout is recommended, not required -- the refresher is the guaranteed deactivation path. + +#### Toolkit gear, dash-free name + +- [ ] `p2` - **ID**: `cpt-insightspec-constraint-auth-toolkit-gear` + +The authenticator is an idiomatic gears-rust gear built on the published `cf-gears-*` crates (no git dependencies), living in the insight workspace like the analytics service. The gear name is dash-free (`authenticator`) so `APP__gears__authenticator__config__*` env overrides work in compose. + +## 3. Technical Architecture + +### 3.1 Domain Model + +| Entity | Purpose | Storage | +|---|---|---| +| `Session` | Active login of one user on one device; stable identity (`session_id`, UUIDv7); holds person, tenants, roles snapshot, IdP linkage, IdP refresh token, expiries, CSRF token | Redis HASH `asm:session:{session_id}` | +| `SessionToken` | Rotating cookie credential; maps token to `session_id` | Redis STRING `asm:token:{token}` | +| `LinkedJwt` | The signed gateway JWT linked 1:1 to the session | Redis STRING `asm:jwt:{session_id}` | +| `UserSessionIndex` | All `session_id`s for one person, scored by expiry | Redis ZSET `asm:user_sessions:{person_id}` | +| `SidIndex` | Map (OIDC issuer, OIDC sid) to local sessions | Redis SET `asm:sid_index:{iss}:{idp_sid}` | +| `LoginState` | Per-login transient state (PKCE verifier, nonce) | Redis HASH `asm:login_state:{state}`, TTL 5 min | +| `IdpRefreshSchedule` | Sessions due for IdP token refresh, scored by due time | Redis ZSET `asm:idp_refresh_due` | +| `ServiceRegistryEntry` | Service name, public key(s), allowed roles, tenant-scoping permission | Gitops-reviewable config (mounted) | +| `SigningKey` | Current + previous JWT signing keys | Mounted K8s Secret, in-process cache | + +Relationships: +- `Person` (owned by Identity Service) has 0..N `Session`s. +- `Session` has exactly one live `LinkedJwt` and 1..2 live `SessionToken` mappings (two only during the rotation grace). +- `Session` has 0..1 `SidIndex` membership (only if the IdP supplies `sid`) and exactly one `IdpRefreshSchedule` entry while refresh is enabled. + +### 3.2 Component Model + +```mermaid +graph LR + subgraph Authenticator + CTL[Auth Controller] + EXC[Token Exchange] + STI[Service Token Issuer] + SM[Session Manager] + OIDCC[OIDC Client] + JWTI[JWT Issuer] + KS[Key Store] + CSRF[CSRF Verifier] + REF[IdP Refresher] + JAN[Janitor] + AUD[Audit Emitter] + end + + subgraph Ext + RD[(Redis)] + IDP[OIDC Provider] + IDS[Identity Service] + RP[Redpanda] + REG[Service registry config] + end + + CTL --> OIDCC + CTL --> SM + CTL --> CSRF + CTL --> AUD + CTL --> IDS + CTL --> JWTI + EXC --> SM + EXC --> JWTI + STI --> JWTI + STI --> REG + JWTI --> KS + SM --> RD + OIDCC --> IDP + REF --> RD + REF --> IDP + JAN --> RD + AUD --> RP +``` + +#### Auth Controller + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-controller` + +##### Why this component exists +The single owner of every endpoint under `/auth/*` -- the only place where session state changes start. + +##### Responsibility scope +Login start, OIDC callback (including the session-fixation revoke, person resolution, claim snapshot, session + linked-JWT creation in one pipeline, and the empty-table bootstrap check), refresh, logout, session list/revoke (self and gateway-JWT-authenticated admin), back-channel logout receiver, CSRF token issuance, `/auth/me`. + +##### Responsibility boundaries +Does not authorize business operations. Does not own person data (Identity Service does). Does not serve the exchange or mint service tokens. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-session-manager` -- creates / reads / refreshes / revokes sessions. +- `cpt-insightspec-component-auth-oidc-client` -- runs the OIDC handshake. +- `cpt-insightspec-component-auth-jwt-issuer` -- mints the linked JWT at login. +- `cpt-insightspec-component-auth-csrf-verifier` -- issues and checks CSRF tokens. +- `cpt-insightspec-component-auth-audit-emitter` -- publishes auth events. + +#### Session Manager + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-session-manager` + +##### Why this component exists +The single entry point for every read or write of session state; centralizes the atomicity guarantees. + +##### Responsibility scope +Create, resolve-by-token, refresh-rotate, list-by-user, revoke (single / all-but-current / all / by-user). Keeps the session record, token mapping, linked JWT, both indexes, and the refresh schedule consistent with MULTI/EXEC pipelines: + +- **Create** -- one pipeline: session HASH + `EXPIREAT`, token mapping, linked JWT, ZSET add, sid-index add, refresh-due add (jittered score). +- **Refresh** -- one pipeline: new token mapping, old mapping TTL shortened to `refresh_grace_ms`, session `expires_at` + TTL update, ZSET score update. No RENAME, no swap keys, no index churn -- the stable `session_id` never moves. +- **Revoke** -- one pipeline per session: delete session, linked JWT, live token mapping(s), ZSET member, sid-index member, refresh-due member. Idempotent. Shared verbatim by logout, back-channel, admin revoke, and the refresher's `invalid_grant` path. + +##### Responsibility boundaries +Does not call the OIDC provider. Does not authenticate requests by itself. Does not own the cookie format. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-controller` -- primary writer. +- `cpt-insightspec-component-auth-exchange` -- hot-path reader. +- `cpt-insightspec-component-auth-idp-refresher` -- schedule consumer, revoke caller. + +#### Token Exchange + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-exchange` + +##### Why this component exists +The gateway's `auth_request` target -- replaces the deleted Router's in-process session check and JWT injection. + +##### Responsibility scope +`GET /internal/authz`: resolve `asm:token:{token}` to `session_id`, load session, read the linked JWT; under the reissue age, return it as-is; past it, rebuild claims from the session record, sign, `SET asm:jwt:{session_id} NX EX` (parallel requests converge on one canonical JWT), return the winner. Emit `X-Gateway-Jwt` and the `Cache-Control` header (`max-age = min(authz_cache_max_age, jwt_exp - now - 60 s)` on 200, `no-store` otherwise). + +##### Responsibility boundaries +Never writes session state other than the JWT slot. Never sets cookies (the subrequest response's `Set-Cookie` would be discarded by nginx anyway). No correlation-id generation -- the response is cacheable; correlation ids are minted at the edge. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-session-manager` -- session reads. +- `cpt-insightspec-component-auth-jwt-issuer` -- reissue path. + +#### JWT Issuer and Key Store + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-jwt-issuer` + +##### Why this component exists +Single signer for user and service tokens; single source for JWKS. + +##### Responsibility scope +Build claims (3.8), sign with the current key, serve `GET /.well-known/jwks.json` (current + previous public keys, cacheable). Key material comes from a plain mounted secret; rotation is a file re-read or pod restart with a `current`+`previous` overlap window (at least jwt TTL + downstream JWKS cache age, about 65 minutes) -- no K8s API watch. + +##### Responsibility boundaries +Does not verify inbound JWTs (the host's auth pipeline does, for the admin surface). Does not choose claims -- callers pass the session record or registry entry. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-exchange` -- reissue caller. +- `cpt-insightspec-component-auth-service-token-issuer` -- service-token caller. +- `cpt-insightspec-component-auth-controller` -- login-time mint caller. + +#### Service Token Issuer + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-service-token-issuer` + +##### Why this component exists +No-user workloads need signed identity without a secret in transit. + +##### Responsibility scope +`POST /internal/token` on the second listener: validate the RFC 7523 assertion (signature against the registry's public keys, `aud`, `exp` at most 60 s), replay-guard `jti` (`SET NX`, same pattern as `asm:logout_jti`), audit, and mint `sub = service:` with registry-allowed roles and optional per-request tenant scoping. + +##### Responsibility boundaries +Does not manage the registry (gitops does). Does not issue user tokens. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-jwt-issuer` -- signer. +- `cpt-insightspec-component-auth-audit-emitter` -- issuance audit. + +#### OIDC Client + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-oidc-client` + +##### Why this component exists +Encapsulates the OIDC protocol: authorize, exchange, refresh, end-session, validate logout token. + +##### Responsibility scope +Authorization code + PKCE flow, ID-token validation, RP-initiated logout URL construction, back-channel `logout_token` validation, and the refresh-token grant used by the IdP Refresher. Reuses the existing oidc-authn-plugin infrastructure where it fits (issuer discovery, JWKS cache with `kid` refresh, single-flight, circuit breaker); the code+PKCE client itself is new (that plugin only verifies tokens). + +##### Responsibility boundaries +Does not store sessions; holds no IdP tokens beyond one operation. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-controller` -- login/logout caller. +- `cpt-insightspec-component-auth-idp-refresher` -- refresh-grant caller. + +#### IdP Refresher + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-idp-refresher` + +##### Why this component exists +The session must not outlive the IdP's willingness to vouch for the user, even when the IdP has no back-channel logout. + +##### Responsibility scope +Leader-elected worker (Redis lock) owned by the gear's runnable capability. Loop: `ZRANGEBYSCORE asm:idp_refresh_due 0 now`, spawn one refresh task per due session under a per-session lock (refresh-token rotation is one-time-use; racing burns the grant), bounded by a semaphore (`idp.refresh_concurrency`, default 128 -- politeness toward the customer IdP, not our capacity). Store rotated tokens back and re-schedule with write-time jitter. `invalid_grant` verdict: revoke all linked sessions via the Session Manager's standard pipeline. Transient errors (timeout, 5xx, 429 with `Retry-After`): backoff and retry, never revoke. + +##### Responsibility boundaries +Does not talk to the browser. Does not decide policy beyond the configured `no_refresh_token_policy`. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-session-manager` -- schedule source and revoke pipeline. +- `cpt-insightspec-component-auth-oidc-client` -- refresh grant. + +#### CSRF Verifier + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-csrf-verifier` + +##### Why this component exists +Defense-in-depth on top of `SameSite=Strict` for state-changing `/auth/*` methods. + +##### Responsibility scope +Issue per-session CSRF tokens at login, constant-time compare on POST/PUT/PATCH/DELETE, fall back to `Origin` allowlist verification. + +##### Responsibility boundaries +Does not protect `/api/*` (gateway strips nothing relevant there; `SameSite=Strict` plus JWT verification cover it). Does not store the token outside the session record. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-controller` -- consumer. + +#### Janitor + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-janitor` + +##### Why this component exists +Per-key Redis TTLs remove records, but the ZSET indexes and the refresh schedule still list dead members until trimmed. + +##### Responsibility scope +Leader-elected periodic pass: `ZREMRANGEBYSCORE` expired members from `asm:user_sessions:*` and orphans from `asm:idp_refresh_due`; emit backlog/removed metrics. + +##### Responsibility boundaries +Does not delete session records (TTL does). One leader per pass. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-session-manager` -- shares key conventions; janitor trims only. + +#### Audit Emitter + +- [ ] `p2` - **ID**: `cpt-insightspec-component-auth-audit-emitter` + +##### Why this component exists +Every auth-relevant action lands on the audit topic with the same envelope and correlation fields. + +##### Responsibility scope +Publish login OK/fail, refresh, logout, revoke (single/all/admin), back-channel logout, `invalid_grant` kills, service-token issuance, bootstrap-admin creation. + +##### Responsibility boundaries +Does not run audit policy or retention -- Audit Service does. + +##### Related components (by ID) +- `cpt-insightspec-component-auth-controller`, `cpt-insightspec-component-auth-service-token-issuer`, `cpt-insightspec-component-auth-idp-refresher` -- callers. + +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-insightspec-design-auth-api-spec` + +Implements the API declared in [PRD section 7.1](./PRD.md#71-public-api-surface) (`cpt-insightspec-interface-auth-api`). + +- **Contracts**: `cpt-insightspec-contract-auth-gateway-jwt`, `cpt-insightspec-contract-auth-authz-exchange`, `cpt-insightspec-contract-auth-jwks-url`, `cpt-insightspec-contract-auth-oidc`, `cpt-insightspec-contract-auth-service-registry`, `cpt-insightspec-contract-auth-sdk` +- **Technology**: REST / OpenAPI (generated by the toolkit's operation registry) +- **Location**: generated `/openapi.json` -- the machine-checkable form of the gateway subrequest contract; the gateway's configurator and e2e tests check against it + +| Method | Path | Listener | Auth | Stability | +|---|---|---|---|---| +| GET | `/auth/login` | main | none | stable | +| GET | `/auth/callback` | main | none (state/nonce/PKCE) | stable | +| POST | `/auth/refresh` | main | session cookie | stable | +| POST | `/auth/logout` | main | session cookie + CSRF | stable | +| GET | `/auth/me` | main | session cookie | stable | +| GET | `/auth/sessions` | main | session cookie | stable | +| DELETE | `/auth/sessions/{id}` | main | session cookie + CSRF | stable | +| DELETE | `/auth/sessions` | main | session cookie + CSRF; admin/service variant: gateway JWT with authorized role | stable | +| POST | `/auth/oidc/back-channel-logout` | main | OIDC `logout_token` | stable | +| GET | `/auth/csrf` | main | session cookie | stable | +| GET | `/internal/authz` | main | session cookie (the exchange) | stable | +| GET | `/.well-known/jwks.json` | main | none (public keys) | stable | +| POST | `/internal/token` | token | RFC 7523 assertion | stable | + +Exchange response contract (the load-bearing part): `200` + `X-Gateway-Jwt: Bearer ` + `Cache-Control: max-age = min(authz_cache_max_age, jwt_exp - now - 60 s)`; `401` (no/expired session) + `Cache-Control: no-store`; any other status is treated by the gateway as "authenticator unavailable" and fails closed. + +### 3.4 Internal Dependencies + +| Dependency Module | Interface Used | Purpose | +|-------------------|----------------|----------| +| Identity Service | REST (SDK client) | Resolve IdP `sub` to `person_id` + tenant memberships at login; `(iss, sub)` resolution for back-channel fallback | +| Audit Service | Redpanda producer | Auth events | +| Permissions service (future) | REST via `authenticator-sdk` in the other direction: it calls session-revoke here | Access-control claims at login (one call); instant claim propagation via revoke | +| nginx gateway | consumer of `/internal/authz` + JWKS | See [Gateway DESIGN](../gateway/DESIGN.md) | + +**Dependency Rules** (per project conventions): no circular dependencies; inter-service calls go through SDK clients; `SecurityContext` propagated on in-process calls; consumers of the authenticator depend on `authenticator-sdk`, never the impl crate. + +### 3.5 External Dependencies + +#### Customer OIDC Provider + +| Dependency Module | Interface Used | Purpose | +|-------------------|---------------|---------| +| OIDC Client | OIDC 1.0 (HTTPS) | Code + PKCE exchange, refresh grants, RP-initiated logout, back-channel logout receiver | + +#### Redis + +| Dependency Module | Interface Used | Purpose | +|-------------------|---------------|---------| +| Session Manager, workers | RESP (TCP/TLS), `redis` crate (tokio, connection-manager) -- the dependency insight already pins | Sessions, mappings, JWTs, indexes, schedule, locks, rate-limit buckets | + +### 3.6 Interactions & Sequences + +#### Login -- one exchange at the start + +**ID**: `cpt-insightspec-seq-auth-login` + +**Use cases**: `cpt-insightspec-usecase-auth-login` + +**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-oidc-provider` + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant B as Authenticator + participant I as OIDC IdP + participant ID as Identity Service + participant R as Redis + + U->>B: GET /auth/callback?code&state (via nginx, plain proxy) + B->>I: exchange code (PKCE) -> bearer / id_token / refresh_token + Note over B: IdP tokens stay HERE, browser never sees them.
Refreshed in background; a definitive refusal later
kills all linked session tokens. + B->>ID: resolve author: person_id, tenant(s) + Note over B: access-control claims: ONE call at login to the
permissions service (built later) -- until then
default roles from config + B->>B: create session: stable session_id (UUIDv7)
+ session token (opaque credential, CSPRNG)
mint linked JWT: sub=person_id, tenants, roles,
sid=session_id, exp=iat+300s + B->>R: one pipeline: session record + token mapping
+ linked JWT + indexes + refresh schedule + B-->>U: Set-Cookie __Host-sid=(session token) + 302 to SPA +``` + +**Description**: The only moment IdP tokens are exchanged. The session-fixation guard (revoke any live session named by an incoming cookie, always generate the new token server-side) runs before session creation, exactly as in the deleted BFF spec. + +#### Every API request -- cookie in, JWT out + +**ID**: `cpt-insightspec-seq-auth-exchange` + +**Use cases**: `cpt-insightspec-usecase-auth-exchange` + +**Actors**: `cpt-insightspec-actor-nginx-gateway` + +```mermaid +sequenceDiagram + autonumber + participant N as nginx gateway + participant B as Authenticator + participant R as Redis + + N->>B: GET /internal/authz (subrequest on exchange-cache miss) + B->>R: GET asm:token:{token} -> session_id; load session + alt no / expired session + B-->>N: 401 + Cache-Control: no-store + else JWT age < reissue threshold (4 min) + B->>R: GET asm:jwt:{session_id} + B-->>N: 200 + X-Gateway-Jwt (stored JWT as-is,
>= 60 s validity left) + Cache-Control: max-age + else JWT age >= reissue threshold + B->>B: rebuild claims from session record, sign fresh JWT + B->>R: SET asm:jwt:{session_id} NX EX (stampede-safe) + B-->>N: 200 + X-Gateway-Jwt (canonical winner) + Cache-Control: max-age + end +``` + +**Description**: Two Redis reads on the hot path -- the same work the deleted Router did in-process, behind one HTTP hop that the gateway's exchange cache absorbs. + +#### Session refresh -- rotation without churn + +**ID**: `cpt-insightspec-seq-auth-refresh` + +**Actors**: `cpt-insightspec-actor-browser-user` + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant B as Authenticator + participant R as Redis + + U->>B: POST /auth/refresh (cookie = old token) + B->>R: GET asm:token:{old} -> session_id; load session + alt mapping resolves, under absolute cap + B->>B: new token = csprng()
new_exp = min(now + ttl, absolute_expires_at)
refresh_at = new_exp - 90s +/- 60s jitter + B->>R: pipeline: SET asm:token:{new} session_id EX new_exp
PEXPIRE asm:token:{old} grace_ms
update session expires_at + TTL + ZSET score + B-->>U: 200 {expires_at, refresh_at} + Set-Cookie (new token) + Note over B,R: session_id, linked JWT, indexes: UNTOUCHED.
No RENAME, no swap key -- the expiring old
mapping IS the grace window. + else mapping gone + B-->>U: 401 + clear cookie + end +``` + +**Description**: A stale-but-in-grace cookie still resolves through the old mapping to the same `session_id` and is answered with the current state, no second rotation -- the deleted spec's grace semantics preserved by a TTL instead of a dedicated key family. + +#### Background IdP refresh and the kill path + +**ID**: `cpt-insightspec-seq-auth-idp-refresh` + +**Use cases**: `cpt-insightspec-usecase-auth-idp-refresh-kill` + +**Actors**: `cpt-insightspec-actor-oidc-provider` + +```mermaid +sequenceDiagram + autonumber + participant W as Refresher (leader) + participant R as Redis + participant I as OIDC IdP + + loop every tick + W->>R: ZRANGEBYSCORE asm:idp_refresh_due 0 now + R-->>W: due session_ids + par per session, semaphore-capped + W->>R: acquire per-session lock (rotation safety) + W->>I: refresh grant (rotating refresh token) + alt success + I-->>W: new tokens + W->>R: store tokens, re-schedule with jitter + else invalid_grant (definitive) + W->>R: revoke ALL linked sessions
(standard revoke pipeline: session + JWT + mappings + indexes) + else transient (timeout / 5xx / 429) + W->>W: backoff, honor Retry-After, retry later + Note over W: fail open on transport --
nobody is logged out by a blip + end + end + end +``` + +**Description**: IdP-side deactivation propagates within about one IdP access-token lifetime with no back-channel logout required. Metrics on outcomes alert before a mass logout, not after. + +#### Back-channel logout + +**ID**: `cpt-insightspec-seq-auth-back-channel` + +**Actors**: `cpt-insightspec-actor-oidc-provider` + +```mermaid +sequenceDiagram + autonumber + participant I as OIDC Provider + participant B as Authenticator + participant R as Redis + + I->>B: POST /auth/oidc/back-channel-logout (logout_token) + B->>B: validate (sig, iss, aud, iat, events, jti) + B->>R: SET asm:logout_jti:{iss}:{jti} NX EX ... + alt replay (NX failed) + B-->>I: 200 (idempotent, no revoke) + else first delivery + B->>R: resolve sessions via asm:sid_index (or per-user index on sub-only fallback) + B->>R: standard revoke pipeline per session + B-->>I: 200 + end +``` + +**Description**: Salvaged unchanged from the deleted BFF spec, including the `jti` replay guard and the documented sub-only blast-radius fallback. + +#### Service token issuance + +**ID**: `cpt-insightspec-seq-auth-service-token` + +**Use cases**: `cpt-insightspec-usecase-auth-service-token` + +**Actors**: `cpt-insightspec-actor-downstream-service` + +```mermaid +sequenceDiagram + autonumber + participant S as Service (background job) + participant B as Authenticator (token listener) + participant R as Redis + + S->>S: sign assertion: iss=sub=service, aud=authenticator,
jti, exp <= 60s (private key) + S->>B: POST /internal/token (assertion) + B->>B: verify signature against registry public keys + B->>R: SET jti NX (replay guard) + B-->>S: gateway JWT: sub=service:name, roles per registry,
optional tenants:[t], TTL 300s, same key + JWKS + S->>S: cache, re-request before expiry +``` + +**Description**: One verification path downstream; onboarding and rotation are gitops PRs against the registry. + +### 3.7 Database schemas & tables + +- [ ] `p3` - **ID**: `cpt-insightspec-db-auth-redis` + +This module's "database" is Redis. All keys carry the `asm:` prefix (authenticator session management) -- owner-prefixed keys on the shared Redis instance, one prefix per module, so operators can identify the owner from the key name (the deleted spec's DD-BFF-04 rationale, with a prefix named after the service that actually exists). Explicitly absent against the deleted spec: **no swap-key family (its `bff:swap:*`), no RENAME-based rotation, no separate JWT-cache prefix (its `router:jwt_cache:*`)** -- the linked JWT lives under `asm:jwt:*` with the same lifecycle as the session. + +```mermaid +graph LR + P[person_id] + IDX["asm:user_sessions:{person_id}
ZSET - score = expires_at"] + S["asm:session:{session_id}
HASH"] + T1["asm:token:{token}
STRING -> session_id"] + T2["asm:token:{old_token}
STRING -> session_id (grace TTL)"] + J["asm:jwt:{session_id}
STRING (signed JWT)"] + SIDX["asm:sid_index:{iss}:{idp_sid}
SET of session_id"] + LS["asm:login_state:{state}
HASH (5 min TTL)"] + LJTI["asm:logout_jti:{iss}:{jti}
STRING - replay guard"] + DUE["asm:idp_refresh_due
ZSET - score = refresh due time"] + + P --> IDX + IDX --> S + T1 --> S + T2 -. rotation grace .-> S + S --- J + S -. idp sid lookup .-> SIDX + S -. scheduled refresh .-> DUE +``` + +#### Key: `asm:token:{token}` + +**Type**: Redis STRING. Value: `session_id`. + +**Purpose**: The rotating credential mapping -- the only thing the cookie value can resolve. Refresh rotation writes the new mapping and shortens the old one's TTL to the rotation grace (`refresh_grace_ms`, default 250 ms); the expiring old mapping **is** the grace window. + +**TTL**: session `expires_at` for the live mapping; `grace_ms` (PX) for the superseded one. + +#### Key: `asm:session:{session_id}` + +**Type**: Redis HASH. Keyed by the **stable** `session_id` (UUIDv7), never by the cookie value. + +| Field | Type | Description | +|---|---|---| +| `person_id` | String | Internal person identifier | +| `tenants` | String (JSON) | Tenant memberships resolved at login -- the JWT's `tenants` source | +| `roles` | String (JSON) | Access-control snapshot fetched at login (default roles until the permissions service exists) | +| `idp_iss` | String | OIDC issuer URL | +| `idp_sub` | String | OIDC subject | +| `idp_sid` | String | OIDC `sid` claim (back-channel logout) | +| `id_token` | String | For `id_token_hint` on RP-initiated logout | +| `idp_refresh_token` | String | Current (rotating) IdP refresh token -- background refresh | +| `idp_access_expires_at` | Int (epoch s) | IdP access-token expiry driving the refresh schedule | +| `created_at` | Int (epoch s) | Session creation time | +| `expires_at` | Int (epoch s) | Current session expiry; advanced by `/auth/refresh` | +| `absolute_expires_at` | Int (epoch s) | Hard cap; `min()`-enforced on refresh | +| `user_agent` | String | Captured at login | +| `ip` | String | Captured at login | +| `csrf_token` | String | CSRF token bound to this session | + +**Redis TTL**: matches `expires_at`; re-set on every refresh. + +#### Key: `asm:jwt:{session_id}` + +**Type**: Redis STRING. Value: the full signed gateway JWT. + +**Purpose**: The linked JWT -- created in the same pipeline as the session, replaced by the reissue-ahead path with `SET ... NX EX ` (stampede-safe: parallel exchanges converge on one canonical JWT; carried over from the deleted Router's DD-ROUTER-10), deleted in the same pipeline as the session on revoke. + +**TTL**: `jwt_reissue_after_seconds` on NX fill; bounded overall by the session's lifecycle. + +#### Key: `asm:user_sessions:{person_id}` + +**Type**: Redis ZSET. Member: `session_id`. Score: `expires_at`. + +**Why ZSET, not SET** (carried over from the deleted BFF spec's DD-BFF-03): active sessions, expired entries, and janitor cleanup are each one `ZRANGEBYSCORE` / `ZREMRANGEBYSCORE`. Rotation never touches this index -- members are stable `session_id`s; only refresh updates the score. + +#### Key: `asm:sid_index:{iss}:{idp_sid}` + +**Type**: Redis SET of `session_id`. + +**Purpose**: Resolve back-channel `logout_token` (`iss` + `sid`) to local sessions. Never churned by rotation. + +#### Key: `asm:login_state:{state}` + +**Type**: Redis HASH. Fields: `pkce_verifier`, `nonce`, `redirect_to`. **TTL**: 5 minutes, one-shot. The live count is capped (layer-2 rate limiting). + +#### Key: `asm:logout_jti:{iss}:{jti}` + +**Type**: Redis STRING presence flag, `SET NX`. Replay guard for back-channel logout tokens. **TTL**: `(iat + max_clock_skew + grace) - now`. The same pattern guards `/internal/token` assertion `jti`s. + +#### Key: `asm:idp_refresh_due` + +**Type**: Redis ZSET. Member: `session_id`. Score: IdP access-token expiry minus `idp.refresh_safety_margin_seconds`, **jittered at write** so sessions do not come due in the same second after a deploy or Redis restore. + +**Purpose**: The refresher's schedule -- `ZRANGEBYSCORE ... 0 now`, no scanning. Maintained in the same pipelines as the session record. + +### 3.8 Gateway JWT Claim Contract + +- [ ] `p2` - **ID**: `cpt-insightspec-design-auth-jwt-claim-spec` + +Technical specification of `cpt-insightspec-contract-auth-gateway-jwt` ([PRD section 7.2](./PRD.md#72-external-integration-contracts)). This schema **supersedes the deleted spec's DD-ROUTER-05** (identity-only JWT, all authorization downstream): the JWT is the signed, complete description of the request author. + +**Header**: `alg` (see the open EdDSA vs ES256 decision in [section 5](#open-eddsa-vs-es256-for-the-gateway-jwt)), `typ: JWT`, `kid` from JWKS. + +| Claim | Type | Value | +|---|---|---| +| `sub` | String | Internal **person_id**; `service:` for service tokens | +| `tenants` | Array of String | All tenant memberships (1..N), resolved at login. The JWT is the only tenant **authority**; per-request **selection** is an unsigned attribute (`X-Tenant-ID` or path segment) that downstream validates against this signed set: selector missing when needed = 400; selector not in the set = 403. An unsigned header can no longer grant anything -- the worst it can do is pick among tenants the JWT already granted | +| `roles` | Array of String | Default from config (`["user"]`); the permissions service's login-time answer later replaces the values, never the shape. Service tokens carry `["service", ...]` per the registry | +| `sid` | String | **Stable** session id (UUIDv7) -- survives cookie rotations; one id from login to logout for tracing, audit, and the JWT/session linkage | +| `iss` | String | Gateway host issuer URL | +| `aud` | String | `internal-services` | +| `iat` / `exp` | Int | `exp = iat + 60..300 s` (default TTL 300 s) | +| `jti` | String | UUIDv7 | + +**SecurityContext alignment (load-bearing).** Downstream gears construct caller identity from claims via the authn-resolver claim mapper into `toolkit_security::SecurityContext`. The claims map 1:1: `sub` maps to `subject_id` (`service:` yields `subject_type = "service"`), `roles` maps to `token_scopes`, and the validated tenant selection maps to `subject_tenant_id`. `SecurityContext` is single-tenant by design -- which is exactly the authority/selection split: `tenants[]` in the JWT is the authority; the shared verification middleware resolves selector membership and constructs the context with the *selected* tenant. + +**Verification at downstream**: signature via JWKS (`GATEWAY_JWKS_URL`), `iss`, `aud`, `exp` -- no shared secrets. Mandatory for every service, fail closed, no production disable knob: a gateway misconfiguration that skips auth yields a JWT-less request downstream and a 401 -- an availability bug, never a breach. The gateway's own check is UX and hot-path efficiency, not the security boundary. + +### 3.9 Configuration Surface + +All tunable via Helm values; defaults chosen so everything holds without touching anything: + +| Value | Default | Meaning | +|---|---|---| +| `authenticator.session_ttl_seconds` | `600` (10 min) | Session token / cookie TTL. Extended **only** by the mandatory `POST /auth/refresh` from the SPA -- no sliding on API traffic. Reasonable range 300-600 s. | +| `authenticator.session_absolute_lifetime_seconds` | `28800` (8 h) | Hard cap across refreshes; after it, re-login. | +| `authenticator.session_refresh_safety_margin_seconds` | `90` | Server-supplied `refresh_at = expires_at - margin + jitter` tells the SPA when to call refresh. | +| `authenticator.refresh_jitter_seconds` | `120` | Full jitter window on `refresh_at`, uniform +/- 60 s. Deliberately big: spreads refresh load from NAT'd offices into a uniform trickle *and* keeps an attacker from aligning to the rotation grace window. Late edge keeps >= 30 s of session life with the default margin. | +| `authenticator.jwt_ttl_seconds` | `300` (5 min) | Linked-JWT validity (`exp - iat`). | +| `authenticator.jwt_reissue_after_seconds` | `240` (4 min) | Serve the stored JWT until this age, then reissue ahead of expiry. Must be `< jwt_ttl`; the difference (60 s) is the guaranteed travel margin. | +| `authenticator.default_roles` | `["user"]` | Baked into every JWT from day one; replaced by the permissions service's answer at login once that service exists. | +| `authenticator.idp.refresh_enabled` | `true` | Background refresh of IdP tokens per session. | +| `authenticator.idp.refresh_safety_margin_seconds` | `60` | Refresh IdP tokens this long before their expiry. | +| `authenticator.idp.refresh_concurrency` | `128` | Max in-flight IdP refresh calls from the leader -- politeness toward the customer IdP, not our capacity. | +| `authenticator.idp.no_refresh_token_policy` | `strict` | When the IdP issues no refresh token: `strict` = session capped at the IdP access-token lifetime; `login_only` = sessions live to the absolute cap, killed only by back-channel logout / manual revoke. | +| `authenticator.bootstrap_first_admin` | `true` | Empty persons table: first IdP-authenticated login is auto-created as universe admin. Window closes permanently on the first created person; always loudly audited. | +| `authenticator.authz_cache_max_age_seconds` | `30` | Upper bound for the gateway-side cookie-to-JWT exchange cache, emitted as `Cache-Control: max-age` on `/internal/authz` 200s (actual value = `min(this, jwt_exp - now - 60 s)`; non-200 = `no-store`). Bounds revocation staleness at the gateway. `0` = per-request checks, instant revocation. | + +Inherited from the deleted BFF spec unchanged: `authenticator.refresh_grace_ms` (default `250`) -- the TTL applied to the superseded token mapping on rotation; plus the CSRF origin allowlist, back-channel clock-skew tolerance, layer-2 rate-limit knobs, and OIDC client settings (`issuer_url`, `client_id`, `client_secret`). + +The config struct mirrors this table 1:1 and deserializes from the gear's config section with `APP__gears__authenticator__config__` env overrides -- the layering the toolkit host already owns (and why the dash-free gear name matters). + +### 3.10 Gear Anatomy + +- [ ] `p3` - **ID**: `cpt-insightspec-design-auth-gear-anatomy` + +The authenticator is an idiomatic gears-rust gear -- the same shape the analytics service took -- not a bespoke service: + +- **Placement**: insight workspace member `src/backend/services/authenticator/`, package `authenticator`, one binary. Toolkit from the published `cf-gears-*` crates (`cf-gears-toolkit`, `toolkit-auth`, `toolkit-security`, `toolkit-canonical-errors`) -- no git dependencies. +- **Declaration**: `#[toolkit::gear(name = "authenticator", deps = ["types-registry"], capabilities = [rest, stateful])]`. The `rest` capability registers every endpoint through the `OperationBuilder` (no hand-mounted axum routes); every registered operation lands in the generated OpenAPI document. The `stateful` capability owns the IdP refresher and the janitor as platform-lifecycle tasks with the toolkit's two-phase graceful shutdown -- no hand-rolled spawn-and-forget. +- **Auth pipeline split**: `/auth/login|callback|refresh|me` and `/internal/authz` are `.public()` at the pipeline level -- their credential is the session cookie, enforced in the service layer. The admin surface (session revoke by user) is `.authenticated()`: the host's authn-resolver pipeline validates a **gateway JWT** and hands the handler a `SecurityContext` -- the "authenticator verifies its own tokens like any downstream service" symmetry, implemented by the platform. +- **Errors**: no custom error enums at API boundaries; domain errors convert into `CanonicalError` (16 canonical variants), serialized as RFC 9457 `Problem`. `Unauthenticated` maps to 401 (the `auth_request` deny); everything else maps to 5xx and the gateway fails closed. The gateway's Lua error shaping emits problem-details too -- one error format from the edge to the gear. +- **SDK**: the `authenticator-sdk` crate carries the inter-gear contract trait, request/response models, and an optional typed error projection. Consumers (the future permissions service) depend on the SDK only. +- **Type system**: DTOs derive the OpenAPI schema and register through the types registry; any future plugin-shaped extension point (a `SessionStore` backend, IdP-quirk adapters) declares a GTS schema with well-known instances collected at link time. +- **The two-listener wrinkle**: the REST host binds one address; the token listener (`POST /internal/token` only) is a small secondary HTTP server owned by the gear's runnable capability -- cluster-internal, one endpoint, deliberately off the public OpenAPI surface. +- **Wiring**: compose service entry (`authenticator`, own port), multi-stage Dockerfile, Helm chart (deployment + configmap + envFrom secret), CI build-path filter, workspace member -- copy the analytics pattern. +- **Reuse map** (verified in the workspace): OIDC issuer discovery + JWKS fetch/cache + circuit breaker + single-flight exist in oidc-authn-plugin infrastructure (reuse/extract); the S2S token client exists as that plugin's token client (also the model for the *client* side of `/internal/token`); the authorization-code + PKCE client and JWT minting + JWKS *serving* are new builds; Redis comes from the workspace's pinned `redis` crate. +- **Known collision**: gears-rust contains a docs-only twin (`gears/system/bff` -- PRD/DESIGN/ADRs, zero code) whose design the authenticator substantially implements. Those docs are marked superseded-for-insight (or the authenticator is later upstreamed as their implementation) -- tracked in the gears-rust repo, outside this document. + +## 4. Cross-Cutting Concerns + +### 4.1 Cookie Hardening + +Salvaged unchanged from the deleted BFF spec: a single helper sets every session cookie; attributes are hard-coded (`__Host-sid`, `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`, no `Domain`), only `Max-Age` comes from config (`session_ttl_seconds`, or 0 for clears). A snapshot test asserts the exact `Set-Cookie` header; any other code path setting cookies fails review. + +### 4.2 CSRF Defense + +Salvaged unchanged: `SameSite=Strict` primary; on state-changing `/auth/*` methods, `X-CSRF-Token` constant-time-compared against the session record, with `Origin`-allowlist fallback; both failing yields 403. The CSRF token is generated once per session at login and dies with the session. Empty `csrf_origins` (default) is fail-closed: token required. The SPA fetches the token via `GET /auth/csrf` (echoed by `/auth/me`). + +### 4.3 Janitor and Leader Election + +Background tasks (janitor, IdP refresher) run on every pod but elect one leader per pass via a Redis lock (TTL slightly longer than the interval) -- carried over from the deleted spec's DD-BFF-09: no extra K8s objects, Redis is already a hard dependency, a missed pass costs little. The janitor trims `asm:user_sessions:*` and `asm:idp_refresh_due`; backlog metrics alert if no pod runs a pass for twice the interval. + +### 4.4 Rate Limiting + +Two layers by design: the gateway carries the coarse per-IP flood guard (`limit_req`, order of 60 r/min with generous burst -- legitimate steady-state traffic is smooth by construction thanks to the big refresh jitter). The authenticator owns the precise layer: a Redis token bucket keyed by session/user, and the login-state cap (default 1000 live `asm:login_state:*` entries per pod, 429 beyond) that stops a slow-trickle Redis-exhaustion attack the edge cannot see. Both run before any expensive work and emit metrics. + +### 4.5 Key Rotation + +JWT signing keys are a plain mounted secret (`current` + optional `previous`); rotation = update the secret and re-read (or roll pods). JWKS publishes both kids during the overlap; downstream JWKS caches refresh on unknown `kid`. The operator runbook keeps `previous` published for at least `jwt_ttl + downstream JWKS cache age` (about 65 minutes with the defaults) before removal -- the same overlap math as the deleted Router spec's runbook, minus the K8s watch machinery. + +### 4.6 Bootstrap Guardrails + +The empty-table first-admin path checks the persons table emptiness inside the login transaction; it admits only an IdP-authenticated principal; the window closes permanently on the first created person; every use emits a dedicated audit event and log line; `bootstrap_first_admin: false` disables it entirely. The INSTALLER (separate component; formalizes the existing seeding flows) is the production path and closes the window by populating the table before first login. + +### 4.7 Observability + +Metrics (Prometheus): + +- `auth_login_total{result}` -- ok / fail / state_mismatch / bootstrap_admin +- `auth_refresh_total{result}` -- ok / grace / expired / past_cap +- `auth_exchange_total{result}` -- ok / reissued / unauthenticated +- `auth_exchange_duration_seconds` -- histogram (the 5 ms p95 NFR) +- `auth_session_active` -- gauge +- `idp_refresh_total{result}` -- ok / transient / invalid_grant +- `idp_refresh_consecutive_failures` -- gauge (alert before the mass logout) +- `service_token_issued_total{service, result}` +- `auth_janitor_removed_total`, `auth_janitor_backlog_size` + +With the gateway exchange cache, the authenticator sees only cache **misses** (roughly one per session per cache window per gateway pod); per-request visibility lives in the gateway's access logs. + +Logs (structured JSON): every auth event with correlation id, `session_id`, `person_id`, tenants. Never log cookies, raw tokens, or refresh tokens. + +Audit (via Audit Service): the full event list in `cpt-insightspec-nfr-auth-audit`. + +## 5. Design Decisions + +### Carried over from the deleted API Gateway specs + +Recorded here so the decisions survive the deleted tree; rationale as originally written: + +- **DD-BFF-01 -- Opaque session vs JWT cookie**: opaque server-side session, instant revocation, nothing portable in the browser. Unchanged. +- **DD-BFF-02 -- Explicit session refresh, no sliding TTL**: only `POST /auth/refresh` extends the session; API traffic never does. Unchanged (TTL default now 600 s). +- **DD-BFF-03 -- ZSET for the user-session index**: score = expiry makes listing and janitor cleanup O(log N). Unchanged; members are now stable `session_id`s, so rotation no longer touches the index at all. +- **DD-BFF-09 -- Workers coordinate via Redis lock**: one leader per pass, no extra K8s objects. Unchanged; now also covers the IdP refresher. +- **DD-ROUTER-03 -- Redis-backed JWT storage (not in-memory)**: multi-pod correctness and single-DEL invalidation. Unchanged in spirit; the key is now `asm:jwt:{session_id}` with a login-time fill instead of `router:jwt_cache:{sid}` with a lazy fill. +- **DD-ROUTER-09 -- JWT storage size cap**: bounded by per-entry TTL plus Redis `allkeys-lru`; losing an entry only costs a re-sign. Unchanged. +- **DD-ROUTER-10 -- Fill uses `SET ... NX EX`**: parallel reissues converge on one canonical JWT per session per window. Unchanged, applied to the reissue-ahead path. + +### Superseded decisions + +- **DD-ROUTER-05 (identity-only JWT, all authorization downstream) -- SUPERSEDED** by DD-AUTH-04: the JWT now carries `tenants` and `roles`; downstream services still make the final authorization decision, but from signed claims instead of per-request identity lookups and unsigned tenant headers. +- **The BFF spec's "no IdP token refresh in v1" carve-out -- SUPERSEDED** by DD-AUTH-03: the authenticator stores and background-refreshes IdP tokens; sessions die on definitive IdP refusal. +- **DD-BFF-10's swap-key rotation pipeline -- SUPERSEDED** in mechanism by DD-AUTH-02 (the grace *semantics* are preserved by the old mapping's TTL; the rotation, grace, and jitter *intent* of DD-BFF-10 carries over with the bigger jitter window). +- **The single-binary gateway NFR (`nfr-gw-single-binary` in the deleted tree) -- deliberately violated and retired**: that NFR existed to avoid a hop between auth and routing; this architecture reintroduces the hop deliberately and absorbs it with the gateway-side exchange cache. + +### DD-AUTH-01: JWT Minted at Login, Linked 1:1 to the Session + +**Decision**: The gateway JWT is created at `/auth/callback` in the same pipeline as the session, stored at `asm:jwt:{session_id}`, served as-is while under the reissue age, and reissued ahead of expiry. + +**Why**: +- The hot path becomes two Redis reads -- no signing, no claim rebuilding per request. +- The reissue-ahead margin guarantees every served JWT at least 60 s of validity -- a queued, retried, or multi-hop request still verifies downstream (this is also what lets user-context fan-out forward the same JWT). +- Session and JWT share one lifecycle: one pipeline creates both, one pipeline revokes both -- no cache-invalidation choreography. + +**Consequences**: Claims are a login-time snapshot (see DD-AUTH-07). Reissue keeps `sid` and claims stable, refreshing only `iat`/`exp`/`jti`. + +### DD-AUTH-02: Session Identity / Credential Split + +**Decision**: Stable `session_id` (UUIDv7) as the universal server-side key and JWT `sid`; the cookie value is only an `asm:token:{token}` mapping. Rotation = write new mapping + let the old one expire after `grace_ms`. No `RENAME`, no ZSET member replacement, no sid-index churn, no dedicated swap/grace key family (the deleted spec's `bff:swap:*`). + +**Why**: +- The deleted spec's conflation of cookie value = session id = Redis key = `sid` claim was the actual bug behind its stale-`sid` problem and its heavyweight rotation pipeline. +- The expiring old mapping *is* the grace window -- one fewer key family, one fewer failure mode. +- Audit and tracing get one id from login to logout instead of a chain of rotated ones. + +**Consequences**: Token theft detection stays probabilistic exactly as in the deleted spec (rotation + jitter make reuse noisy); the grace path returns current state without re-rotating, so races cannot churn tokens. + +### DD-AUTH-03: Background IdP Token Refresh + +**Decision**: Store the IdP refresh token at login; a leader-elected worker refreshes ahead of IdP expiry off the `asm:idp_refresh_due` schedule; `invalid_grant` kills all linked sessions; transport failures retry forever without killing anything; `no_refresh_token_policy` governs IdPs that issue no refresh token. + +**Why**: +- A session must not outlive the IdP's willingness to vouch for the user; back-channel logout is optional in the wild, so the refresher is the guaranteed deactivation path. +- Fail-open on transport / fail-closed on verdict: a five-minute IdP blip must not log out the installation, but a revoked user must be gone within about one IdP access-token lifetime. +- The schedule ZSET (with write-time jitter) plus a per-session rotation lock plus a politeness semaphore handle scale and one-time-use refresh-token rotation safely. + +**Consequences**: New requirement on the customer IdP (refresh tokens / `offline_access`) recorded in the OIDC provider contract. New metrics alert on rising transient failures before sessions start dying at their caps. + +### DD-AUTH-04: Tenants and Roles in the JWT + +**Decision**: The JWT carries `tenants[]` (the only tenant authority) and `roles` from day one. Per-request tenant selection stays an unsigned attribute validated against the signed set inside the shared verification middleware. + +**Why**: +- Headers are not signed; an unsigned `X-Insight-Tenant-Id` as a trust source is exactly the anti-pattern this design deletes. Demoting it to a validated selector means the worst a forged header can do is pick among tenants the JWT already granted. +- The SPA keeps sending `X-Tenant-ID` exactly as today; switching tenant in the UI is just a different selector -- no session mutation, no JWT reissue. +- Fixing the claim shape now means the permissions service later changes claim values, never the contract; downstream services code against the final shape from day one. + +**Consequences**: Membership and role changes propagate on re-login; the instant lever is session revocation (the permissions service calls the admin revoke on grant change). Claim size stays bounded: memberships are 1..few in practice; if a deployment ever has persons in hundreds of tenants, cap the claim and fall back to selector + membership lookup -- a downstream-only change. + +### DD-AUTH-05: Service Tokens via RFC 7523 Assertions and a Public-Key Registry + +**Decision**: `POST /internal/token` accepts `private_key_jwt` assertions verified against a gitops-reviewable registry (service name, public keys, allowed roles, tenant-scoping); output is a normal gateway JWT with `sub = service:`. + +**Why**: +- Against static client secrets: no secret in transit, rotation is a reviewable PR shipping key n+1 alongside n. +- Against K8s ServiceAccount projected tokens: those bind auth to k8s (compose/e2e need a parallel mechanism) and couple the authenticator to the apiserver -- a good v2 convenience layer, wrong base. +- Against mTLS/SPIFFE: strongest story, but drags in cert infra or a mesh for a handful of services. +- One verification path downstream; the permissions service's authenticated path to session-revoke falls out for free (its registry entry grants the revoke role). + +**Consequences**: Dev keypairs are checked into dev config; the flow is identical outside k8s. Assertion `jti` replay uses the existing `SET NX` pattern. + +### DD-AUTH-06: Two Listeners for Two Internal Surfaces + +**Decision**: Main listener (`/auth/*`, JWKS, `/internal/authz`) network-scoped to gateway pods; a second, single-endpoint listener for `/internal/token` reachable from app-namespace service pods. The token listener is a small secondary server owned by the runnable capability, off the public OpenAPI surface. + +**Why**: The two internal endpoints have different legitimate callers; only nginx ever needs the exchange, while services must reach the token endpoint themselves. NetworkPolicies per listener are defense-in-depth around the credential checks, not the authentication. + +**Consequences**: One host-model wrinkle (the REST host binds one address) resolved inside the gear; compose/dev without NetworkPolicies still holds via edge 404 + credential checks. + +### DD-AUTH-07: Access-Control Claims Fetched Once, at Login + +**Decision**: Permissions are stored and managed in a separate service (built later). The authenticator makes one request to it at login, bakes the answer into the session record and the linked JWT, and never asks again for that session's lifetime. Until it exists, `default_roles` apply. + +**Why**: Keeps the authenticator out of the authorization business; keeps the hot path free of permission lookups; the instant-propagation lever (revoke sessions on grant change) is already in the design and becomes part of the permissions service's contract from day one. + +**Consequences**: Worst-case staleness = the 8 h absolute cap, in practice bounded by revoke-on-change. If login-time-only ever proves too stale, re-fetching on JWT reissue (once per TTL) is a drop-in upgrade inside the authenticator -- no contract change. + +### DD-AUTH-08: Empty-Table First-Admin Bootstrap plus INSTALLER + +**Decision**: Ship both bootstrap ways: guardrailed empty-table first-admin (on by default, off-switchable) and the INSTALLER as the documented production path. + +**Why**: An empty persons table otherwise deadlocks every fresh install (a known operational scar); way 1 makes dev/demo installs self-healing; way 2 formalizes seeding that must happen anyway and closes way 1's window by populating the table. + +**Consequences**: The documented race ("first colleague to log in wins the universe") is bounded to IdP-authenticated principals on an empty install and is loudly audited; security-sensitive installs disable it. + +### OPEN: EdDSA vs ES256 for the Gateway JWT + +The deleted spec mandated EdDSA (DD-BFF-05: small signatures, fast verify), but the existing downstream verifier (oidc-authn-plugin) validates RS256/ES256 today. Either extend the plugin for EdDSA (`jsonwebtoken` supports it -- a small, upstreamable change) or mint ES256 and change nothing anywhere; both satisfy the original rationale; leaning ES256 for zero friction unless the plugin extension proves trivial. **Recorded as open; resolved in implementation step 04 of the nginx + authorization plan, before any signing code lands.** + +## 6. Traceability + +- **PRD**: [PRD.md](./PRD.md) +- **Sibling**: [Gateway DESIGN](../gateway/DESIGN.md) -- the nginx edge: routing, exchange cache, subrequest contract consumer +- **Parent**: [Backend PRD](../specs/PRD.md), [Backend DESIGN](../specs/DESIGN.md) +- **ADRs**: [ADR/](./ADR/) -- to be authored alongside implementation; decisions captured inline in section 5 until then, including carried-over and superseded decisions from the deleted `api-gateway/` spec tree +- **Decision document**: the nginx + authorization analysis (workspace-level) that mandated this architecture diff --git a/docs/components/backend/authenticator/PRD.md b/docs/components/backend/authenticator/PRD.md new file mode 100644 index 000000000..f39038570 --- /dev/null +++ b/docs/components/backend/authenticator/PRD.md @@ -0,0 +1,758 @@ +--- +status: proposed +date: 2026-07-06 +--- + +# PRD -- Authenticator Service + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) +- [2. Actors](#2-actors) + - [2.1 Human Actors](#21-human-actors) + - [2.2 System Actors](#22-system-actors) +- [3. Operational Concept & Environment](#3-operational-concept--environment) + - [3.1 Module-Specific Environment Constraints](#31-module-specific-environment-constraints) +- [4. Scope](#4-scope) + - [4.1 In Scope](#41-in-scope) + - [4.2 Out of Scope](#42-out-of-scope) +- [5. Functional Requirements](#5-functional-requirements) + - [5.1 OIDC Login Flow](#51-oidc-login-flow) + - [5.2 Session Identity and Credential Split](#52-session-identity-and-credential-split) + - [5.3 Session Cookie](#53-session-cookie) + - [5.4 Session Refresh](#54-session-refresh) + - [5.5 Session Store](#55-session-store) + - [5.6 Linked Gateway JWT](#56-linked-gateway-jwt) + - [5.7 Cookie-to-JWT Exchange](#57-cookie-to-jwt-exchange) + - [5.8 JWKS Publication](#58-jwks-publication) + - [5.9 Session Management](#59-session-management) + - [5.10 Logout](#510-logout) + - [5.11 CSRF Protection](#511-csrf-protection) + - [5.12 IdP Token Lifecycle](#512-idp-token-lifecycle) + - [5.13 Service Tokens](#513-service-tokens) + - [5.14 Bootstrap of a Fresh Install](#514-bootstrap-of-a-fresh-install) + - [5.15 Internal Endpoint Reachability](#515-internal-endpoint-reachability) +- [6. Non-Functional Requirements](#6-non-functional-requirements) + - [6.1 NFR Inclusions](#61-nfr-inclusions) + - [6.2 NFR Exclusions](#62-nfr-exclusions) +- [7. Public Library Interfaces](#7-public-library-interfaces) + - [7.1 Public API Surface](#71-public-api-surface) + - [7.2 External Integration Contracts](#72-external-integration-contracts) +- [8. Use Cases](#8-use-cases) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Dependencies](#10-dependencies) +- [11. Assumptions](#11-assumptions) +- [12. Risks](#12-risks) + + + +## 1. Overview + +### 1.1 Purpose + +The authenticator is the auth core of Insight: a standalone service that implements the BFF / token-handler pattern. It runs the OIDC login flow against the customer's identity provider, holds the user session server-side in Redis, and exposes a small `/auth/*` API the SPA uses to log in, refresh, and log out. IdP tokens never leave the authenticator; the browser holds only an opaque session cookie. + +At login the authenticator mints a signed **gateway JWT linked 1:1 to the session** -- the complete, signed description of the request author (`sub` = person_id, `tenants`, `roles`, `sid`). On every API request the nginx gateway (see [Gateway DESIGN](../gateway/DESIGN.md)) exchanges the session cookie for that JWT via an `auth_request` subrequest to `GET /internal/authz`; only the JWT travels to downstream services, which verify it against the authenticator's JWKS. The authenticator also issues **service tokens** for no-user workloads, so downstream services keep exactly one verification path. + +### 1.2 Background / Problem Statement + +Today the SPA runs the OIDC flow itself with `oidc-client-ts`, keeps IdP tokens in browser `sessionStorage`, and sends `Authorization: Bearer ` on every request. Any XSS leaks every active token; tokens are visible to extensions and dev tools; revocation requires waiting out the IdP token lifetime; and downstream services trust an unsigned `X-Insight-Tenant-Id` header for tenant context. + +The previously specified remedy -- a single Rust API Gateway binary combining a BFF module with a custom reverse-proxy Router module -- was removed by decision (see the nginx + authorization decision document): the proxy half is commodity work nginx does better, while the BFF half is the hard, security-critical, custom part. The authenticator is that BFF half, kept as a standalone service reached by nginx subrequests, with deliberate changes: the JWT is minted at login and linked to the session (not lazily per request), it carries `tenants` and `roles` claims (superseding the identity-only contract), IdP tokens are refreshed in the background (reversing the earlier "no IdP refresh in v1" carve-out), and the session's stable identity is split from its rotating cookie credential. + +### 1.3 Goals (Business Outcomes) + +- Remove all IdP and access tokens from browser storage; the browser holds one opaque cookie. +- Make sessions revocable per-session and per-user from a single store, with gateway-visible effect bounded by a configurable cache max-age (default 30 s). +- Give every internal service a verifiable, short-lived, signed identity claim per request -- for user traffic and service-to-service traffic alike. +- Propagate IdP-side account deactivation within roughly one IdP access-token lifetime even when the customer IdP has no back-channel logout. +- Keep the SPA simple: no token handling code in the browser. + +### 1.4 Glossary + +| Term | Definition | +|------|------------| +| Session | The server-side login of one user on one device, born at `/auth/callback`, dead at logout/revoke/expiry. Identified by a **stable `session_id`** (UUIDv7) that never changes across cookie rotations. | +| Session token | The opaque cookie value -- a rotating **credential** mapping to the `session_id`. Generated from a CSPRNG; no claims, no meaning outside Redis. Rotation writes a new mapping and lets the old one expire after a grace TTL. | +| Gateway JWT | Short-lived signed JWT minted by the authenticator **at login**, stored server-side, linked 1:1 to the session, reissued ahead of expiry. The only credential downstream services ever see. | +| Exchange | The gateway's `auth_request` subrequest to `GET /internal/authz`: session cookie in, `X-Gateway-Jwt` header out. | +| Service token | A gateway JWT with `sub = service:`, issued at `POST /internal/token` against an RFC 7523 signed assertion, for workloads with no user context. | +| IdP tokens | Tokens issued by the customer's identity provider. Stored only inside the authenticator's session record; refreshed by a background worker; never sent to the browser or downstream services. | +| Downstream service | Any internal Insight service behind the gateway (Analytics API, Identity Service, etc.). Verifies the gateway JWT itself -- mandatory, fail closed, no production disable knob. | + +## 2. Actors + +### 2.1 Human Actors + +#### Browser User + +**ID**: `cpt-insightspec-actor-browser-user` + +**Role**: Any authenticated end user accessing Insight through the SPA. +**Needs**: Log in, stay logged in across requests, log out, see active sessions, revoke a session from another device. + +#### Tenant Administrator + +**ID**: `cpt-insightspec-actor-tenant-admin` + +**Role**: Already defined in the parent backend PRD. Additionally needs to revoke any user's sessions (forced logout on role change, offboarding, suspected compromise). + +### 2.2 System Actors + +#### OIDC Provider + +**ID**: `cpt-insightspec-actor-oidc-provider` + +**Role**: Customer identity provider. Runs the authorization code + PKCE flow, issues refresh tokens, may call back-channel logout. + +#### Nginx Gateway + +**ID**: `cpt-insightspec-actor-nginx-gateway` + +**Role**: The edge reverse proxy (see [Gateway DESIGN](../gateway/DESIGN.md)). Calls `GET /internal/authz` per request (through its exchange cache), forwards `/auth/*` traffic verbatim, injects the returned JWT upstream. + +#### Downstream Service + +**ID**: `cpt-insightspec-actor-downstream-service` + +**Role**: Any internal Insight service that receives the gateway JWT and authorizes the request from its signed claims. Also a client of `POST /internal/token` for background work. + +#### Redis + +**ID**: `cpt-insightspec-actor-redis` + +**Role**: Stores session records, token mappings, linked JWTs, and indexes. The single source of truth for "who is logged in". + +## 3. Operational Concept & Environment + +### 3.1 Module-Specific Environment Constraints + +- Single deployment per Insight installation, reached only through the nginx gateway for browser traffic; TLS terminates at the ingress in front of the gateway. +- The authenticator and the SPA share one public hostname (required by the `__Host-` cookie prefix); the SPA is routed through the gateway. +- Stateless and horizontally scalable -- all session state is in Redis. Background workers (IdP refresher, janitor) elect one leader via a Redis lock. +- Two listeners: the main port (`/auth/*`, JWKS, `/internal/authz`) is network-scoped to the gateway pods; the token port (`POST /internal/token`) is reachable from application-namespace service pods. See 5.15. +- The service name is deliberately dash-free (`authenticator`) so the `APP__gears__authenticator__config__*` environment override convention works in docker compose. + +## 4. Scope + +### 4.1 In Scope + +- OIDC authorization code + PKCE login flow as a confidential client, with session-fixation guard. +- Opaque session cookie with short hard TTL, hardened attributes, and rotation-with-grace on explicit refresh. +- Stable-session-identity / rotating-credential split of the session model. +- Session record storage in Redis with per-user index, IdP-sid index, and atomic pipelines. +- Gateway JWT minted at login, linked 1:1 to the session, reissued ahead of expiry; claim contract with `sub`, `tenants`, `roles`, `sid`. +- Cookie-to-JWT exchange endpoint `GET /internal/authz` for the gateway's `auth_request`, including the response caching contract. +- JWKS publication at `/.well-known/jwks.json`. +- Session listing and revocation (single, all-but-current, all; admin-initiated). +- Logout: local, RP-initiated, and OIDC back-channel receiver with `jti` replay guard. +- CSRF defense for state-changing `/auth/*` requests. +- Background IdP token refresh per session; definitive refusal kills all linked sessions. +- Service tokens at `POST /internal/token` against an RFC 7523 assertion and a gitops-reviewable service registry. +- First-admin bootstrap on a fresh install (guardrailed, off-switchable). +- Rate limiting layer 2 (login-state cap and per-session/user token buckets); audit events; expired-index janitor. + +### 4.2 Out of Scope + +- Reverse-proxying, route tables, header rewriting, response streaming -- owned by the [nginx gateway](../gateway/DESIGN.md). +- Authorization decisions inside downstream services (each service still enforces RBAC and visibility from signed claims). +- Permissions storage and management -- a separate permissions service, built later. Until it exists every JWT carries the configured default roles. +- User registration, password management, MFA -- handled by the customer OIDC provider. +- WebSocket lifetime enforcement -- deferred to the downstream WS handler when a WS feature lands; no WebSocket code exists in the stack today. +- Mobile or third-party API clients (v1 serves the bundled SPA and internal services). + +## 5. Functional Requirements + +### 5.1 OIDC Login Flow + +#### Authorization Code with PKCE + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-oidc-login` + +The system **MUST** implement OIDC authorization code flow with PKCE as a confidential client. The authenticator **MUST** generate `state`, `nonce`, and PKCE verifier per login attempt and validate them on callback. The browser **MUST NOT** receive or transmit the IdP code, ID token, access token, or refresh token at any point. + +The new session token issued at the end of a successful callback **MUST** be generated server-side from a CSPRNG and **MUST NOT** be derived from, or equal to, any value present in the incoming request (cookies, headers, query). Any `__Host-sid` cookie present on the `/auth/callback` request **MUST** be ignored; if its value maps to a live session in Redis, that session **MUST** be revoked before the new session is created. This prevents session fixation where an attacker plants a known token before the victim logs in. + +At login the system **MUST** resolve the authenticated person via Identity Service (`sub` to `person_id` plus tenant memberships) and **MUST** fetch access-control claims once, from the permissions service when it exists; until then the configured `authenticator.default_roles` apply. + +**Rationale**: The whole point of the redesign -- IdP tokens never leave the server; claims are resolved once, at login, and baked into the session. + +**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-oidc-provider` + +### 5.2 Session Identity and Credential Split + +#### Stable Session Identity, Rotating Credential + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-session-model` + +The system **MUST** separate the session's identity from its credential: + +1. **`session_id`** -- stable, internal, generated as UUIDv7 at login, unchanged until logout/revoke/expiry. Everything server-side keys on it: the session record, the linked JWT, the per-user index, the IdP-sid index, audit, and the JWT's `sid` claim. +2. **Session token** (the cookie value) -- a rotating credential stored only as a mapping from token to `session_id`. Refresh rotation writes a new mapping and lets the old mapping expire after a grace TTL. Nothing else moves. + +The system **MUST NOT** use the cookie value as a Redis session key, **MUST NOT** rename session records on rotation, and **MUST NOT** maintain a separate swap/grace key family -- the expiring old mapping is the grace window. + +**Rationale**: The earlier spec conflated cookie value, session id, Redis key, and `sid` claim, which forced a rename-based rotation pipeline and a stale-`sid` problem. The split removes both: the JWT never carries a stale `sid`, and rotation is one write. + +**Actors**: `cpt-insightspec-actor-browser-user` + +### 5.3 Session Cookie + +#### Session Cookie Issuance + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-session-cookie` + +After a successful OIDC callback, the system **MUST** issue an opaque session cookie with these attributes: + +- `__Host-` prefix (forces host-only + Secure + Path=/). +- `HttpOnly`. +- `Secure`. +- `SameSite=Strict`. +- Random value with at least 128 bits of entropy. +- Short hard TTL, configurable (`authenticator.session_ttl_seconds`, default 600 seconds). The cookie `Max-Age` **MUST** match the token mapping TTL in Redis. +- The TTL **MUST NOT** be extended automatically by activity. Only an explicit `POST /auth/refresh` extends it (see 5.4). +- An absolute hard cap (`authenticator.session_absolute_lifetime_seconds`, default 8 h) **MUST** apply across refreshes -- once reached, refresh fails and the user must log in again. + +The cookie value **MUST** be opaque -- no claims, no JWT, no user-identifying data. + +**Rationale**: Short TTL plus explicit refresh limits the window for stolen-cookie reuse; the absolute cap forces re-authentication on a known schedule. + +**Actors**: `cpt-insightspec-actor-browser-user` + +### 5.4 Session Refresh + +#### Explicit Session Refresh Endpoint + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-session-refresh` + +The system **MUST** expose `POST /auth/refresh`. The cookie value rotates on every successful refresh. Behaviour: + +1. **Unknown token / no session** -- 401, clear the cookie. +2. **Token maps to a live session** (normal path): generate a fresh session token (CSPRNG, at least 128 bits); compute `new_exp = min(now + session_ttl, absolute_expires_at)`; write the new token mapping; shorten the old mapping's TTL to the rotation grace (`authenticator.refresh_grace_ms`, default 250 ms); update the session record's `expires_at`, its Redis TTL, and the per-user index score -- all in one pipeline. Re-issue the cookie and return `200 {expires_at, refresh_at}`. +3. **Token maps to a session already rotated past** (grace path): the old mapping still resolves to the same `session_id` during the grace TTL; return the current cookie value and `200 {expires_at, refresh_at}` without rotating again. + +`refresh_at` is server-supplied: `expires_at - safety_margin + jitter`, with `safety_margin` = `authenticator.session_refresh_safety_margin_seconds` (default 90 s) and full jitter window `authenticator.refresh_jitter_seconds` (default 120 s, uniform +/- 60 s), re-jittered on every refresh. The jitter is deliberately big: it spreads refresh load from NAT'd offices into a uniform trickle and keeps an attacker from aligning to the rotation grace window; the late jitter edge still leaves at least 30 s of session life. + +The system **MUST NOT** extend the session on any other endpoint. A stale cookie on `/internal/authz` returns 401 immediately -- the SPA must call `/auth/refresh` first. Session refresh **MUST NOT** touch the linked JWT: the JWT is keyed by the stable `session_id` and has its own reissue cycle (see 5.6). + +`GET /auth/me` **MUST** return the same `{expires_at, refresh_at}` fields (freshly jittered) so the SPA can prime its refresh timer at page load. + +**SPA contract.** The SPA **MUST** coordinate `/auth/refresh` across browser tabs (single leader via `BroadcastChannel`, `localStorage` fallback) and **MUST** schedule the next refresh from the server-supplied `refresh_at`. + +**Rationale**: Cookie rotation makes stolen-credential reuse noisy and short-lived; the grace mapping absorbs benign races; the big jitter both spreads load and defeats grace-window alignment. + +**Actors**: `cpt-insightspec-actor-browser-user` + +### 5.5 Session Store + +#### Redis-Backed Session Storage + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-session-store` + +The system **MUST**: + +1. **Persist sessions** -- record every active session server-side with all fields needed to validate, refresh, and revoke it (person, tenants, roles snapshot, IdP linkage, IdP refresh token and expiries, timestamps, hard cap, CSRF token). Key family `asm:session:{session_id}`. +2. **Maintain the token-credential mapping** `asm:token:{token}` to `session_id`, TTL-bounded (see 5.2, 5.4). +3. **Store the linked JWT** at `asm:jwt:{session_id}` (see 5.6). +4. **Maintain a per-user session index** for "list my devices" and "log out everywhere" in sub-linear time. Key family `asm:user_sessions:{person_id}`. +5. **Maintain an IdP-sid lookup** resolving `(iss, idp_sid)` from back-channel logout tokens to local sessions. Key family `asm:sid_index:*`. +6. **Maintain the IdP refresh schedule** `asm:idp_refresh_due` so the background refresher can find sessions due for refresh without scanning (see 5.12). +7. **Make create / refresh / revoke atomic** -- session record, token mapping, linked JWT, and all indexes change in one pipeline; a partial failure **MUST NOT** leave them out of sync. Revocation deletes session **and** linked JWT together. +8. **Run a periodic janitor** that trims expired entries from the indexes and emits a drift metric. + +The exact Redis schema is specified in [DESIGN section 3.7](./DESIGN.md#37-database-schemas--tables). + +**Rationale**: Server-side storage is what makes sessions revocable; the indexes make listing, revocation, back-channel logout, and scheduled IdP refresh fast; atomicity prevents zombie state. + +**Actors**: `cpt-insightspec-actor-redis` + +### 5.6 Linked Gateway JWT + +#### Login-Minted, Session-Linked JWT + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-linked-jwt` + +The system **MUST** mint the gateway JWT at `/auth/callback`, in the same pipeline that creates the session, and store it keyed by the stable `session_id`. From then on the JWT is **reissued ahead of expiry**: while its age is under `authenticator.jwt_reissue_after_seconds` (default 240 s of the 300 s TTL) the stored JWT is served as-is; past that age the system rebuilds claims from the session record, signs a fresh JWT, and stores it with `SET ... NX EX` so parallel requests converge on one canonical JWT (stampede-safe). + +**Guarantee**: the gateway never receives a JWT with less than `jwt_ttl - jwt_reissue_after` (default 60 s) of validity left -- a request can sit in a queue, retry, or travel across services for a minute and still verify downstream. + +The JWT **MUST** carry exactly: + +| Claim | Value | +|---|---| +| `sub` | internal **person_id** (or `service:` for service tokens, see 5.13) | +| `tenants` | array of tenant ids the person belongs to (1..N), resolved at login. The JWT is the only tenant **authority**; per-request **selection** among them is an unsigned attribute (e.g. `X-Tenant-ID`) that downstream validates against this signed set | +| `roles` | present from day one, default from `authenticator.default_roles` (`["user"]`). Once the separate permissions service exists, its answer -- fetched once at login -- replaces the default. Claim shape is fixed now so extending costs nothing | +| `sid` | the **stable** `session_id` (UUIDv7) -- survives cookie rotations; one id from login to logout for tracing, audit, and the JWT/session linkage | +| `iss`, `aud`, `iat`, `exp`, `jti` | `exp = iat + 60..300 s`; `jti` UUIDv7 | + +Claim freshness: access-control claims are a login-time snapshot stored in the session record; reissue rebuilds the JWT with fresh `iat`/`exp`/`jti` and the same claims. Permission changes propagate on re-login, or immediately if the permissions service revokes the user's sessions on change. + +Session revoke/logout **MUST** delete the session and the linked JWT in one pipeline. Signing keys are a mounted secret with `current` + `previous` overlap for rotation; the signature algorithm decision (EdDSA vs ES256) is recorded as open in [DESIGN section 5](./DESIGN.md#5-design-decisions). + +**Rationale**: An eagerly minted, session-linked JWT gives every request a complete signed author description with zero per-request minting on the hot path, and supersedes the earlier identity-only, lazily-minted contract. + +**Actors**: `cpt-insightspec-actor-downstream-service`, `cpt-insightspec-actor-nginx-gateway` + +### 5.7 Cookie-to-JWT Exchange + +#### `/internal/authz` Exchange Endpoint + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-authz-exchange` + +The system **MUST** expose `GET /internal/authz` on the main listener as the gateway's `auth_request` target: + +1. Read the `__Host-sid` cookie, resolve `asm:token:{token}` to `session_id`, load the session; on miss return **401**. +2. On hit, read the linked JWT; serve it as-is while fresh, reissue ahead of expiry per 5.6. +3. Respond `200` with the JWT in the `X-Gateway-Jwt` response header (`Bearer `). +4. A `200` response **MUST** carry `Cache-Control: max-age = min(authz_cache_max_age, jwt_exp - now - 60 s)` so the gateway-side exchange cache can never serve a JWT past its travel margin. Any non-200 response **MUST** carry `Cache-Control: no-store` -- a cached 401 would lock out a user who just logged in. + +The response carries no correlation id -- it is cacheable, so per-request correlation ids are generated at the edge (see [Gateway DESIGN](../gateway/DESIGN.md)). + +**Rationale**: This endpoint replaces the deleted Router's in-process session check + JWT injection; the Cache-Control contract keeps the authenticator in control of gateway-side staleness (revocation takes effect at the gateway within `authz_cache_max_age`, default 30 s). + +**Actors**: `cpt-insightspec-actor-nginx-gateway` + +### 5.8 JWKS Publication + +#### JWKS Endpoint + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-jwks` + +The system **MUST** publish the public verification key set at `GET /.well-known/jwks.json` (RFC 7517), served through the gateway. Each downstream service is configured with the absolute JWKS URL (Helm value, env `GATEWAY_JWKS_URL`); services fetch at startup, cache, and re-fetch on unknown `kid`. Key rotation keeps `current` + `previous` published for the documented overlap window. + +**Rationale**: Downstream services verify signatures with no shared secrets and no service discovery. + +**Actors**: `cpt-insightspec-actor-downstream-service` + +### 5.9 Session Management + +#### List Active Sessions + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-session-list` + +The system **MUST** expose an authenticated endpoint returning the calling user's active sessions (created_at, expires_at, user_agent, ip, current=true/false), read from the per-user index with score > now. + +**Actors**: `cpt-insightspec-actor-browser-user` + +#### Revoke Sessions + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-session-revoke` + +The system **MUST** support: (1) revoke the current session (logout); (2) revoke a specific other session; (3) revoke all sessions for a user (self "log out everywhere", admin-initiated, or permissions-service-initiated on grant change). Each operation deletes the session record, the linked JWT, the token mapping(s), and the index entries atomically. + +Revocation is instant at the authenticator. At the gateway it takes effect within the exchange-cache max-age (default 30 s); any in-flight JWT dies within its own `exp` (at most 300 s). + +The admin surface (revoke by user) **MUST** itself require a valid gateway JWT with an authorized role -- the authenticator verifies its own tokens exactly like any downstream service. + +**Rationale**: Instant revocation is the reason sessions are opaque and server-side; the admin path is how the future permissions service forces claim refresh. + +**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-tenant-admin`, `cpt-insightspec-actor-downstream-service` + +### 5.10 Logout + +#### Logout (Local, RP-Initiated, Back-Channel) + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-logout` + +The system **MUST** provide `POST /auth/logout` that revokes the current session, clears the cookie (`Max-Age=0`), and redirects (or returns a redirect URL) to the OIDC `end_session_endpoint` for RP-initiated logout. + +The system **MUST** accept OIDC back-channel logout tokens at a dedicated endpoint, validate the `logout_token` per spec, locate sessions by `(iss, sid)` (via the sid index) or `(iss, sub)` (resolve via Identity Service, then walk the per-user index), and revoke them. + +The system **MUST** protect the back-channel endpoint against replay: every accepted `logout_token` **MUST** be recorded by `(iss, jti)` with a TTL of at least `iat + max_clock_skew`, and any subsequent delivery of the same `(iss, jti)` **MUST** short-circuit to a successful response without performing another revoke. + +The system **MUST** document and accept that a `logout_token` carrying only `sub` (no `sid`) revokes every active session for that user ("log out everywhere") -- OIDC-spec-compliant fallback, called out in the runbook so a misconfigured IdP does not silently widen blast radius. + +**Rationale**: Back-channel logout is the fast path for IdP-side termination (the background refresher is the guaranteed path, see 5.12); `jti` replay protection stops a captured logout token from being replayed as a DoS. + +**Actors**: `cpt-insightspec-actor-oidc-provider`, `cpt-insightspec-actor-browser-user` + +### 5.11 CSRF Protection + +#### CSRF Defense + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-csrf` + +For state-changing methods (POST, PUT, PATCH, DELETE) on `/auth/*`, the system **MUST** require either: + +1. A double-submit CSRF token sent in `X-CSRF-Token` matching a value bound to the session, or +2. A verified `Origin` header matching the configured SPA origin. + +`SameSite=Strict` is the primary defense; this requirement is the second line. + +**Rationale**: Defense in depth is cheap and protects against same-site-but-different-path vectors. + +**Actors**: `cpt-insightspec-actor-browser-user` + +### 5.12 IdP Token Lifecycle + +#### Background IdP Token Refresh + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-idp-refresh` + +The session must not outlive the IdP's willingness to vouch for the user. Therefore, deliberately reversing the earlier "no IdP token refresh in v1" carve-out: + +1. At login the system **MUST** store the IdP **refresh token** (and access-token expiry) in the session record, alongside the `id_token`. +2. A **background worker** (one leader elected via Redis lock) **MUST** refresh each session's IdP tokens `authenticator.idp.refresh_safety_margin_seconds` before expiry and store the rotated refresh token back. A per-session lock **MUST** guard each refresh: most IdPs rotate refresh tokens one-time-use, and two pods racing the same rotation would burn the grant and falsely kill the session. +3. The worker **MUST** find due sessions via the `asm:idp_refresh_due` schedule (no scanning), with due-times jittered at write so sessions do not herd after a deploy or Redis restore. In-flight refreshes are capped by `authenticator.idp.refresh_concurrency` (default 128) -- politeness toward the customer IdP; IdP `429` is handled as transient honoring `Retry-After`. +4. **Definitive refusal** (`invalid_grant`: revoked, expired, user disabled) **MUST** kill every session linked to that grant through the same revoke pipeline as logout. **Transient failures** (IdP unreachable, timeout, 5xx) **MUST NOT** kill sessions: retry with backoff until success or a definitive verdict. Fail open on transport, fail closed on verdict. +5. When the IdP issues no refresh token, `authenticator.idp.no_refresh_token_policy` applies: `strict` (default) caps the session at the IdP access-token lifetime; `login_only` lets sessions live to the absolute cap, killed only by back-channel logout or manual revoke. +6. The system **MUST** emit metrics for refresh outcomes, a consecutive-transient-failure gauge, and an `invalid_grant` counter -- alert before the mass logout, not after. + +**Rationale**: IdP-side deactivation propagates within roughly one IdP access-token lifetime even if the customer IdP has no back-channel logout; a five-minute IdP blip must not log out the entire installation. + +**Actors**: `cpt-insightspec-actor-oidc-provider`, `cpt-insightspec-actor-redis` + +### 5.13 Service Tokens + +#### Service Token Issuance + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-service-tokens` + +For workloads with no user context (background jobs, seeds, the future permissions service), the system **MUST** expose `POST /internal/token` on the dedicated token listener: + +1. The caller proves its identity with a short-lived signed assertion (`private_key_jwt`, RFC 7523): `iss = sub = `, `aud = authenticator`, `jti`, `exp` at most 60 s. +2. The authenticator validates the signature against a **service registry** -- a gitops-reviewable config mapping service name to public key(s), allowed extra roles, and whether the service may request a tenant-scoped token. Onboarding a service is a PR adding its public key; rotation ships key n+1 alongside n. +3. The assertion `jti` **MUST** be replay-guarded (same `SET NX` pattern as the back-channel logout guard). Every issuance is audited. +4. Out comes a **normal gateway JWT**: `sub = service:`, `roles: ["service", ...]` per the registry, TTL 300 s, signed with the same key, published in the same JWKS -- downstream services keep exactly one verification path for user and service traffic. A tenant-scoped request sets `tenants: [t]`; no `tenants` claim means cross-tenant service work, which downstream authorizes against the `service` role. +5. Services cache the token and re-request before expiry -- the same reissue-ahead pattern as everything else. + +User-context fan-out (service A calls service B while serving a user request) does **not** use this endpoint: internal services propagate the incoming `Authorization` header on outbound calls made on behalf of the request -- that is what the 60 s reissue-ahead travel margin buys. + +JWT-free internal endpoints behind NetworkPolicy alone are **rejected**: every service verifies the JWT, no exceptions; network position is never trust. + +**Rationale**: No secret in transit, reviewable onboarding, one verification path. + +**Actors**: `cpt-insightspec-actor-downstream-service` + +### 5.14 Bootstrap of a Fresh Install + +#### First Login on an Empty Install + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-bootstrap` + +Login resolves the person via Identity Service; unknown person means 403. A fresh install has an empty persons table -- nobody could ever log in. The system **MUST** support two composable ways in: + +1. **Empty table, first admin** (`authenticator.bootstrap_first_admin`, default `true`): if the persons table is empty at login, the IdP-authenticated person is auto-created with the universe-admin role. Guardrails: it admits only someone who authenticated at the customer's IdP; it is active **only while the table is empty** -- the window closes permanently on the first created person; every use emits a loud audit event and log line. Security-sensitive installs can turn it off. +2. **INSTALLER** -- a separate setup component that populates identity before first login (from AD, Okta, CSV, etc.) with explicit admin designation. This is the production path; the persons table must be populated anyway for the product to function. + +**Rationale**: Way 1 makes dev, demos, and honest mistakes self-healing; way 2 is the documented production install step, after which way 1's window is closed anyway. + +**Actors**: `cpt-insightspec-actor-tenant-admin`, `cpt-insightspec-actor-oidc-provider` + +### 5.15 Internal Endpoint Reachability + +#### Two Listeners, Network Scopes, Credentials Everywhere + +- [ ] `p1` - **ID**: `cpt-insightspec-fr-auth-internal-reachability` + +`/internal/authz` turns a stolen cookie value into a signed JWT, so its reachability is layered: + +1. **Edge**: `/internal/*` never routes through the gateway (generated 404) and the ingress has a single backend -- the gateway -- so nothing external can name the authenticator. +2. **Network**: the authenticator **MUST** serve two listeners with different scopes. Main port (`/auth/*`, JWKS, `/internal/authz`): NetworkPolicy admits ingress from gateway pods only. Token port (`POST /internal/token` only): ingress from application-namespace service pods. +3. **Credential**: each endpoint still authenticates its caller regardless of network position -- `/internal/authz` requires a live session cookie; `/internal/token` requires a valid registry assertion. **Network position is never authentication.** + +Compose/dev has no NetworkPolicies; acceptable -- layers 1 and 3 still hold there. + +**Rationale**: Defense in depth around the one endpoint that exchanges a bearer credential for a signed one. + +**Actors**: `cpt-insightspec-actor-nginx-gateway`, `cpt-insightspec-actor-downstream-service` + +## 6. Non-Functional Requirements + +### 6.1 NFR Inclusions + +#### Session Lookup Latency + +- [ ] `p2` - **ID**: `cpt-insightspec-nfr-auth-exchange-p95` + +The `/internal/authz` exchange (token mapping + session + JWT reads) **MUST** complete within 5 ms p95 under normal load, keeping total gateway overhead comfortably inside the 15 ms p95 budget the deleted gateway spec carried. + +**Threshold**: 5 ms p95 for the exchange; two Redis reads on the hot path. + +#### Session TTL Bounds + +- [ ] `p1` - **ID**: `cpt-insightspec-nfr-auth-session-ttl` + +Session TTL and absolute lifetime **MUST** be configurable via Helm values without code change. Defaults: 600 s TTL (reasonable range 300-600 s), 8 h absolute cap. + +**Threshold**: Operator can set both knobs via Helm values; they take effect on rolling restart. + +#### Cookie Hardening + +- [ ] `p1` - **ID**: `cpt-insightspec-nfr-auth-cookie-attrs` + +Every session cookie response **MUST** include `__Host-` prefix, `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`, and no `Domain` attribute. A code path that would set a session cookie without all of these **MUST** fail closed. + +**Threshold**: 100% of session-cookie responses match the attribute set. + +#### Audit of Auth Events + +- [ ] `p1` - **ID**: `cpt-insightspec-nfr-auth-audit` + +Every login, logout, session refresh, session revocation, back-channel logout, IdP-refresh verdict (`invalid_grant` kill), service-token issuance, and bootstrap-admin creation **MUST** emit an audit event consumed by the Audit Service. + +**Threshold**: 100% coverage of auth events. + +#### Rate Limiting on `/auth/*` (Layer 2) + +- [ ] `p1` - **ID**: `cpt-insightspec-nfr-auth-rate-limit` + +The gateway carries a coarse per-IP flood guard (layer 1). The authenticator **MUST** enforce the precise layer: a Redis token bucket keyed by session/user (not IP -- corporate NAT makes per-IP limits at the precise layer wrong), and a global cap on concurrent live `asm:login_state:*` entries (default 1000 per pod) rejecting excess `/auth/login` with 429 before any Redis write. + +**Threshold**: under a sustained login flood, login-state entries stay at or below the cap and CPU is bounded. + +#### Fail Closed on Redis + +- [ ] `p1` - **ID**: `cpt-insightspec-nfr-auth-fail-closed` + +If Redis is unreachable, `/internal/authz` and `/auth/*` mutations **MUST** fail (401/503) and the readiness probe **MUST** fail. No local session cache, no degraded mode. + +**Threshold**: Zero exchanges served without a live Redis session read. + +### 6.2 NFR Exclusions + +- **HTTPS enforcement / HSTS**: owned by the nginx gateway, the component all traffic crosses (see [Gateway DESIGN](../gateway/DESIGN.md)). The authenticator is plain HTTP behind it, network-scoped per 5.15. +- **Per-route rate limiting on `/api/*`**: gateway `limit_req` (coarse) plus per-service middleware; not an authenticator concern. +- **Gateway latency budget**: the end-to-end 15 ms p95 budget is allocated across gateway + exchange; the authenticator's share is pinned by `cpt-insightspec-nfr-auth-exchange-p95`. + +## 7. Public Library Interfaces + +### 7.1 Public API Surface + +#### Auth API + +- [ ] `p1` - **ID**: `cpt-insightspec-interface-auth-api` + +**Type**: REST API + +**Stability**: stable + +**Endpoints** (main listener unless noted): + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/auth/login` | Start OIDC flow; 302 to IdP. | +| GET | `/auth/callback` | OIDC callback; creates session + linked JWT; sets cookie; 302 to SPA. | +| POST | `/auth/refresh` | Rotate cookie, extend session TTL; return `{expires_at, refresh_at}`. | +| POST | `/auth/logout` | Revoke current session; clear cookie; return RP-logout URL. | +| GET | `/auth/me` | Current user, tenants, plus `{expires_at, refresh_at}`. | +| GET | `/auth/sessions` | List active sessions for current user. | +| DELETE | `/auth/sessions/{id}` | Revoke a specific session. | +| DELETE | `/auth/sessions` | Revoke all sessions of current user. Admin/service variant revokes by user id (gateway-JWT authenticated). | +| POST | `/auth/oidc/back-channel-logout` | Receive IdP back-channel logout tokens. | +| GET | `/auth/csrf` | Issue CSRF token bound to current session. | +| GET | `/internal/authz` | Cookie-to-JWT exchange for the gateway `auth_request` (never routed externally). | +| GET | `/.well-known/jwks.json` | Public keys for JWT verification. | +| POST | `/internal/token` | Service-token issuance (dedicated token listener). | + +All endpoints are registered through the toolkit operation builder and land in the generated OpenAPI document, which is the machine-checkable form of the gateway subrequest contract. + +### 7.2 External Integration Contracts + +#### Gateway JWT Claim Contract + +- [ ] `p1` - **ID**: `cpt-insightspec-contract-auth-gateway-jwt` + +**Direction**: defined and minted by the authenticator, consumed by every downstream service. + +**Format**: signed JWT (algorithm decision EdDSA vs ES256 open, see [DESIGN section 5](./DESIGN.md#5-design-decisions)). + +**Claims**: `iss`, `aud`, `sub`, `iat`, `exp`, `jti` plus `tenants`, `roles`, `sid` -- exactly as specified in 5.6. + +**Compatibility**: Additive custom claims only without a major version. The permissions service later changes claim *values* (roles), never the contract shape. + +#### Authz Exchange Contract + +- [ ] `p1` - **ID**: `cpt-insightspec-contract-auth-authz-exchange` + +**Direction**: provided to the nginx gateway. + +**Protocol/Format**: HTTP subrequest to `GET /internal/authz`; 200 with `X-Gateway-Jwt: Bearer ` response header; 401 deny; `Cache-Control: max-age = min(authz_cache_max_age, jwt_exp - now - 60 s)` on 200, `no-store` on non-200. + +**Compatibility**: Path, header name, and caching semantics are a versioned contract between the two artifacts; covered by an e2e test (gateway risk R8). + +#### JWKS Distribution Contract + +- [ ] `p1` - **ID**: `cpt-insightspec-contract-auth-jwks-url` + +**Direction**: configuration -- each downstream service is given the JWKS URL. + +**Mechanism**: Helm value / env `GATEWAY_JWKS_URL` pointing at `/.well-known/jwks.json` through the gateway. Services fetch on startup, cache, refetch on unknown `kid`. + +**Compatibility**: URL stable across minor releases; RFC 7517 schema. + +#### OIDC Provider Contract + +- [ ] `p1` - **ID**: `cpt-insightspec-contract-auth-oidc` + +**Direction**: required from customer. + +**Protocol**: OIDC Authorization Code + PKCE; RP-initiated logout; back-channel logout (optional but recommended); **refresh-token issuance to this client** (some IdPs require the `offline_access` scope). When refresh tokens are not granted, `authenticator.idp.no_refresh_token_policy` governs session lifetime. + +**Compatibility**: Standard OIDC. + +#### Service Registry Contract + +- [ ] `p1` - **ID**: `cpt-insightspec-contract-auth-service-registry` + +**Direction**: required from operators (gitops-reviewable config). + +**Protocol/Format**: registry entries mapping service name to public key(s) (for RFC 7523 assertion verification), allowed extra roles, and tenant-scoping permission. Public keys are not secrets; the registry lives in reviewable configuration. + +**Compatibility**: Additive; key rotation ships n+1 alongside n. + +#### Authenticator SDK + +- [ ] `p2` - **ID**: `cpt-insightspec-contract-auth-sdk` + +**Direction**: provided to internal consumers (e.g. the future permissions service). + +**Protocol/Format**: `authenticator-sdk` crate -- the inter-gear contract trait (session revoke, introspection as needed), request/response models, optional typed error projection over canonical errors. Consumers depend on the SDK only, never on the implementation crate. + +**Compatibility**: SemVer on the crate; additive trait evolution. + +## 8. Use Cases + +#### Login + +- [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-login` + +**Actor**: `cpt-insightspec-actor-browser-user` + +**Preconditions**: SPA loaded; no valid session cookie. + +**Main Flow**: +1. SPA calls a protected API; the gateway's exchange returns 401 with a login URL. +2. Browser requests `/auth/login` (plain proxy through the gateway); authenticator stores `state`, `nonce`, PKCE verifier; redirects to IdP. +3. User authenticates at IdP; IdP redirects to `/auth/callback` with the code. +4. Authenticator validates `state`, exchanges the code (PKCE), validates the ID token; stores the IdP refresh token for background refresh. +5. Authenticator resolves person and tenant memberships via Identity Service; fetches access-control claims (default roles until the permissions service exists). +6. Authenticator creates the session (stable `session_id`, UUIDv7), the token mapping, **and mints the linked JWT** -- one pipeline. +7. Authenticator sets the session cookie and redirects to the SPA's original target. + +**Postconditions**: Browser holds an opaque cookie; Redis holds session record, token mapping, linked JWT, index entries, and an IdP-refresh due entry. Audit event recorded. + +**Alternative Flows**: +- **State or nonce mismatch**: 400, no session created. +- **Person not found and table not empty**: 403; audit records the failed login. +- **Person table empty and bootstrap enabled**: first-admin bootstrap per `cpt-insightspec-fr-auth-bootstrap`. + +#### API Request (Cookie In, JWT Out) + +- [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-exchange` + +**Actor**: `cpt-insightspec-actor-nginx-gateway` + +**Preconditions**: Live session; gateway exchange-cache miss for this session token. + +**Main Flow**: +1. Browser sends `GET /api/...` with the session cookie and nothing else. +2. Gateway subrequests `GET /internal/authz`. +3. Authenticator resolves token to session; JWT age under the reissue threshold: returns 200 + `X-Gateway-Jwt` + `Cache-Control: max-age=...`. +4. Gateway caches the exchange, strips the cookie, injects `Authorization: Bearer ` upstream. +5. Downstream service verifies the signature via JWKS and authorizes from claims. + +**Postconditions**: Upstream saw only the JWT; the gateway serves subsequent requests for this session from its cache within max-age. + +**Alternative Flows**: +- **No/expired session**: 401 with `no-store`; gateway returns 401 with the login URL, no upstream call. +- **JWT past reissue age**: authenticator rebuilds claims, signs fresh, `SET NX` stampede-safe, returns the canonical JWT. + +#### Log Out Everywhere + +- [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-logout-everywhere` + +**Actor**: `cpt-insightspec-actor-browser-user` + +**Main Flow**: +1. User triggers "log out everywhere"; SPA calls `DELETE /auth/sessions`. +2. Authenticator enumerates the per-user index and, in one pipeline, deletes every session record, linked JWT, token mapping, sid-index entry, and refresh-schedule entry; clears the current cookie. + +**Postconditions**: Every device's next exchange returns 401 (gateway-side within cache max-age, at most 30 s by default); in-flight JWTs die within their `exp` (at most 300 s). Audit events recorded per session. + +**Alternative Flows**: +- **Admin- or service-initiated**: same operation against a target user via the gateway-JWT-authenticated admin surface (permission check enforced). + +#### IdP Refresh Kill Path + +- [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-idp-refresh-kill` + +**Actor**: `cpt-insightspec-actor-oidc-provider` + +**Preconditions**: User disabled/revoked at the IdP; linked session still live; IdP has no back-channel logout. + +**Main Flow**: +1. The refresher leader pops the session from `asm:idp_refresh_due`. +2. Refresh attempt returns `invalid_grant` (definitive). +3. Every session linked to that grant is revoked through the standard pipeline. + +**Postconditions**: User is back at `/auth/login` within one session TTL. Audit + `invalid_grant` counter incremented. + +**Alternative Flows**: +- **Transient failure (timeout, 5xx, 429)**: retry with backoff, honoring `Retry-After`; nobody is logged out by a blip; consecutive-failure gauge rises for alerting. + +#### Service Token Issuance + +- [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-service-token` + +**Actor**: `cpt-insightspec-actor-downstream-service` + +**Preconditions**: Service registered (public key in the registry); background job needs to call another service. + +**Main Flow**: +1. Service signs an RFC 7523 assertion and POSTs it to `/internal/token` (token listener). +2. Authenticator validates against the registry, replay-guards `jti`, audits the issuance. +3. Response: gateway JWT with `sub = service:`, registry-allowed roles, TTL 300 s. +4. Service caches the token and re-requests before expiry. + +**Postconditions**: The callee verifies the token through the same JWKS path as user traffic. + +**Alternative Flows**: +- **Unknown service / bad signature / replayed jti**: 401, audited. + +## 9. Acceptance Criteria + +- [ ] `cpt-insightspec-fr-auth-oidc-login`, `cpt-insightspec-fr-auth-session-cookie`: After login, no IdP token is present in anything delivered to the browser; the only auth artifact is the opaque `__Host-sid` cookie with the full attribute set and `Max-Age` matching the configured TTL. +- [ ] `cpt-insightspec-fr-auth-session-model`: The JWT `sid` claim and all Redis session keys are unchanged across any number of cookie rotations within one session. +- [ ] `cpt-insightspec-fr-auth-linked-jwt`, `cpt-insightspec-fr-auth-authz-exchange`: Every 200 from `/internal/authz` carries a JWT with at least 60 s of remaining validity and a `Cache-Control: max-age` no greater than `authz_cache_max_age`; every non-200 carries `no-store`. +- [ ] `cpt-insightspec-fr-auth-session-revoke`: After "revoke all", every device returns 401 within one gateway-JWT TTL (at most 300 s); at the authenticator itself, immediately. +- [ ] `cpt-insightspec-fr-auth-idp-refresh`: With the fake IdP's revoke control hook fired, all linked sessions die on the next scheduled refresh; with its outage hook fired, no session dies. +- [ ] `cpt-insightspec-fr-auth-service-tokens`: A registered service obtains a `sub = service:` JWT verifiable via the same JWKS; an unregistered caller gets 401. +- [ ] `cpt-insightspec-fr-auth-bootstrap`: On an empty persons table with bootstrap enabled, the first IdP login creates a universe admin and emits the audit event; the second login does not. +- [ ] `cpt-insightspec-fr-auth-logout`, `cpt-insightspec-fr-auth-csrf`: Local, RP-initiated, and back-channel logout all converge on the same revoke pipeline; state-changing `/auth/*` requests without CSRF token or matching `Origin` are rejected 403. + +## 10. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| Redis | Session records, token mappings, linked JWTs, indexes, refresh schedule, locks | `p1` | +| Customer OIDC provider | Authentication (code + PKCE), refresh tokens, RP-initiated + back-channel logout | `p1` | +| Identity Service | Map IdP `sub` to internal `person_id` and tenant memberships | `p1` | +| Audit Service | Sink for auth events | `p1` | +| Nginx gateway | Fronts all browser traffic; exchange caller; see [Gateway DESIGN](../gateway/DESIGN.md) | `p1` | +| Permissions service (future) | Access-control claims at login; session-revoke caller on grant change | `p3` | + +## 11. Assumptions + +- The customer OIDC provider supports authorization code + PKCE and RP-initiated logout; refresh-token issuance is expected (policy knob covers its absence); back-channel logout is optional (the refresher is the guaranteed deactivation path). +- The SPA and the gateway share one hostname; the SPA follows the refresh contract (server-supplied `refresh_at`, multi-tab leader election, 401 handling). +- Redis is deployed HA; session loss requires re-login for affected users -- acceptable. +- Dev and CI log in through a fake IdP exercising the same OIDC code path -- no dev-login bypass endpoint exists in the authenticator. + +## 12. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Redis outage | All users effectively logged out; logins blocked | HA Redis; fail closed; readiness probe | +| Authenticator down | No new exchanges; gateway fails closed with 503 + Retry-After | Stateless horizontal scaling; gateway exchange cache absorbs brief blips for already-cached sessions | +| Customer IdP withholds refresh tokens | Sessions capped at IdP access-token lifetime under `strict` policy | `no_refresh_token_policy` knob; documented in the OIDC provider contract | +| IdP outage during refresh wave | Sessions drift toward their cap | Fail-open-on-transport; backoff + `Retry-After`; consecutive-failure gauge alerts before mass logout | +| `logout_token` without `sid` widens blast radius | Back-channel logout becomes "log out everywhere" for that user | Runbook callout; log line on every `(iss, sub)`-only fallback | +| Bootstrap race on fresh install | First IdP-authenticated colleague wins universe admin | Empty-table-only window; loud audit; off switch; INSTALLER as production path | +| Gateway exchange cache staleness | Revocation reaches the gateway up to `authz_cache_max_age` late | Default 30 s, well inside the 300 s acceptance bound; set 0 for per-request checks | +| Registry misconfiguration | A service gets roles it should not have | Gitops review of every registry change; issuance audit trail | diff --git a/docs/components/backend/gateway/DESIGN.md b/docs/components/backend/gateway/DESIGN.md new file mode 100644 index 000000000..a17abcf1e --- /dev/null +++ b/docs/components/backend/gateway/DESIGN.md @@ -0,0 +1,435 @@ +--- +status: proposed +date: 2026-07-06 +--- + +# DESIGN -- Gateway (nginx edge) + +- [ ] `p3` - **ID**: `cpt-insightspec-design-gateway` + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Topology](#13-topology) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Component Model](#31-component-model) + - [3.2 Route Configuration Schema](#32-route-configuration-schema) + - [3.3 Generated Location Hygiene Block](#33-generated-location-hygiene-block) + - [3.4 Subrequest Contract](#34-subrequest-contract) + - [3.5 Lua Module](#35-lua-module) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Failure Handling](#37-failure-handling) + - [3.8 Reload Procedure](#38-reload-procedure) + - [3.9 Observability](#39-observability) +- [4. Design Decisions](#4-design-decisions) + - [DD-GW-01: nginx (OpenResty) Instead of a Custom Rust Router](#dd-gw-01-nginx-openresty-instead-of-a-custom-rust-router) + - [DD-GW-02: Route Configurator -- Humans Never Write Locations](#dd-gw-02-route-configurator----humans-never-write-locations) + - [DD-GW-03: Gateway-Side Exchange Cache in Lua Shared Memory](#dd-gw-03-gateway-side-exchange-cache-in-lua-shared-memory) + - [DD-GW-04: Ingress-Orthogonal Topology](#dd-gw-04-ingress-orthogonal-topology) + - [DD-GW-05: The Gateway Is Not the Security Boundary](#dd-gw-05-the-gateway-is-not-the-security-boundary) + - [Carried over and known issues](#carried-over-and-known-issues) +- [5. Traceability](#5-traceability) + + + +--- + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The gateway is the commodity half of the deleted API Gateway spec, implemented by the tool that has been doing exactly this work for twenty years instead of by new Rust code: an **OpenResty (nginx) deployment** that routes and proxies all browser traffic, authenticates `/api/*` requests via `auth_request` subrequests to the [authenticator](../authenticator/DESIGN.md), and injects the returned gateway JWT upstream. Streaming, WebSocket upgrades, timeouts, header rewriting, hot reload -- all stock nginx behavior. + +Nobody hand-writes the nginx config. A small **route configurator** (Rust CLI, run in CI) compiles a reviewable `routes.yaml` -- the schema salvaged nearly verbatim from the deleted Router spec -- into the full `nginx.conf`, emitting the complete auth-and-hygiene block into every generated location. A small **Lua module** provides the three things stock nginx cannot: a worker-shared cookie-to-JWT exchange cache honoring the authenticator's `Cache-Control`, per-request UUIDv7 correlation ids, and RFC 9457 problem-details error shaping. + +### 1.2 Architecture Drivers + +The gateway consumes the contracts the authenticator PRD defines; the FR-level drivers live there. + +| Requirement | Design Response | +|-------------|------------------| +| `cpt-insightspec-fr-auth-authz-exchange` (consumer side) | `auth_request` to `/internal/authz` behind the Lua exchange cache; `X-Gateway-Jwt` into `Authorization` | +| `cpt-insightspec-fr-auth-internal-reachability` (edge layer) | Generated `location /internal/ { return 404; }`; configurator forbids operator routes outside `/api/` | +| `cpt-insightspec-contract-auth-authz-exchange` | Cache TTL driven by response `Cache-Control`; 401 is `no-store`, never cached | +| Route table reviewability (deleted Router's `fr-router-config-*`) | `routes.yaml` in git; configurator validates in CI; `nginx -t` demoted to smoke test | +| Streaming, WebSockets, timeouts, longest-prefix routing (deleted `fr-router-proxy`, `fr-router-route-resolve`) | nginx core behavior; `location` matching is natively longest-prefix; unmatched `/api/` returns 404 | +| HSTS ownership (deleted `nfr-gw-https-only`) | The gateway sets `Strict-Transport-Security` on every response -- it is the component all traffic crosses, regardless of ingress choice | +| Fail closed (deleted `nfr-router-fail-closed`) | `auth_request` fails closed by default; Lua shapes the bare 500 into 503 + `Retry-After` problem-details | + +**ADRs**: decisions captured inline in [section 4](#4-design-decisions); to be extracted alongside implementation. + +### 1.3 Topology + +- [ ] `p3` - **ID**: `cpt-insightspec-topology-gateway` + +The gateway is a **separate service, not bound to the ingress layer at all**. The edge chain is fixed: + +```mermaid +graph LR + B[Browser] -->|HTTPS| ING["LB / ingress controller (any)
TLS termination + host routing only"] + ING --> GW["nginx gateway (Deployment)
auth, paths, headers, HSTS"] + GW -->|/auth/*, JWKS, subrequests| AUTH[authenticator] + GW -->|"Authorization: Bearer JWT"| DS[analytics-api / identity / ...] + GW -->|/| SPA[insight-front static server] +``` + +- The ingress controller's only jobs are TLS termination and host routing to one backend: the gateway Service. It can be swapped (ingress-nginx to traefik / Gateway API) at any time as a pure ops track -- no auth annotations, path rules, or header logic live at the ingress. +- The gateway owns security headers (HSTS on every response) -- they ride with the component all traffic crosses. +- One hostname, one entry: `__Host-sid` pins the cookie to a single host, so the SPA is routed through the gateway too (`location /` to the insight-front static server). One origin, one TLS cert, one place where a path exists or does not. +- `/internal/*` never routes (generated 404) -- nothing external can name the authenticator. +- Deployment: OpenResty image, generated ConfigMap, reloader sidecar, at least 2 replicas; liveness `/healthz` static, readiness = authenticator `/ready` reachable. + +## 2. Principles & Constraints + +### 2.1 Design Principles + +#### Humans never hand-edit locations + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-gateway-generated-config` + +Every `location` block -- including the unauthenticated ones (`/auth/`, JWKS, the SPA shell) -- is emitted by the configurator from `routes.yaml`. There is no location to forget the auth or hygiene directives in, because there is no hand-written location. + +#### Auth at the edge is UX and efficiency, not the boundary + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-gateway-not-the-boundary` + +Every downstream service verifies the gateway JWT itself -- mandatory, fail closed, no production disable knob. The gateway's `auth_request` turns no-session into a fast 401 with a login URL and mints the exchange once; the signature check in each service is the security boundary. The worst outcome of a gateway misconfiguration is an availability bug visible in the first smoke test, never a breach. + +#### Fail closed, shaped + +- [ ] `p2` - **ID**: `cpt-insightspec-principle-gateway-fail-closed` + +Authenticator unreachable means deny -- but as a proper 503 with `Retry-After` and a problem-details body, never a mystery 500. Upstream 5xx pass through untouched. + +### 2.2 Constraints + +#### OpenResty as the shipped image + +- [ ] `p2` - **ID**: `cpt-insightspec-constraint-gateway-openresty` + +The shipped image is OpenResty; every plain-nginx directive runs unchanged under it. The Lua surface is deliberately tiny (see 3.5) and the stock-nginx `proxy_cache` fallback is documented as the exit path if the Lua module ever becomes a burden. + +#### Routes only under `/api/` + +- [ ] `p2` - **ID**: `cpt-insightspec-constraint-gateway-api-prefix` + +Operator-defined routes must carry the `/api/` prefix (configurator-enforced). The non-`/api/` surface (`/auth/`, `/.well-known/jwks.json`, `/`, `/healthz`) is fixed and generated, never operator-extensible. + +## 3. Technical Architecture + +### 3.1 Component Model + +```mermaid +graph LR + subgraph CI["CI / chart build"] + RY[routes.yaml in git] + CFG[route configurator CLI] + RY --> CFG + CFG -->|validated nginx.conf| CM[ConfigMap] + end + + subgraph Pod["gateway pod (OpenResty)"] + NG[nginx core
locations + proxying] + LUA[Lua module
exchange cache + corr-id + error shaping] + REL[reloader sidecar
nginx -t && reload] + end + + CM --> REL --> NG + NG --- LUA + NG -->|auth_request /_gw_auth| AUTH[authenticator /internal/authz] + LUA -.lua_shared_dict.-> NG +``` + +#### Route Configurator + +- [ ] `p2` - **ID**: `cpt-insightspec-component-gateway-configurator` + +##### Why this component exists +Hand-written nginx config and syntax-only `nginx -t` validation are how auth and hygiene directives get forgotten. The configurator makes route validation semantic and location emission mechanical. + +##### Responsibility scope +A small Rust CLI: `routes.yaml` in, complete validated `nginx.conf` out. Enforces the schema rules (3.2) before nginx ever sees the config; emits the full hygiene block (3.3) into every generated location; emits the fixed unauthenticated surface. Golden-file snapshot tests pin the emitted config; runs in CI, so invalid YAML fails the pipeline, never the pod. + +##### Responsibility boundaries +Does not run in the pod or watch anything at runtime. Does not decide routing policy -- `routes.yaml` (reviewed in git) does. + +##### Related components (by ID) +- `cpt-insightspec-component-gateway-nginx-core` -- consumes the generated config. + +#### nginx Core + +- [ ] `p2` - **ID**: `cpt-insightspec-component-gateway-nginx-core` + +##### Why this component exists +The commodity edge: routing, proxying, streaming, WebSocket upgrades, timeouts, rate limiting -- the code nobody should hand-write in Rust. + +##### Responsibility scope +Longest-prefix `location` matching; `auth_request` on every `/api/` location; plain proxy for `/auth/` and JWKS (with a coarse per-IP `limit_req` flood guard on `/auth/`); SPA at `/`; `proxy_buffering off` where streaming matters; WebSocket upgrade pass-through (auth runs once at upgrade, JWT frozen for the socket's life); `/internal/` returns 404; HSTS on every response. + +`auth_request` semantics the design hangs on: subrequest 2xx = allow; 401/403 = deny with that status; anything else (authenticator unreachable) = fail closed. Subrequest response headers travel via `auth_request_set` (that is how the JWT arrives); the response body and any `Set-Cookie` are discarded -- fine, the design never sets cookies on `/api/*`. The subrequest is sent without the request body, so uploads are not buffered twice. + +##### Responsibility boundaries +Mints nothing, stores no sessions, verifies no signatures. Never reaches Redis or the IdP. + +##### Related components (by ID) +- `cpt-insightspec-component-gateway-lua` -- access-phase cache and error shaping. +- `cpt-insightspec-component-auth-exchange` (authenticator) -- subrequest target. + +#### Lua Module + +- [ ] `p2` - **ID**: `cpt-insightspec-component-gateway-lua` + +##### Why this component exists +Three requirements stock nginx cannot meet: a worker-shared exchange cache with response-driven TTLs, per-request UUIDv7 correlation ids (a cached subrequest response cannot supply them -- an id from there would repeat for a whole cache window), and RFC 9457 error shaping that distinguishes "authenticator refused" from "unreachable" from "timed out". + +##### Responsibility scope +See 3.5. A straight-line chain, deliberately trivial: dict hit = done; miss = subrequest, cache per the authenticator's `Cache-Control`. + +##### Responsibility boundaries +Never learns the Redis schema or exchange semantics beyond the HTTP contract (the variant where Lua reads Redis directly was considered and rejected -- it would split exchange semantics across two languages for marginal gain). + +##### Related components (by ID) +- `cpt-insightspec-component-gateway-nginx-core` -- host. + +### 3.2 Route Configuration Schema + +- [ ] `p2` - **ID**: `cpt-insightspec-design-gateway-routes-schema` + +The configurator's input contract -- the deleted Router spec's `routes.yaml` schema, salvaged nearly verbatim; the contract survives the Router's death, only the compile target changed (was: in-process route trie; now: nginx.conf). One field is dropped: `websocket_max_lifetime_seconds` (nginx cannot enforce an absolute socket lifetime; see the known issues in [section 4](#carried-over-and-known-issues)). + +```yaml +version: 1 +defaults: + timeout_ms: 30000 + strip_prefix: false + websocket: false + # Operator-extensible deny-list of request headers. The hardcoded + # gateway-reserved set (Authorization, X-Correlation-Id, + # X-Forwarded-*, gateway cookies) is always stripped in addition. + strip_request_headers: + - X-Real-IP + - Forwarded +routes: + - prefix: /api/v1/analytics + upstream: http://analytics.insight.svc.cluster.local:8081 + timeout_ms: 60000 + strip_prefix: false + + - prefix: /api/v1/identity + upstream: http://identity.insight.svc.cluster.local:8082 + + - prefix: /api/v1/stream + upstream: http://analytics.insight.svc.cluster.local:8081 + websocket: true + timeout_ms: 0 +``` + +Validation rules (enforced by the configurator in CI, before nginx ever sees the config): + +- `version` must be a known schema version. +- `prefix` unique across the table; no two routes share an exact prefix. +- `prefix` must start with `/api/`. +- `upstream` must be a valid URL with hostname and port. +- `timeout_ms >= 0`; `0` only allowed when `websocket: true`. +- `strip_request_headers` entries must be valid HTTP header names; reserved gateway headers (`Authorization`, `X-Correlation-Id`, `X-Forwarded-*`, gateway cookies) **MUST NOT** appear in this list -- they are stripped unconditionally. `X-Tenant-ID` is **not** reserved and must pass through (it is the tenant selector the downstream middleware validates against the signed `tenants[]`). + +**Why the defaults strip `X-Real-IP` and `Forwarded` -- and how backends still get the client IP.** Those two are *inbound, client-writable* identity headers: the gateway never sets them, so any value arriving upstream could only have come from the browser -- an attacker sending `Forwarded: for=1.2.3.4` would spoof IP-based audit trails, rate-limit keys, or geo logic in any backend that reads them. Stripping them leaves exactly **one source of client-IP truth**: the `X-Forwarded-For` chain, which the gateway strips from the client unconditionally (reserved set) and re-writes itself (hygiene block item 5), resolving the true peer address via `set_real_ip_from` trust of the ingress hops. Backends read client IP from that header and nothing else; the authenticator's session records (`ip` captured at login) rely on the same chain. Same trust model as the tenant selector: an unsigned inbound header is never authority. If an upstream ever genuinely needs `X-Real-IP`, the configurator emits it gateway-written (`$remote_addr` after real-ip resolution) as a hygiene-block addition -- do not remove it from the strip list, which would reintroduce the client-writable variant. + +### 3.3 Generated Location Hygiene Block + +Every generated `/api/` location gets, without exception (this is what closes the deleted spec's header-hygiene and auth-bypass risks by construction): + +1. `auth_request` to the internal exchange location, with `auth_request_set` capturing `X-Gateway-Jwt`. +2. `Authorization` set to the captured JWT -- replacing anything the browser sent. +3. Cookie strip: the `__Host-sid` (and CSRF) cookies never travel upstream (regex `map` over the `Cookie` header). +4. Per-request `X-Correlation-Id` (UUIDv7 from Lua; any client-supplied value stripped). +5. `X-Forwarded-For` / `X-Forwarded-Proto` / `Host` set by the gateway -- the single, gateway-authored source of client-IP truth for every upstream (client-supplied `X-Forwarded-*` is stripped unconditionally, and the client-writable alternatives `X-Real-IP` / `Forwarded` are stripped by the defaults; see the note under 3.2). `set_real_ip_from` trusts only the ingress hops (otherwise the per-IP flood-guard key silently becomes the LB address). +6. Operator `strip_request_headers` from `defaults`. +7. Per-route `proxy_read_timeout` from `timeout_ms`; WebSocket upgrade boilerplate when `websocket: true`; `proxy_buffering off`. +8. `error_page` wiring for the fail-closed exits (3.7). + +CI proof: a poisoned-request snapshot test per generated route (forged `Authorization`, junk cookies, junk correlation id sent in; assert what the upstream stub receives), and a no-cookie-means-401 assertion on every `/api/` route. + +### 3.4 Subrequest Contract + +The gateway side of `cpt-insightspec-contract-auth-authz-exchange` ([authenticator PRD section 7.2](../authenticator/PRD.md#72-external-integration-contracts)) -- an explicit interface between two artifacts, covered by an e2e test so drift cannot go unnoticed: + +| Element | Value | +|---|---| +| Subrequest target | `GET /internal/authz` on the authenticator (internal nginx location, `internal;`, request body not forwarded) | +| Allow | subrequest `200`; JWT arrives in the `X-Gateway-Jwt` response header, injected upstream as `Authorization` | +| Deny | subrequest `401` -- returned to the client with `WWW-Authenticate` and a login URL body; never cached (`no-store`) | +| Unavailable | any other subrequest outcome -- shaped to `503` + `Retry-After` (3.7) | +| Cache TTL | driven entirely by the authenticator's `Cache-Control: max-age` (bounded by its `authz_cache_max_age`, default 30 s; already discounted for the JWT travel margin) | +| Cache key | the session-token cookie value (extracted by regex `map`; `$cookie_*` variables cannot address the dash in `__Host-sid`) | + +Revocation staleness price, stated honestly: logout / revoke-all / back-channel / `invalid_grant` take effect at the gateway within at most the cache max-age (default 30 s -- well inside the 300 s acceptance bound), and the cache is per gateway pod, so staleness does not grow with replicas. `authz_cache_max_age_seconds: 0` disables caching for per-request checks. + +### 3.5 Lua Module + +- [ ] `p2` - **ID**: `cpt-insightspec-design-gateway-lua-module` + +About 50 lines of access-phase Lua plus helpers, deliberately trivial: + +- **Exchange cache**: `lua_shared_dict jwt_cache` -- cookie token in, JWT out. Hit: set `Authorization`, done. Miss: subrequest to the exchange, cache per the response `Cache-Control` (never a non-200). The dict is a **pre-allocated fixed-size shared-memory zone with native LRU eviction**: it structurally cannot grow past its declared size (gateway OOM via cache is impossible by construction), and eviction costs one extra subrequest on the next hit for the evicted session. Sizing: an entry is cookie token + JWT, about 1.5 KB; entries expire with their max-age, so steady state tracks sessions active per cache window; the default `64m` zone covers roughly 40k concurrently active sessions -- a set-and-forget Helm value emitted by the configurator. +- **Correlation ids**: per-request UUIDv7 generated in the access phase and injected as `X-Correlation-Id` -- never read from the cacheable subrequest response. Stock-nginx `$request_id` (random hex) is the documented fallback. +- **Error shaping**: the access phase sees the subrequest outcome directly and emits RFC 9457 problem-details distinguishing refused / unreachable / timed out, with a dynamic `Retry-After` -- the same error format the toolkit's canonical errors produce, one format from the edge to the gear. + +Testing scales to the module's triviality: a handful of e2e cases covering the two failure modes that would hurt silently (a cached 401; caching past the `Cache-Control` TTL) -- no unit tests for uuid generation or other plumbing. + +**Documented exit path**: stock-nginx `proxy_cache` on the auth subrequest location (keyed by the cookie `map`, `proxy_cache_lock` collapsing concurrent misses -- the documented pattern for caching token introspection) delivers the same cache contract with zero code, minus the UUIDv7 and error-shaping extras. The subrequest contract is identical either way. + +### 3.6 Interactions & Sequences + +#### API request through the gateway + +**ID**: `cpt-insightspec-seq-gateway-request` + +**Use cases**: `cpt-insightspec-usecase-auth-exchange` (authenticator PRD) + +**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-nginx-gateway` + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant N as nginx gateway + participant L as Lua (shared dict) + participant B as Authenticator + participant S as Downstream service + + U->>N: GET /api/... (cookie = session token, nothing else) + N->>L: exchange cache lookup (key = token) + alt dict hit (<= max-age old) + L-->>N: JWT + else miss + N->>B: auth_request subrequest /internal/authz + alt 200 + B-->>N: X-Gateway-Jwt + Cache-Control: max-age + N->>L: cache per max-age + else 401 + B-->>N: 401 + no-store + N-->>U: 401 {login: /auth/login} + WWW-Authenticate + else unreachable / timeout / 5xx + N-->>U: 503 problem-details + Retry-After (fail closed) + end + end + N->>S: request + Authorization: Bearer JWT
(browser Authorization replaced, __Host-sid stripped,
fresh X-Correlation-Id, X-Forwarded-*) + S->>S: verify JWT via JWKS (the actual security boundary) + S-->>N: response (streamed) + N-->>U: response (+ HSTS) +``` + +**Description**: The hot path costs a shared-memory lookup per request; only about one exchange per session per cache window per pod reaches the authenticator. + +### 3.7 Failure Handling + +Based on the decision document's failure analysis; upstream errors keep their own status codes because `proxy_intercept_errors` stays off: + +| Failure | Behavior | +|---|---| +| No cookie / unknown session | 401 from the subrequest; JSON body with the login URL + `WWW-Authenticate: Session realm="insight"` (the SPA contract keys off it); never cached | +| Authenticator unreachable / timeout | fail closed: shaped `503` + problem-details + `Retry-After` (Lua tier; the stock-nginx tier via `error_page 500` inside the `/api/` block is the documented fallback) | +| Authenticator 5xx on the subrequest | same shaped 503 -- an nginx-generated 500 in an `/api/` location can only mean the auth subrequest failed | +| Upstream connection refused | 502, passed as-is | +| Upstream timeout | 504, per-route `timeout_ms` | +| Upstream 5xx | pass through untouched | +| Route not matched under `/api/` | 404 (no upstream call) | +| `/internal/*` from outside | 404, always | +| Invalid generated config at reload | `nginx -t` refuses; old workers keep serving (last-good-config) | +| Gateway pod without reachable authenticator | readiness fails (readiness = authenticator `/ready`) | + +### 3.8 Reload Procedure + +Route changes are a deploy-time pipeline, not a runtime watcher: + +1. Edit `routes.yaml` in git; review the PR (reviewability carried over from the deleted Router's ConfigMap decision). +2. CI runs the configurator: semantic validation, `nginx.conf` generation, golden-file snapshot diff, `nginx -t` as smoke test. +3. The chart ships the generated config in the ConfigMap. +4. In the pod, the reloader sidecar (or a checksum-annotation pod roll) runs `nginx -t && nginx -s reload`. A broken config is refused and the old workers keep serving -- the "keep last good table" behavior the deleted Router spec demanded, minus the custom watcher code. +5. Reload closes old-worker connections after `worker_shutdown_timeout` -- blunter than the deleted spec's per-route WebSocket sweep (all old-worker sockets, not per-route); acceptable, clients reconnect. + +Config-reload audit events are emitted from CI/CD rather than a runtime watcher. + +### 3.9 Observability + +Edge observability is deliberately three sources (degraded against the deleted Router's in-process histograms -- accepted): + +- **JSON access logs** (status, duration, upstream time, route, correlation id) -- the per-request source of truth, shipped to the log pipeline. Never log cookie values or JWTs. +- **nginx-prometheus-exporter** for basics (connections, requests, statuses). +- **Authenticator metrics** -- which, behind the exchange cache, see only cache misses; per-request counters come from the access logs, not the authenticator. + +## 4. Design Decisions + +### DD-GW-01: nginx (OpenResty) Instead of a Custom Rust Router + +**Decision**: Delete the planned Rust Router module; nginx does the routing and proxying. + +**Why**: +- The Router spec was about half proxy plumbing -- streaming bodies, WebSocket upgrade registry, route-table hot reload, header rewriting, per-route timeouts. Every one of those is a bug class in fresh Rust code handling hostile input at the edge, and a solved problem in nginx. +- The security-critical core (the authenticator) gets smaller and more testable by not living in a proxy binary. +- `auth_request` is the same contract as Envoy `ext_authz` / Traefik `forwardAuth` -- the authenticator is not married to nginx. +- Ops already run nginx twice in this stack; `curl` + access logs beat debugging a custom proxy. +- Less privileged runtime: no K8s API RBAC for ConfigMap/Secret watches -- mounted files only. + +**Consequences**: One extra in-cluster hop on exchange-cache misses (absorbed by DD-GW-03; the 15 ms p95 budget holds comfortably); edge observability degrades to three sources (3.9); the WebSocket absolute-lifetime cap is lost at the edge (known issue below); the deleted `nfr-gw-single-binary` NFR is retired deliberately. + +### DD-GW-02: Route Configurator -- Humans Never Write Locations + +**Decision**: `routes.yaml` (salvaged schema) in git; a Rust CLI compiles it to the full `nginx.conf` in CI with semantic validation and golden-file snapshot tests; every generated location carries the full hygiene block; `nginx -t` is a smoke test, not the validation. + +**Why**: +- Hand-written nginx config is how a route ships without `auth_request` or with a leaky `Cookie` header. Generation removes the place to make that mistake; per the not-the-boundary principle the residual risk is availability, not security. +- Keeps the deleted spec's reviewability (routes are a PR) and its validation rules, moved earlier in the pipeline -- invalid YAML fails CI, never the pod. + +**Consequences**: New services need a `routes.yaml` PR (documented in the operator runbook). The configurator is a small, unit-testable CLI on the same Rust stack. + +### DD-GW-03: Gateway-Side Exchange Cache in Lua Shared Memory + +**Decision**: Cache the cookie-to-JWT exchange per gateway pod in a `lua_shared_dict`, TTL driven by the authenticator's `Cache-Control`; only misses travel to the authenticator. + +**Why**: +- Removes the per-request hop (the honest cost of splitting auth from routing); authenticator load drops from every-request to at most a couple of requests per minute per session per pod. +- The authenticator stays in control of cacheability by construction: max-age is already discounted for the JWT travel margin, and non-200 is `no-store` (never cache a rejection over a fresh login). +- Fixed-size shm with native LRU makes memory safety free -- the same bounded-cache philosophy the deleted spec chose for its Redis JWT cache. +- Having Lua at the edge pays twice more: per-request UUIDv7 correlation ids and RFC 9457 error shaping. + +**Consequences**: Revocation reaches the gateway within max-age (default 30 s, bound stated in 3.4); per-pod caching means no cross-replica coherence machinery and no growth of staleness with replicas. The Lua-reads-Redis variant stays rejected (splits exchange semantics across two languages); the stock `proxy_cache` fallback stays documented as the exit. + +### DD-GW-04: Ingress-Orthogonal Topology + +**Decision**: The gateway is a separate Deployment behind whatever terminates TLS; the ingress routes one host to one backend and does nothing else. The SPA rides through the gateway. + +**Why**: +- ingress-nginx is retired upstream; swapping the ingress controller must be a pure ops track with zero impact on auth -- so nothing auth- or path-related may live at the ingress. +- Security headers belong to the component all traffic crosses, not to whichever ingress happens to be installed. +- `__Host-sid` requires one hostname; routing the SPA through the gateway gives one origin, one cert, one entry point. + +**Consequences**: One extra proxy hop for static SPA assets -- negligible. The gateway stays plain HTTP behind the ingress. + +### DD-GW-05: The Gateway Is Not the Security Boundary + +**Decision**: Every downstream service verifies the gateway JWT itself -- mandatory, fail closed, no production disable knob. The gateway's auth is UX and hot-path efficiency. + +**Why**: In the deleted Rust Router, auth was structural (every request passed the middleware chain by construction); in nginx it is per-location config. The containment is this rule: a route missing auth means a JWT-less request downstream and a 401 -- an availability bug caught by the first smoke test, never a breach. This is what zero trust means here: no service trusts network position, headers, or another service's word; only the signature. + +**Consequences**: CI asserts every `/api/` route returns 401 without a cookie; downstream verification ships as one shared middleware so a new service gets the boundary by adding a dependency (implementation phase 3 of the plan). + +### Carried over and known issues + +- **DD-ROUTER-02 (reviewable route table in git)** -- carried over; the review artifact is `routes.yaml`, the runtime artifact is the generated config. +- **WebSocket absolute-lifetime cap -- known issue, deferred with eyes open**: nginx can only idle-timeout a socket (`proxy_read_timeout`), not enforce the deleted DD-ROUTER-07's absolute `websocket_max_lifetime_seconds` (the post-revoke staleness bound). No WebSocket code exists in the stack today, so nothing ships with this gap; when a WS feature lands, the downstream WS handler enforces max lifetime (it holds the JWT and its `exp`; closing at `exp` + grace is a few lines there). The same bound covers role-change staleness on open sockets. +- **Close-WS-on-route-removal** -- blunter than the deleted spec: reload + `worker_shutdown_timeout` closes all old-worker connections, not per-route. Accepted. +- **Coarse per-IP `limit_req` on `/auth/*`** -- layer 1 only (order of 60 r/min, burst above the biggest expected office); precise limiting lives in the authenticator (layer 2). The big refresh jitter is what makes legitimate steady-state traffic smooth enough for this split; `set_real_ip_from` must trust only the ingress hops. nginx `limit_req` zones are per-pod; if the gateway ever scales past a few replicas and needs exact global limits, the authenticator's Redis token bucket is already the authoritative layer. + +## 5. Traceability + +- **Sibling**: [Authenticator PRD](../authenticator/PRD.md), [Authenticator DESIGN](../authenticator/DESIGN.md) -- session lifecycle, exchange contract (`cpt-insightspec-contract-auth-authz-exchange`), JWT claim contract, JWKS +- **Parent**: [Backend PRD](../specs/PRD.md), [Backend DESIGN](../specs/DESIGN.md) +- **Decision document**: the nginx + authorization analysis (workspace-level) that mandated this architecture +- **ADRs**: decisions captured inline in section 4 until extracted alongside implementation diff --git a/docs/components/backend/specs/DESIGN.md b/docs/components/backend/specs/DESIGN.md index 701a3682d..3d0829fa4 100644 --- a/docs/components/backend/specs/DESIGN.md +++ b/docs/components/backend/specs/DESIGN.md @@ -1,6 +1,6 @@ --- status: proposed -date: 2026-03-31 +date: 2026-07-06 --- # DESIGN -- Backend @@ -81,7 +81,7 @@ The system is deployed as a **standalone product** on Kubernetes via per-service | `cpt-insightspec-fr-be-business-alerts` | Alerts Service evaluates metric thresholds on schedule, sends email via Email Service | | `cpt-insightspec-fr-be-audit-trail` | Audit Service consumes events from Redpanda, stores in ClickHouse | | `cpt-insightspec-fr-be-email-delivery` | Email Service consumes from Redpanda, renders templates, delivers via SMTP | -| `cpt-insightspec-fr-be-oidc-auth` | modkit-auth validates OIDC/JWT tokens from customer IdP | +| `cpt-insightspec-fr-be-oidc-auth` | Authenticator service runs OIDC against the customer IdP and holds sessions (token-handler pattern); the nginx gateway exchanges the session cookie for a signed gateway JWT; every service verifies that JWT via JWKS (see [Authenticator](../authenticator/DESIGN.md) and [Gateway](../gateway/DESIGN.md)) | | `cpt-insightspec-fr-be-identity-resolution-service` | Identity Resolution Service maps cross-source aliases to canonical person_id, golden record, merge/split | | `cpt-insightspec-fr-be-transform-rules` | Transform Service manages dbt model configs, Silver/Gold rules, field mappings, triggers dbt runs via Kestra | | `cpt-insightspec-fr-be-forward-only-migrations` | Forward-only MariaDB migrations via modkit-db (SeaORM); no rollback scripts | @@ -115,6 +115,11 @@ graph TB ING[Ingress
TLS/HTTPS] end + subgraph EdgeGW["Edge"] + GW[nginx gateway
routing + auth_request + HSTS] + AUTH[Authenticator
OIDC, sessions, gateway JWT] + end + subgraph Frontend FE[React SPA
Recharts] end @@ -152,14 +157,16 @@ graph TB PM[Prometheus + Grafana
+ Alertmanager] end - ING --> FE - ING --> AA - ING --> CM - ING --> IS - ING --> IR - ING --> TS - ING --> AS - ING --> AU + ING --> GW + GW -->|/| FE + GW -->|/auth/*, subrequests| AUTH + GW -->|Bearer gateway JWT| AA + GW -->|Bearer gateway JWT| CM + GW -->|Bearer gateway JWT| IS + GW -->|Bearer gateway JWT| IR + GW -->|Bearer gateway JWT| TS + GW -->|Bearer gateway JWT| AS + GW -->|Bearer gateway JWT| AU AA -->|read| CH AA -->|CRUD| MDB @@ -190,16 +197,19 @@ graph TB AA -->|cache| RD IS -->|cache| RD IR -->|cache| RD + AUTH -->|sessions| RD + AUTH <-->|code + PKCE, refresh| OIDC + AUTH -->|person resolution| IS AB -->|extract| DS AB -->|load| CH KS -->|orchestrate| AB KS -->|run dbt| CH - - OIDC -.->|JWT tokens| ING ``` -**Services (8 custom + 1 infra stack)**: +**Services (9 custom + edge + 1 infra stack)**: +- **Authenticator** — OIDC login, server-side sessions, linked gateway JWT, service tokens ([docs](../authenticator/PRD.md)) +- **nginx gateway** — edge routing, cookie-to-JWT exchange via `auth_request`, security headers ([docs](../gateway/DESIGN.md)) - **Analytics API** — query ClickHouse, metrics catalog, dashboards, CSV export - **Connector Manager** — connector CRUD, credentials, Airbyte API - **Identity Service** — org tree, OIDC mapping, RBAC, HR/directory sync @@ -614,9 +624,53 @@ No public API. Purely internal (Redpanda consumer + SMTP producer). Does NOT aut - `cpt-insightspec-component-be-alerts-service` -- subscribes to: receives email requests - `cpt-insightspec-component-be-connector-manager` -- subscribes to: receives email requests +#### Authenticator + +- [ ] `p1` - **ID**: `cpt-insightspec-component-be-authenticator` + +##### Why this component exists + +The security-critical auth core: browsers must not hold IdP tokens, sessions must be instantly revocable, and every service needs a signed, complete description of the request author. + +##### Responsibility scope + +OIDC login (code + PKCE) against the customer IdP; server-side sessions in Redis; opaque rotating session cookie; gateway JWT minted at login and linked 1:1 to the session; cookie-to-JWT exchange for the gateway (`/internal/authz`); JWKS; logout (local, RP-initiated, back-channel); background IdP token refresh; service tokens for no-user workloads; first-admin bootstrap. Detailed in [authenticator/PRD.md](../authenticator/PRD.md) + [DESIGN.md](../authenticator/DESIGN.md). + +##### Responsibility boundaries + +Does not proxy or route traffic (the gateway does). Does not own person data (Identity Service does) or permissions (separate service, later). Does not authorize business operations -- downstream services do, from signed claims. + +##### Related components (by ID) + +- `cpt-insightspec-component-be-gateway` -- serves: answers its auth subrequests, receives its `/auth/*` traffic +- `cpt-insightspec-component-be-identity-service` -- depends on: resolves IdP subject to person and tenants at login +- `cpt-insightspec-component-be-audit-service` -- publishes to: emits auth audit events via Redpanda + +#### Nginx Gateway + +- [ ] `p1` - **ID**: `cpt-insightspec-component-be-gateway` + +##### Why this component exists + +The single entry point for all browser traffic: commodity edge work (routing, proxying, streaming, WebSockets, security headers) done by nginx instead of custom code. + +##### Responsibility scope + +OpenResty deployment with generated config (route configurator compiles `routes.yaml`); `auth_request` cookie-to-JWT exchange with a bounded per-pod cache; JWT injection upstream; cookie stripping; correlation ids; HSTS; SPA delivery through one origin. Detailed in [gateway/DESIGN.md](../gateway/DESIGN.md). + +##### Responsibility boundaries + +Not the security boundary -- every downstream service verifies the gateway JWT itself, fail closed. Holds no sessions, verifies no signatures, never reaches Redis or the IdP. + +##### Related components (by ID) + +- `cpt-insightspec-component-be-authenticator` -- depends on: exchange subrequests, `/auth/*` proxying, JWKS +- `cpt-insightspec-component-be-analytics-api` -- routes to: `/api/v1/analytics` +- `cpt-insightspec-component-be-identity-service` -- routes to: `/api/v1/identity` + ### 3.3 API Contracts -Single Ingress routes to all services by path prefix. Each service owns its prefix and versions independently. Email Service has no public API. +The ingress routes one hostname to the nginx gateway; the gateway routes by path prefix. Each service owns its prefix and versions independently. Email Service has no public API. ```text https://insight.customer.com/ @@ -699,8 +753,10 @@ https://insight.customer.com/ │ └── GET /events → Query audit trail (OData) ``` -Ingress routing rules: -- `/` → frontend (nginx) +Routing rules (owned by the nginx gateway; the ingress only terminates TLS and forwards the host to the gateway): +- `/` → frontend (insight-front static server) +- `/auth/*`, `/.well-known/jwks.json` → authenticator (plain proxy, no auth subrequest -- it IS the auth) +- `/internal/*` → 404, never routed - `/api/v1/analytics/*` → analytics service - `/api/v1/connectors/*` → connector-manager service - `/api/v1/identity/*` → identity-service @@ -709,12 +765,16 @@ Ingress routing rules: - `/api/v1/transforms/*` → transform-service - `/api/v1/audit/*` → audit-service +Every `/api/*` route carries the gateway's `auth_request` exchange: session cookie in, `Authorization: Bearer ` out (see [gateway/DESIGN.md](../gateway/DESIGN.md)). + All responses use RFC 9457 Problem Details for errors. All list endpoints support OData `$filter`, `$orderby`, `$select`, cursor-based pagination per [DNA REST conventions](../../../../DNA/REST/API.md). ### 3.4 Internal Dependencies | From | To | Protocol | Purpose | |------|------|----------|---------| +| Nginx gateway | Authenticator | HTTP (`auth_request` subrequest) | Cookie-to-JWT exchange per request; `/auth/*` plain proxy; JWKS | +| Authenticator | Identity Service | HTTP (SDK) | Resolve IdP `sub` → person_id + tenants at login; back-channel `(iss, sub)` fallback | | Analytics API | Identity Service | HTTP (SDK) | Resolve person_id → org scope for query filtering | | Connector Manager | Identity Service | HTTP (SDK) | Validate tenant context for connector operations | | Alerts Service | Analytics API | HTTP (SDK) | Resolve metric definitions for threshold queries | @@ -787,16 +847,19 @@ All responses use RFC 9457 Problem Details for errors. All list endpoints suppor ```mermaid sequenceDiagram participant U as User (Browser) - participant GW as API Gateway + participant GW as nginx gateway + participant AUTH as Authenticator participant AA as Analytics API participant AZ as Authz Plugin participant IS as Identity Service participant CH as ClickHouse participant RP as Redpanda - U->>GW: GET /api/v1/analytics/metrics/query (OIDC token) - GW->>GW: Validate JWT (modkit-auth) - GW->>AA: Forward with SecurityContext + U->>GW: GET /api/v1/analytics/metrics/query (session cookie) + GW->>AUTH: auth_request /internal/authz (on exchange-cache miss) + AUTH-->>GW: 200 + X-Gateway-Jwt + GW->>AA: Forward with Authorization: Bearer gateway JWT (cookie stripped) + AA->>AA: Verify JWT via JWKS; build SecurityContext from signed claims AA->>AZ: PolicyEnforcer.access_scope(ctx, METRIC, "list") AZ->>IS: Get person roles + org memberships (cached) IS-->>AZ: roles=[analyst], units=[unit-B], effective_from/to @@ -847,20 +910,23 @@ sequenceDiagram ```mermaid sequenceDiagram participant U as User - participant GW as API Gateway + participant AUTH as Authenticator + participant I as OIDC Provider participant IS as Identity Service participant DB as MariaDB - U->>GW: First request with OIDC token - GW->>GW: Validate JWT, extract sub + email - GW->>IS: Resolve person_id for (oidc_issuer, oidc_sub) + U->>AUTH: GET /auth/callback?code&state (first login, via gateway) + AUTH->>I: exchange code (PKCE) for tokens + AUTH->>AUTH: validate id_token, extract sub + email + AUTH->>IS: Resolve person_id for (oidc_issuer, oidc_sub) IS->>DB: SELECT person_id FROM user_identities WHERE oidc_sub=? DB-->>IS: Not found IS->>DB: SELECT id FROM persons WHERE email=? AND tenant_id=? DB-->>IS: person_id found IS->>DB: INSERT INTO user_identities (oidc_issuer, oidc_sub, person_id) - IS-->>GW: person_id - GW->>GW: Attach person_id to SecurityContext + IS-->>AUTH: person_id + tenants + AUTH->>AUTH: create session, mint linked gateway JWT
(sub=person_id, tenants, roles, sid) + AUTH-->>U: Set-Cookie __Host-sid + 302 to SPA ``` ### 3.7 Database Schemas & Tables @@ -986,10 +1052,12 @@ All async communication flows through Redpanda. All messages include `tenant_id` ### 4.1 Authentication -OIDC only -- connects to customer's existing identity provider. No bundled IdP. +OIDC only -- connects to customer's existing identity provider. No bundled IdP. Token-handler pattern: IdP tokens never reach the browser. -- modkit-auth validates inbound JWT/OIDC tokens -- OIDC `sub` → `person_id` mapping via Identity Service `user_identities` table +- The [Authenticator](../authenticator/DESIGN.md) runs the OIDC flow, holds the session server-side, and mints a short-lived signed **gateway JWT** (`sub` = person_id, `tenants`, `roles`, `sid`) linked to the session +- The [nginx gateway](../gateway/DESIGN.md) exchanges the opaque session cookie for that JWT on every `/api/*` request and injects it as `Authorization: Bearer` +- Every service validates the gateway JWT against the authenticator's JWKS (authn-resolver pipeline) -- mandatory, fail closed, no production disable knob +- OIDC `sub` → `person_id` mapping via Identity Service `user_identities` table, resolved once at login - First login triggers email-based matching (see SEQ-BE-03) ### 4.2 Authorization (RBAC + Org Tree) diff --git a/docs/components/backend/specs/PRD.md b/docs/components/backend/specs/PRD.md index 7d3c29116..44e3b5076 100644 --- a/docs/components/backend/specs/PRD.md +++ b/docs/components/backend/specs/PRD.md @@ -1,6 +1,6 @@ --- status: proposed -date: 2026-03-31 +date: 2026-07-06 --- # PRD -- Backend @@ -181,6 +181,8 @@ The product is deployed as a standalone installation on customer Kubernetes clus - Credential management with per-tenant envelope encryption - Org tree sync from HR/directory systems via pluggable adapters - OIDC-to-person identity resolution (login mapping) +- Session-based browser authentication via the Authenticator service (token-handler pattern; see [authenticator/PRD.md](../authenticator/PRD.md)) +- Edge routing and cookie-to-JWT exchange via the nginx gateway (see [gateway/DESIGN.md](../gateway/DESIGN.md)) - Cross-source identity resolution (alias matching, golden records, merge/split) - RBAC with five roles (Viewer, Analyst, Connector Admin, Identity Admin, Tenant Admin) - Org-tree-based data visibility with follow-the-unit-strict policy @@ -328,9 +330,11 @@ The system **MUST** map disparate identity signals (emails, usernames, employee - [ ] `p1` - **ID**: `cpt-insightspec-fr-be-oidc-auth` -The system **MUST** authenticate all API requests via OIDC/JWT tokens issued by the customer's identity provider. No bundled identity provider or user/password management **MUST** be included. +The system **MUST** authenticate users against the customer's identity provider via OIDC. No bundled identity provider or user/password management **MUST** be included. -**Rationale**: Enterprise customers have existing IdPs. The product must integrate, not replace. +Browser authentication follows the token-handler pattern: the [Authenticator service](../authenticator/PRD.md) runs the OIDC flow, keeps IdP tokens server-side, and gives the browser only an opaque session cookie. The [nginx gateway](../gateway/DESIGN.md) exchanges that cookie for a short-lived signed gateway JWT on every API request; every backend service **MUST** verify that gateway JWT (via JWKS) and authorize from its signed claims -- fail closed, no production disable knob. + +**Rationale**: Enterprise customers have existing IdPs. The product must integrate, not replace. Keeping IdP tokens out of the browser and verifying a signed claim set in every service removes token-theft-via-XSS and unsigned-header trust. **Actors**: `cpt-insightspec-actor-oidc-provider` From d6ed3aa7ba144f63cc1c3b89f671d4ea2de3a3de Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Tue, 7 Jul 2026 09:43:53 +0200 Subject: [PATCH 2/3] docs: align authenticator/gateway specs with Constructor Studio template constraints Studio validation (now working after the Cypilot -> Constructor Studio migration) surfaced three deterministic issues: - authenticator/PRD.md: tenant-admin and oidc-provider actors were duplicate definitions of IDs owned by the parent backend PRD; now referenced instead of redefined - gateway/DESIGN.md: restructured to the required DESIGN template headings (Functional Drivers, NFR Allocation, Architecture Layers, Domain Model, API Contracts, Internal/External Dependencies, Database schemas & tables) and moved the topology ID under Technical Architecture (new Deployment Topology section) cfs validate --artifact now passes on all five touched artifacts; the repo-wide run reports only pre-existing errors in untouched files. Co-Authored-By: Claude Fable 5 Signed-off-by: Anton Zelenov --- docs/components/backend/authenticator/PRD.md | 8 +- docs/components/backend/gateway/DESIGN.md | 232 +++++++++++++------ 2 files changed, 163 insertions(+), 77 deletions(-) diff --git a/docs/components/backend/authenticator/PRD.md b/docs/components/backend/authenticator/PRD.md index f39038570..f0b96c7ff 100644 --- a/docs/components/backend/authenticator/PRD.md +++ b/docs/components/backend/authenticator/PRD.md @@ -97,17 +97,13 @@ The previously specified remedy -- a single Rust API Gateway binary combining a #### Tenant Administrator -**ID**: `cpt-insightspec-actor-tenant-admin` - -**Role**: Already defined in the parent backend PRD. Additionally needs to revoke any user's sessions (forced logout on role change, offboarding, suspected compromise). +Defined in the [parent backend PRD](../specs/PRD.md) as `cpt-insightspec-actor-tenant-admin`. In this module the Tenant Administrator additionally needs to revoke any user's sessions (forced logout on role change, offboarding, suspected compromise). ### 2.2 System Actors #### OIDC Provider -**ID**: `cpt-insightspec-actor-oidc-provider` - -**Role**: Customer identity provider. Runs the authorization code + PKCE flow, issues refresh tokens, may call back-channel logout. +Defined in the [parent backend PRD](../specs/PRD.md) as `cpt-insightspec-actor-oidc-provider`. In this module the customer identity provider runs the authorization code + PKCE flow, issues refresh tokens to the authenticator, and may call back-channel logout. #### Nginx Gateway diff --git a/docs/components/backend/gateway/DESIGN.md b/docs/components/backend/gateway/DESIGN.md index a17abcf1e..1de1ae599 100644 --- a/docs/components/backend/gateway/DESIGN.md +++ b/docs/components/backend/gateway/DESIGN.md @@ -12,20 +12,26 @@ date: 2026-07-06 - [1. Architecture Overview](#1-architecture-overview) - [1.1 Architectural Vision](#11-architectural-vision) - [1.2 Architecture Drivers](#12-architecture-drivers) - - [1.3 Topology](#13-topology) + - [1.3 Architecture Layers](#13-architecture-layers) - [2. Principles & Constraints](#2-principles--constraints) - [2.1 Design Principles](#21-design-principles) - [2.2 Constraints](#22-constraints) - [3. Technical Architecture](#3-technical-architecture) - - [3.1 Component Model](#31-component-model) - - [3.2 Route Configuration Schema](#32-route-configuration-schema) - - [3.3 Generated Location Hygiene Block](#33-generated-location-hygiene-block) - - [3.4 Subrequest Contract](#34-subrequest-contract) - - [3.5 Lua Module](#35-lua-module) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) - [3.6 Interactions & Sequences](#36-interactions--sequences) - - [3.7 Failure Handling](#37-failure-handling) - - [3.8 Reload Procedure](#38-reload-procedure) - - [3.9 Observability](#39-observability) + - [3.7 Database schemas & tables](#37-database-schemas--tables) + - [3.8 Route Configuration Schema](#38-route-configuration-schema) + - [3.9 Generated Location Hygiene Block](#39-generated-location-hygiene-block) + - [3.10 Subrequest Contract](#310-subrequest-contract) + - [3.11 Lua Module](#311-lua-module) + - [3.12 Failure Handling](#312-failure-handling) + - [3.13 Reload Procedure](#313-reload-procedure) + - [3.14 Observability](#314-observability) + - [3.15 Deployment Topology](#315-deployment-topology) - [4. Design Decisions](#4-design-decisions) - [DD-GW-01: nginx (OpenResty) Instead of a Custom Rust Router](#dd-gw-01-nginx-openresty-instead-of-a-custom-rust-router) - [DD-GW-02: Route Configurator -- Humans Never Write Locations](#dd-gw-02-route-configurator----humans-never-write-locations) @@ -51,6 +57,8 @@ Nobody hand-writes the nginx config. A small **route configurator** (Rust CLI, r The gateway consumes the contracts the authenticator PRD defines; the FR-level drivers live there. +#### Functional Drivers + | Requirement | Design Response | |-------------|------------------| | `cpt-insightspec-fr-auth-authz-exchange` (consumer side) | `auth_request` to `/internal/authz` behind the Lua exchange cache; `X-Gateway-Jwt` into `Authorization` | @@ -61,11 +69,26 @@ The gateway consumes the contracts the authenticator PRD defines; the FR-level d | HSTS ownership (deleted `nfr-gw-https-only`) | The gateway sets `Strict-Transport-Security` on every response -- it is the component all traffic crosses, regardless of ingress choice | | Fail closed (deleted `nfr-router-fail-closed`) | `auth_request` fails closed by default; Lua shapes the bare 500 into 503 + `Retry-After` problem-details | +#### NFR Allocation + +The gateway carries its share of the NFRs the authenticator PRD pins: + +| NFR ID | NFR Summary | Allocated To | Design Response | Verification Approach | +|--------|-------------|--------------|-----------------|----------------------| +| `cpt-insightspec-nfr-auth-exchange-p95` | Exchange within 5 ms p95; 15 ms p95 total edge overhead | Lua exchange cache | Hot path is a shared-memory lookup; only about one exchange per session per cache window per pod reaches the authenticator | Load test measured at the gateway | +| `cpt-insightspec-nfr-auth-rate-limit` | Layered `/auth/*` rate limiting | `limit_req` zone | Coarse per-IP flood guard (layer 1); precise layer 2 lives in the authenticator | Flood test: excess requests rejected at the edge before reaching the authenticator | +| `cpt-insightspec-nfr-auth-fail-closed` | No auth without a live session check | `auth_request` + error shaping | Subrequest failure never passes through -- shaped 503 + `Retry-After`; readiness fails without a reachable authenticator | Kill the authenticator; assert 503 problem-details and not-ready | + **ADRs**: decisions captured inline in [section 4](#4-design-decisions); to be extracted alongside implementation. -### 1.3 Topology +### 1.3 Architecture Layers -- [ ] `p3` - **ID**: `cpt-insightspec-topology-gateway` +| Layer | Responsibility | Technology | +|-------|---------------|------------| +| TLS edge | TLS termination, host routing to exactly one backend | Customer-chosen ingress controller (any) | +| Gateway | Path routing, `auth_request` exchange, header hygiene, HSTS, coarse rate limiting | OpenResty (nginx + the Lua module) | +| Auth | Sessions, JWT mint and exchange | [Authenticator](../authenticator/DESIGN.md) (sibling artifact) | +| Upstreams | Business APIs and the SPA shell | Downstream services / insight-front | The gateway is a **separate service, not bound to the ingress layer at all**. The edge chain is fixed: @@ -82,7 +105,6 @@ graph LR - The gateway owns security headers (HSTS on every response) -- they ride with the component all traffic crosses. - One hostname, one entry: `__Host-sid` pins the cookie to a single host, so the SPA is routed through the gateway too (`location /` to the insight-front static server). One origin, one TLS cert, one place where a path exists or does not. - `/internal/*` never routes (generated 404) -- nothing external can name the authenticator. -- Deployment: OpenResty image, generated ConfigMap, reloader sidecar, at least 2 replicas; liveness `/healthz` static, readiness = authenticator `/ready` reachable. ## 2. Principles & Constraints @@ -112,7 +134,7 @@ Authenticator unreachable means deny -- but as a proper 503 with `Retry-After` a - [ ] `p2` - **ID**: `cpt-insightspec-constraint-gateway-openresty` -The shipped image is OpenResty; every plain-nginx directive runs unchanged under it. The Lua surface is deliberately tiny (see 3.5) and the stock-nginx `proxy_cache` fallback is documented as the exit path if the Lua module ever becomes a burden. +The shipped image is OpenResty; every plain-nginx directive runs unchanged under it. The Lua surface is deliberately tiny (see 3.11) and the stock-nginx `proxy_cache` fallback is documented as the exit path if the Lua module ever becomes a burden. #### Routes only under `/api/` @@ -122,7 +144,20 @@ Operator-defined routes must carry the `/api/` prefix (configurator-enforced). T ## 3. Technical Architecture -### 3.1 Component Model +### 3.1 Domain Model + +The gateway holds no business entities. The objects it owns are configuration and cache artifacts: + +| Entity | Purpose | Storage | +|--------|---------|--------| +| `routes.yaml` | Reviewable route table -- the operator-facing source of truth | Git (chart repo) | +| Generated `nginx.conf` | Compiled routing + hygiene configuration | ConfigMap, produced by the configurator in CI | +| Exchange-cache entry | Session token to JWT, one cache window | `lua_shared_dict` (per pod, fixed shm, LRU) | +| Correlation id | Per-request UUIDv7 | Request-scoped, never stored | + +It reads nothing else: no Redis, no database, no K8s API. + +### 3.2 Component Model ```mermaid graph LR @@ -153,7 +188,7 @@ graph LR Hand-written nginx config and syntax-only `nginx -t` validation are how auth and hygiene directives get forgotten. The configurator makes route validation semantic and location emission mechanical. ##### Responsibility scope -A small Rust CLI: `routes.yaml` in, complete validated `nginx.conf` out. Enforces the schema rules (3.2) before nginx ever sees the config; emits the full hygiene block (3.3) into every generated location; emits the fixed unauthenticated surface. Golden-file snapshot tests pin the emitted config; runs in CI, so invalid YAML fails the pipeline, never the pod. +A small Rust CLI: `routes.yaml` in, complete validated `nginx.conf` out. Enforces the schema rules (3.8) before nginx ever sees the config; emits the full hygiene block (3.9) into every generated location; emits the fixed unauthenticated surface. Golden-file snapshot tests pin the emitted config; runs in CI, so invalid YAML fails the pipeline, never the pod. ##### Responsibility boundaries Does not run in the pod or watch anything at runtime. Does not decide routing policy -- `routes.yaml` (reviewed in git) does. @@ -188,7 +223,7 @@ Mints nothing, stores no sessions, verifies no signatures. Never reaches Redis o Three requirements stock nginx cannot meet: a worker-shared exchange cache with response-driven TTLs, per-request UUIDv7 correlation ids (a cached subrequest response cannot supply them -- an id from there would repeat for a whole cache window), and RFC 9457 error shaping that distinguishes "authenticator refused" from "unreachable" from "timed out". ##### Responsibility scope -See 3.5. A straight-line chain, deliberately trivial: dict hit = done; miss = subrequest, cache per the authenticator's `Cache-Control`. +See 3.11. A straight-line chain, deliberately trivial: dict hit = done; miss = subrequest, cache per the authenticator's `Cache-Control`. ##### Responsibility boundaries Never learns the Redis schema or exchange semantics beyond the HTTP contract (the variant where Lua reads Redis directly was considered and rejected -- it would split exchange semantics across two languages for marginal gain). @@ -196,7 +231,94 @@ Never learns the Redis schema or exchange semantics beyond the HTTP contract (th ##### Related components (by ID) - `cpt-insightspec-component-gateway-nginx-core` -- host. -### 3.2 Route Configuration Schema +### 3.3 API Contracts + +- [ ] `p2` - **ID**: `cpt-insightspec-interface-gateway-edge` + +- **Contracts**: consumes `cpt-insightspec-contract-auth-authz-exchange` (see 3.10); transports `cpt-insightspec-contract-auth-gateway-jwt` (injects, never inspects); fronts the URL of `cpt-insightspec-contract-auth-jwks-url` +- **Technology**: HTTP/1.1 + WebSocket reverse proxy (OpenResty) +- **Location**: generated `nginx.conf` (ConfigMap); the operator-facing contract is `routes.yaml` (3.8) + +**Endpoints Overview**: + +| Method | Path | Description | Stability | +|--------|------|-------------|-----------| +| ANY | `/` | SPA shell, proxied to insight-front | stable | +| ANY | `/auth/*` | Plain proxy to the authenticator (no `auth_request` -- it IS the auth); coarse `limit_req` | stable | +| GET | `/.well-known/jwks.json` | Proxy to the authenticator | stable | +| ANY | `/api/**` | `auth_request` exchange, hygiene block, proxy to the routed upstream | stable | +| GET | `/healthz` | Static liveness | stable | +| ANY | `/internal/*` | 404, always | stable | + +### 3.4 Internal Dependencies + +| Dependency Module | Interface Used | Purpose | +|-------------------|----------------|----------| +| Authenticator | `GET /internal/authz` subrequest (`cpt-insightspec-contract-auth-authz-exchange`) | Cookie-to-JWT exchange; also the readiness signal | +| Authenticator | `/auth/*` + JWKS plain proxy | Login surface and key distribution | +| insight-front | HTTP (static) | SPA shell at `/` | +| Downstream services | HTTP upstreams from `routes.yaml` | Routed business APIs | + +The gateway is configuration, not code -- it consumes no SDK crates and exposes none; its inter-module contracts are the HTTP surfaces above. + +### 3.5 External Dependencies + +#### Ingress controller (cluster-provided) + +| Dependency Module | Interface Used | Purpose | +|-------------------|---------------|---------| +| Ingress controller (any -- customer/cluster choice) | Plain HTTP behind TLS | TLS termination and host routing to the gateway Service; deliberately nothing else (see DD-GW-04) | + +No other external systems: the gateway never talks to Redis, the IdP, or the K8s API. + +### 3.6 Interactions & Sequences + +#### API request through the gateway + +**ID**: `cpt-insightspec-seq-gateway-request` + +**Use cases**: `cpt-insightspec-usecase-auth-exchange` (authenticator PRD) + +**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-nginx-gateway` + +```mermaid +sequenceDiagram + autonumber + actor U as Browser + participant N as nginx gateway + participant L as Lua (shared dict) + participant B as Authenticator + participant S as Downstream service + + U->>N: GET /api/... (cookie = session token, nothing else) + N->>L: exchange cache lookup (key = token) + alt dict hit (<= max-age old) + L-->>N: JWT + else miss + N->>B: auth_request subrequest /internal/authz + alt 200 + B-->>N: X-Gateway-Jwt + Cache-Control: max-age + N->>L: cache per max-age + else 401 + B-->>N: 401 + no-store + N-->>U: 401 {login: /auth/login} + WWW-Authenticate + else unreachable / timeout / 5xx + N-->>U: 503 problem-details + Retry-After (fail closed) + end + end + N->>S: request + Authorization: Bearer JWT
(browser Authorization replaced, __Host-sid stripped,
fresh X-Correlation-Id, X-Forwarded-*) + S->>S: verify JWT via JWKS (the actual security boundary) + S-->>N: response (streamed) + N-->>U: response (+ HSTS) +``` + +**Description**: The hot path costs a shared-memory lookup per request; only about one exchange per session per cache window per pod reaches the authenticator. + +### 3.7 Database schemas & tables + +None -- the gateway is deliberately stateless: no database, no Redis, no persistent volumes. Its only mutable state is the per-pod `lua_shared_dict` exchange cache (fixed-size shared memory, native LRU; entries expire with their `Cache-Control` max-age; see 3.11), fully reconstructible from subrequests at any moment. Losing a pod loses nothing. + +### 3.8 Route Configuration Schema - [ ] `p2` - **ID**: `cpt-insightspec-design-gateway-routes-schema` @@ -240,7 +362,7 @@ Validation rules (enforced by the configurator in CI, before nginx ever sees the **Why the defaults strip `X-Real-IP` and `Forwarded` -- and how backends still get the client IP.** Those two are *inbound, client-writable* identity headers: the gateway never sets them, so any value arriving upstream could only have come from the browser -- an attacker sending `Forwarded: for=1.2.3.4` would spoof IP-based audit trails, rate-limit keys, or geo logic in any backend that reads them. Stripping them leaves exactly **one source of client-IP truth**: the `X-Forwarded-For` chain, which the gateway strips from the client unconditionally (reserved set) and re-writes itself (hygiene block item 5), resolving the true peer address via `set_real_ip_from` trust of the ingress hops. Backends read client IP from that header and nothing else; the authenticator's session records (`ip` captured at login) rely on the same chain. Same trust model as the tenant selector: an unsigned inbound header is never authority. If an upstream ever genuinely needs `X-Real-IP`, the configurator emits it gateway-written (`$remote_addr` after real-ip resolution) as a hygiene-block addition -- do not remove it from the strip list, which would reintroduce the client-writable variant. -### 3.3 Generated Location Hygiene Block +### 3.9 Generated Location Hygiene Block Every generated `/api/` location gets, without exception (this is what closes the deleted spec's header-hygiene and auth-bypass risks by construction): @@ -248,14 +370,14 @@ Every generated `/api/` location gets, without exception (this is what closes th 2. `Authorization` set to the captured JWT -- replacing anything the browser sent. 3. Cookie strip: the `__Host-sid` (and CSRF) cookies never travel upstream (regex `map` over the `Cookie` header). 4. Per-request `X-Correlation-Id` (UUIDv7 from Lua; any client-supplied value stripped). -5. `X-Forwarded-For` / `X-Forwarded-Proto` / `Host` set by the gateway -- the single, gateway-authored source of client-IP truth for every upstream (client-supplied `X-Forwarded-*` is stripped unconditionally, and the client-writable alternatives `X-Real-IP` / `Forwarded` are stripped by the defaults; see the note under 3.2). `set_real_ip_from` trusts only the ingress hops (otherwise the per-IP flood-guard key silently becomes the LB address). +5. `X-Forwarded-For` / `X-Forwarded-Proto` / `Host` set by the gateway -- the single, gateway-authored source of client-IP truth for every upstream (client-supplied `X-Forwarded-*` is stripped unconditionally, and the client-writable alternatives `X-Real-IP` / `Forwarded` are stripped by the defaults; see the note under 3.8). `set_real_ip_from` trusts only the ingress hops (otherwise the per-IP flood-guard key silently becomes the LB address). 6. Operator `strip_request_headers` from `defaults`. 7. Per-route `proxy_read_timeout` from `timeout_ms`; WebSocket upgrade boilerplate when `websocket: true`; `proxy_buffering off`. -8. `error_page` wiring for the fail-closed exits (3.7). +8. `error_page` wiring for the fail-closed exits (3.12). CI proof: a poisoned-request snapshot test per generated route (forged `Authorization`, junk cookies, junk correlation id sent in; assert what the upstream stub receives), and a no-cookie-means-401 assertion on every `/api/` route. -### 3.4 Subrequest Contract +### 3.10 Subrequest Contract The gateway side of `cpt-insightspec-contract-auth-authz-exchange` ([authenticator PRD section 7.2](../authenticator/PRD.md#72-external-integration-contracts)) -- an explicit interface between two artifacts, covered by an e2e test so drift cannot go unnoticed: @@ -264,13 +386,13 @@ The gateway side of `cpt-insightspec-contract-auth-authz-exchange` ([authenticat | Subrequest target | `GET /internal/authz` on the authenticator (internal nginx location, `internal;`, request body not forwarded) | | Allow | subrequest `200`; JWT arrives in the `X-Gateway-Jwt` response header, injected upstream as `Authorization` | | Deny | subrequest `401` -- returned to the client with `WWW-Authenticate` and a login URL body; never cached (`no-store`) | -| Unavailable | any other subrequest outcome -- shaped to `503` + `Retry-After` (3.7) | +| Unavailable | any other subrequest outcome -- shaped to `503` + `Retry-After` (3.12) | | Cache TTL | driven entirely by the authenticator's `Cache-Control: max-age` (bounded by its `authz_cache_max_age`, default 30 s; already discounted for the JWT travel margin) | | Cache key | the session-token cookie value (extracted by regex `map`; `$cookie_*` variables cannot address the dash in `__Host-sid`) | Revocation staleness price, stated honestly: logout / revoke-all / back-channel / `invalid_grant` take effect at the gateway within at most the cache max-age (default 30 s -- well inside the 300 s acceptance bound), and the cache is per gateway pod, so staleness does not grow with replicas. `authz_cache_max_age_seconds: 0` disables caching for per-request checks. -### 3.5 Lua Module +### 3.11 Lua Module - [ ] `p2` - **ID**: `cpt-insightspec-design-gateway-lua-module` @@ -284,50 +406,7 @@ Testing scales to the module's triviality: a handful of e2e cases covering the t **Documented exit path**: stock-nginx `proxy_cache` on the auth subrequest location (keyed by the cookie `map`, `proxy_cache_lock` collapsing concurrent misses -- the documented pattern for caching token introspection) delivers the same cache contract with zero code, minus the UUIDv7 and error-shaping extras. The subrequest contract is identical either way. -### 3.6 Interactions & Sequences - -#### API request through the gateway - -**ID**: `cpt-insightspec-seq-gateway-request` - -**Use cases**: `cpt-insightspec-usecase-auth-exchange` (authenticator PRD) - -**Actors**: `cpt-insightspec-actor-browser-user`, `cpt-insightspec-actor-nginx-gateway` - -```mermaid -sequenceDiagram - autonumber - actor U as Browser - participant N as nginx gateway - participant L as Lua (shared dict) - participant B as Authenticator - participant S as Downstream service - - U->>N: GET /api/... (cookie = session token, nothing else) - N->>L: exchange cache lookup (key = token) - alt dict hit (<= max-age old) - L-->>N: JWT - else miss - N->>B: auth_request subrequest /internal/authz - alt 200 - B-->>N: X-Gateway-Jwt + Cache-Control: max-age - N->>L: cache per max-age - else 401 - B-->>N: 401 + no-store - N-->>U: 401 {login: /auth/login} + WWW-Authenticate - else unreachable / timeout / 5xx - N-->>U: 503 problem-details + Retry-After (fail closed) - end - end - N->>S: request + Authorization: Bearer JWT
(browser Authorization replaced, __Host-sid stripped,
fresh X-Correlation-Id, X-Forwarded-*) - S->>S: verify JWT via JWKS (the actual security boundary) - S-->>N: response (streamed) - N-->>U: response (+ HSTS) -``` - -**Description**: The hot path costs a shared-memory lookup per request; only about one exchange per session per cache window per pod reaches the authenticator. - -### 3.7 Failure Handling +### 3.12 Failure Handling Based on the decision document's failure analysis; upstream errors keep their own status codes because `proxy_intercept_errors` stays off: @@ -344,7 +423,7 @@ Based on the decision document's failure analysis; upstream errors keep their ow | Invalid generated config at reload | `nginx -t` refuses; old workers keep serving (last-good-config) | | Gateway pod without reachable authenticator | readiness fails (readiness = authenticator `/ready`) | -### 3.8 Reload Procedure +### 3.13 Reload Procedure Route changes are a deploy-time pipeline, not a runtime watcher: @@ -356,7 +435,7 @@ Route changes are a deploy-time pipeline, not a runtime watcher: Config-reload audit events are emitted from CI/CD rather than a runtime watcher. -### 3.9 Observability +### 3.14 Observability Edge observability is deliberately three sources (degraded against the deleted Router's in-process histograms -- accepted): @@ -364,6 +443,17 @@ Edge observability is deliberately three sources (degraded against the deleted R - **nginx-prometheus-exporter** for basics (connections, requests, statuses). - **Authenticator metrics** -- which, behind the exchange cache, see only cache misses; per-request counters come from the access logs, not the authenticator. +### 3.15 Deployment Topology + +- [ ] `p3` - **ID**: `cpt-insightspec-topology-gateway` + +One OpenResty Deployment (at least 2 replicas) behind the single ingress backend, per the edge chain fixed in 1.3: + +- Image: OpenResty; config mounted from the ConfigMap the configurator generated in CI; the Lua module ships in the image. +- Reloader sidecar (or checksum-annotation pod roll) applies config changes with `nginx -t && nginx -s reload` (3.13). +- Probes: liveness = static `/healthz`; readiness = authenticator `/ready` reachable (a gateway pod that cannot authenticate anything must not receive traffic). +- No volumes, no Redis, no K8s API access -- mounted files only; pods are disposable (3.7). + ## 4. Design Decisions ### DD-GW-01: nginx (OpenResty) Instead of a Custom Rust Router @@ -377,7 +467,7 @@ Edge observability is deliberately three sources (degraded against the deleted R - Ops already run nginx twice in this stack; `curl` + access logs beat debugging a custom proxy. - Less privileged runtime: no K8s API RBAC for ConfigMap/Secret watches -- mounted files only. -**Consequences**: One extra in-cluster hop on exchange-cache misses (absorbed by DD-GW-03; the 15 ms p95 budget holds comfortably); edge observability degrades to three sources (3.9); the WebSocket absolute-lifetime cap is lost at the edge (known issue below); the deleted `nfr-gw-single-binary` NFR is retired deliberately. +**Consequences**: One extra in-cluster hop on exchange-cache misses (absorbed by DD-GW-03; the 15 ms p95 budget holds comfortably); edge observability degrades to three sources (3.14); the WebSocket absolute-lifetime cap is lost at the edge (known issue below); the deleted `nfr-gw-single-binary` NFR is retired deliberately. ### DD-GW-02: Route Configurator -- Humans Never Write Locations @@ -399,7 +489,7 @@ Edge observability is deliberately three sources (degraded against the deleted R - Fixed-size shm with native LRU makes memory safety free -- the same bounded-cache philosophy the deleted spec chose for its Redis JWT cache. - Having Lua at the edge pays twice more: per-request UUIDv7 correlation ids and RFC 9457 error shaping. -**Consequences**: Revocation reaches the gateway within max-age (default 30 s, bound stated in 3.4); per-pod caching means no cross-replica coherence machinery and no growth of staleness with replicas. The Lua-reads-Redis variant stays rejected (splits exchange semantics across two languages); the stock `proxy_cache` fallback stays documented as the exit. +**Consequences**: Revocation reaches the gateway within max-age (default 30 s, bound stated in 3.10); per-pod caching means no cross-replica coherence machinery and no growth of staleness with replicas. The Lua-reads-Redis variant stays rejected (splits exchange semantics across two languages); the stock `proxy_cache` fallback stays documented as the exit. ### DD-GW-04: Ingress-Orthogonal Topology From ae49f0274aa82131f917b41720efdc8ed45cde80 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Tue, 7 Jul 2026 10:01:23 +0200 Subject: [PATCH 3/3] docs: address CodeRabbit review on authenticator/gateway specs - authenticator/PRD.md (MD001 heading-increment): group section 8 use cases under '### 8.1 Browser Session Lifecycle' and '### 8.2 Service- to-Service Authentication' so headings increment ## -> ### -> #### (use-case names stay #### as the Studio PRD template mandates and cfs requires; the parent backend PRD uses the same grouping pattern). - gateway/DESIGN.md (readiness anti-pattern): decouple gateway readiness from the authenticator. Readiness is now local (nginx workers + valid config); an authenticator blip no longer drains the fleet or the exchange cache. Authenticator reachability moves to a separate /healthz/authenticator dependency probe for alerting only. Updated the NFR-allocation row, internal-deps row, failure table, deployment- topology probes, and API-contracts endpoint list. - gateway/DESIGN.md (cache vs token rotation): document that the token-keyed exchange cache extends a retired cookie's effective life from the 250 ms rotation grace to at most authz_cache_max_age (30 s) at that pod. Explained the bounds (session-linked JWT so revocation is unaffected; only benefits a stolen old cookie; consistent with the probabilistic theft-detection model) and the authz_cache_max_age=0 lever; documented why a purge-on-rotation hook is deliberately not added in v1. cfs validate/toc/check-language green on both artifacts. Co-Authored-By: Claude Fable 5 Signed-off-by: Anton Zelenov --- docs/components/backend/authenticator/PRD.md | 6 ++++++ docs/components/backend/gateway/DESIGN.md | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/components/backend/authenticator/PRD.md b/docs/components/backend/authenticator/PRD.md index f0b96c7ff..faed9dfa9 100644 --- a/docs/components/backend/authenticator/PRD.md +++ b/docs/components/backend/authenticator/PRD.md @@ -43,6 +43,8 @@ date: 2026-07-06 - [7.1 Public API Surface](#71-public-api-surface) - [7.2 External Integration Contracts](#72-external-integration-contracts) - [8. Use Cases](#8-use-cases) + - [8.1 Browser Session Lifecycle](#81-browser-session-lifecycle) + - [8.2 Service-to-Service Authentication](#82-service-to-service-authentication) - [9. Acceptance Criteria](#9-acceptance-criteria) - [10. Dependencies](#10-dependencies) - [11. Assumptions](#11-assumptions) @@ -614,6 +616,8 @@ All endpoints are registered through the toolkit operation builder and land in t ## 8. Use Cases +### 8.1 Browser Session Lifecycle + #### Login - [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-login` @@ -692,6 +696,8 @@ All endpoints are registered through the toolkit operation builder and land in t **Alternative Flows**: - **Transient failure (timeout, 5xx, 429)**: retry with backoff, honoring `Retry-After`; nobody is logged out by a blip; consecutive-failure gauge rises for alerting. +### 8.2 Service-to-Service Authentication + #### Service Token Issuance - [ ] `p1` - **ID**: `cpt-insightspec-usecase-auth-service-token` diff --git a/docs/components/backend/gateway/DESIGN.md b/docs/components/backend/gateway/DESIGN.md index 1de1ae599..e30659f91 100644 --- a/docs/components/backend/gateway/DESIGN.md +++ b/docs/components/backend/gateway/DESIGN.md @@ -77,7 +77,7 @@ The gateway carries its share of the NFRs the authenticator PRD pins: |--------|-------------|--------------|-----------------|----------------------| | `cpt-insightspec-nfr-auth-exchange-p95` | Exchange within 5 ms p95; 15 ms p95 total edge overhead | Lua exchange cache | Hot path is a shared-memory lookup; only about one exchange per session per cache window per pod reaches the authenticator | Load test measured at the gateway | | `cpt-insightspec-nfr-auth-rate-limit` | Layered `/auth/*` rate limiting | `limit_req` zone | Coarse per-IP flood guard (layer 1); precise layer 2 lives in the authenticator | Flood test: excess requests rejected at the edge before reaching the authenticator | -| `cpt-insightspec-nfr-auth-fail-closed` | No auth without a live session check | `auth_request` + error shaping | Subrequest failure never passes through -- shaped 503 + `Retry-After`; readiness fails without a reachable authenticator | Kill the authenticator; assert 503 problem-details and not-ready | +| `cpt-insightspec-nfr-auth-fail-closed` | No auth without a live session check | `auth_request` + error shaping | Subrequest failure never passes through -- shaped 503 + `Retry-After` per request; readiness stays local so an authenticator blip does not drain the fleet (see 3.15) | Kill the authenticator; assert per-request 503 problem-details while the gateway stays Ready and keeps serving cache hits, `/auth/*`, and the SPA | **ADRs**: decisions captured inline in [section 4](#4-design-decisions); to be extracted alongside implementation. @@ -247,14 +247,15 @@ Never learns the Redis schema or exchange semantics beyond the HTTP contract (th | ANY | `/auth/*` | Plain proxy to the authenticator (no `auth_request` -- it IS the auth); coarse `limit_req` | stable | | GET | `/.well-known/jwks.json` | Proxy to the authenticator | stable | | ANY | `/api/**` | `auth_request` exchange, hygiene block, proxy to the routed upstream | stable | -| GET | `/healthz` | Static liveness | stable | +| GET | `/healthz` | Static liveness + local readiness (no dependency gating) | stable | +| GET | `/healthz/authenticator` | Dependency health (probes authenticator `/ready`); for monitoring/alerting only, never the readiness gate | stable | | ANY | `/internal/*` | 404, always | stable | ### 3.4 Internal Dependencies | Dependency Module | Interface Used | Purpose | |-------------------|----------------|----------| -| Authenticator | `GET /internal/authz` subrequest (`cpt-insightspec-contract-auth-authz-exchange`) | Cookie-to-JWT exchange; also the readiness signal | +| Authenticator | `GET /internal/authz` subrequest (`cpt-insightspec-contract-auth-authz-exchange`) | Cookie-to-JWT exchange; surfaced as a separate dependency health signal, not wired to the readiness gate (3.15) | | Authenticator | `/auth/*` + JWKS plain proxy | Login surface and key distribution | | insight-front | HTTP (static) | SPA shell at `/` | | Downstream services | HTTP upstreams from `routes.yaml` | Routed business APIs | @@ -392,6 +393,14 @@ The gateway side of `cpt-insightspec-contract-auth-authz-exchange` ([authenticat Revocation staleness price, stated honestly: logout / revoke-all / back-channel / `invalid_grant` take effect at the gateway within at most the cache max-age (default 30 s -- well inside the 300 s acceptance bound), and the cache is per gateway pod, so staleness does not grow with replicas. `authz_cache_max_age_seconds: 0` disables caching for per-request checks. +**Cookie rotation and the cache.** Because the cache key is the raw session-token value, a `200` cached against a token that the authenticator then rotates away on `/auth/refresh` keeps authorizing that *retired* token at this pod until the entry expires -- extending its effective life from the authenticator's rotation grace (`refresh_grace_ms`, default 250 ms) to at most `authz_cache_max_age` (default 30 s). This is the **same bounded staleness envelope already accepted for revocation above**, applied to rotation, and it is bounded on every axis: + +- It does **not** extend a session or defeat revocation. The cached artifact is the session-linked JWT (keyed by the stable `session_id`, not the token); killing the session -- logout, revoke-all, `invalid_grant` -- still takes effect within the same max-age, and any served JWT still dies at its own `exp` (<= 300 s). +- It benefits **only** a party still presenting the old cookie. The legitimate client switched to the new cookie the instant refresh returned, so its old cache entry simply goes unused and ages out; the exposure is exactly the stolen-old-cookie case that cookie rotation targets -- and per the authenticator's own model that detection is deliberately probabilistic and noisy, not deterministic. +- The lever is explicit: `authz_cache_max_age_seconds: 0` disables the cache and returns rotation to the authenticator's 250 ms grace with per-request checks; any value in between trades authenticator load for a correspondingly shorter retired-cookie window. + +A cache-purge-on-rotation hook is deliberately **not** added in v1: the gateway is intentionally a dumb, dependency-free cache (no Redis, no rotation events -- see DD-GW-03), and a per-pod invalidation channel would reintroduce exactly the coupling that design avoids, to shave a bounded 30 s window off an already-probabilistic theft-detection mechanism. If a deployment needs a tighter bound, it lowers `authz_cache_max_age`; if it needs zero, it sets `0`. + ### 3.11 Lua Module - [ ] `p2` - **ID**: `cpt-insightspec-design-gateway-lua-module` @@ -421,7 +430,7 @@ Based on the decision document's failure analysis; upstream errors keep their ow | Route not matched under `/api/` | 404 (no upstream call) | | `/internal/*` from outside | 404, always | | Invalid generated config at reload | `nginx -t` refuses; old workers keep serving (last-good-config) | -| Gateway pod without reachable authenticator | readiness fails (readiness = authenticator `/ready`) | +| Authenticator unreachable fleet-wide | gateway pods stay Ready (local readiness); each `/api/*` cache miss fails closed with a shaped 503 while cache hits, `/auth/*`, JWKS, and the SPA keep serving; a separate dependency health check flips for alerting (see 3.15) | ### 3.13 Reload Procedure @@ -451,7 +460,7 @@ One OpenResty Deployment (at least 2 replicas) behind the single ingress backend - Image: OpenResty; config mounted from the ConfigMap the configurator generated in CI; the Lua module ships in the image. - Reloader sidecar (or checksum-annotation pod roll) applies config changes with `nginx -t && nginx -s reload` (3.13). -- Probes: liveness = static `/healthz`; readiness = authenticator `/ready` reachable (a gateway pod that cannot authenticate anything must not receive traffic). +- Probes: liveness = static `/healthz`; **readiness = local only** (nginx workers accepting connections + a valid config loaded). Readiness is deliberately **not** gated on the authenticator: coupling a caching reverse proxy's readiness to a downstream dependency turns a transient auth blip into a fleet-wide outage -- every pod drops from the Service endpoints at once, draining the very exchange cache meant to absorb the blip and taking down cache hits, `/auth/*` login, JWKS, and SPA delivery along with it. The gateway already degrades gracefully per request (cache hits keep serving; each `/api/*` cache miss fails closed with a shaped 503 + `Retry-After`), so a Ready-but-degraded pod is strictly better than a NotReady one. The authenticator dependency is exposed as a **separate** health check (e.g. `/healthz/authenticator`, probing the authenticator `/ready`) consumed by monitoring/alerting only -- never by the readiness gate. - No volumes, no Redis, no K8s API access -- mounted files only; pods are disposable (3.7). ## 4. Design Decisions