From fdf62e14330a9222949b14900bee91569638261f Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:10:50 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(authenticator):=20/auth/refresh=20ro?= =?UTF-8?q?tation=20+=20session=20management=20surface=20(steps=2010.1?= =?UTF-8?q?=E2=80=9310.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 1 — POST /auth/refresh (G10 rotation model, no swap keys): a fresh CSPRNG token mapping is written and the superseded mapping's TTL drops to refresh_grace_ms (default 250 ms) — the expiring old mapping IS the grace window — while the session's expires_at advances to min(now + session_ttl, absolute cap) across the record, its key TTL, and the per-user index score, all in one pipeline. The stable session_id and the linked JWT are untouched. A stale token inside the grace window resolves to the same session and is answered with the current credential (no re-rotation); past grace or past either cap → 401 + cleared cookie. Response is {expires_at, refresh_at} with refresh_at = expires_at − 90 s ± uniform(60 s) (big-jitter decision, G8), re-jittered per call and shared with GET /auth/me; cookie Max-Age is the actual remaining session life. Item 2 — session management: GET /auth/sessions lists the caller's live sessions from the per-user ZSET (created_at, expires_at, user_agent, ip, current flag; attribution captured at login from User-Agent + first X-Forwarded-For hop, length-capped). DELETE /auth/sessions/{id} revokes one owned session (absent and not-owned are both 404 — no existence oracle); DELETE /auth/sessions revokes everything for the current user. Every revoke runs the standard pipeline: token mappings + session + linked JWT + index entries in one MULTI/EXEC. The admin/service variant DELETE /auth/admin/users/{person_id}/sessions is a .authenticated() operation: the host authn pipeline (cf-gears-oidc-authn-plugin, newly linked) verifies the ES256 gateway JWT against the authenticator's own issuer, and the handler requires one of admin_revoke_roles (default ["session_admin"]) before delegating to the SDK contract (AuthenticatorClientV1::revoke_user_sessions) — the lever the future permissions service pulls (DD-AUTH-07). Config wiring: committed host config flips to auth_disabled: false with a fail-closed .invalid placeholder issuer; dev compose bind-mounts a full-auth override (authn-tls issuer + self-signed CA) and grants the dev testclient the session_admin role; the Helm configmap renders the plugin block off tlsDiscovery (real issuer + in-pod CA when enabled, dark otherwise). Also repairs the e2e harness: identity-stub readiness probed the old /v1/persons path, and two stale ignored e2e asserts predated the space-delimited roles claim and the UUIDv5 service sub. run-e2e.sh now also runs the new refresh + sessions loops; all four e2e loops pass locally. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- deploy/compose/authenticator-fullauth.yaml | 144 ++++++++ docker-compose.yml | 6 + src/backend/Cargo.lock | 1 + src/backend/services/authenticator/Cargo.toml | 1 + .../authenticator/config/insight.yaml | 49 ++- .../helm/templates/configmap.yaml | 59 ++- .../helm/templates/deployment.yaml | 8 + .../services/authenticator/src/api/error.rs | 6 + .../authenticator/src/api/handlers.rs | 335 +++++++++++++++++- .../services/authenticator/src/api/mod.rs | 65 ++++ .../services/authenticator/src/config.rs | 6 + .../services/authenticator/src/gear.rs | 7 +- .../services/authenticator/src/main.rs | 6 +- .../services/authenticator/src/session.rs | 76 +++- .../authenticator/tests/e2e_login_loop.rs | 6 +- .../authenticator/tests/e2e_refresh.rs | 206 +++++++++++ .../authenticator/tests/e2e_service_token.rs | 11 +- .../authenticator/tests/e2e_sessions.rs | 209 +++++++++++ .../services/authenticator/tests/run-e2e.sh | 10 +- 19 files changed, 1173 insertions(+), 38 deletions(-) create mode 100644 deploy/compose/authenticator-fullauth.yaml create mode 100644 src/backend/services/authenticator/tests/e2e_refresh.rs create mode 100644 src/backend/services/authenticator/tests/e2e_sessions.rs diff --git a/deploy/compose/authenticator-fullauth.yaml b/deploy/compose/authenticator-fullauth.yaml new file mode 100644 index 000000000..beb673c9f --- /dev/null +++ b/deploy/compose/authenticator-fullauth.yaml @@ -0,0 +1,144 @@ +# dev-compose full-auth authenticator host config (bind-mounted over +# /app/config/insight.yaml). Same structure as +# services/authenticator/config/insight.yaml, but with the oidc-authn-plugin +# wired to the dev TLS discovery front (`authn-tls`, i.e. the authenticator's +# own issuer) + its self-signed CA at /certs/ca.pem, so the `.authenticated()` +# admin surface (session revoke-by-user) verifies real gateway JWTs in dev. +# Redis/Identity/OIDC leaves come from APP__ env in docker-compose.yml. + +server: + home_dir: "/tmp/authenticator" + +logging: + default: + console_level: info + +gears: + api-gateway: + config: + bind_addr: "0.0.0.0:8083" + enable_docs: true + cors_enabled: true + openapi: + title: "Insight Authenticator" + version: "0.1.0" + description: "OIDC login, opaque sessions, and the cookie-to-JWT exchange (BFF / token-handler)" + auth_disabled: false + + gear-orchestrator: + config: {} + + grpc-hub: + config: + listen_addr: "uds:///tmp/authenticator-grpc" + + authn-resolver: + config: + vendor: "hyperspot" + + # Gateway-JWT verification for the `.authenticated()` admin surface. Verifies + # the ES256 gateway JWT against our own JWKS (resolved via OIDC discovery on + # the issuer) and maps claims into the SecurityContext. The deployment + # supplies the real issuer + self-signed CA (dev/e2e) via the config layer; + # the placeholders below (`.invalid`, never resolvable) fail closed. + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + trusted_issuers: + # = the authenticator's own gateway_issuer (the token `iss`); + # discovery_url omitted → {issuer}/.well-known/openid-configuration, + # served by the authn-tls front. + - issuer: "https://authn-tls:8443" + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + custom_ca_certificate_paths: ["/certs/ca.pem"] + # Client-credentials exchange is UNUSED here (the authenticator makes no + # outbound S2S calls through the plugin) but the block is required config. + s2s_oauth: + discovery_url: "https://authn-tls:8443" + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + + authz-resolver: + config: + vendor: "hyperspot" + + static-authz-plugin: + config: + vendor: "hyperspot" + priority: 100 + + tenant-resolver: + config: + vendor: "hyperspot" + + single-tenant-tr-plugin: + config: + vendor: "hyperspot" + priority: 20 + + authenticator: + config: + # HTTP bind is owned by api-gateway above; retained for diagnostics only. + bind_addr: "0.0.0.0:8083" + # Session + JWT lifecycle defaults are the §4.1 table (baked into the + # config struct); overridden here only when a deployment needs to. + # Connection strings + OIDC client secret are injected via env. + redis_url: "" + signing_keys_path: "" + identity_url: "" + gateway_issuer: "" + jwt_audience: "internal-services" + redirect_uri: "" + default_return_to: "/" + # Login scopes. offline_access is omitted (survives-logout token, wrong for a + # BFF); add it only for an IdP that needs it for a refresh token, e.g. Entra. + oidc_scopes: ["openid", "email", "profile"] + idp: + issuer_url: "" + client_id: "" + client_secret: "" + # id_token claim naming the user's single tenant (string; an array is + # tolerated — first entry wins). fakeidp/Keycloak emit `tenant_id`; + # Entra emits `tid`. + tenant_claim: "tenant_id" + # Fallback tenant for a claim-less IdP (e.g. Okta); empty = fail closed. + default_tenant_id: "" + # Service tokens (§10 G1 / DD-AUTH-05). The token endpoint runs on its own + # listener (token_bind_addr) so it never shares the main port. Service + # tokens are always tenant-scoped; the caller names the tenant. + # + # `testclient` is DEV/TEST ONLY. No key material is committed: dev-compose + # and run-e2e generate a throwaway keypair (like the gateway signing key) + # and drop its public half in public_key_dir; the private half is handed + # to the calling client. Real services land their public key via a gitops + # PR (chart ConfigMap uses inline public_keys), never this test entry. + service_tokens: + token_bind_addr: "0.0.0.0:8093" + audience: "http://localhost:8093/internal/token" + assertion_max_lifetime_seconds: 60 + token_ttl_seconds: 300 + # Set via env in dev/e2e to the generated-key dir (see run-e2e.sh / + # dev-compose.sh): APP__gears__authenticator__config__service_tokens__public_key_dir + public_key_dir: "" + services: + testclient: + public_key_paths: ["testclient.pub.pem"] + # session_admin authorizes the admin revoke-by-user operation — + # dev/e2e only; real registry entries earn it via a gitops PR. + roles: ["service", "session_admin"] diff --git a/docker-compose.yml b/docker-compose.yml index 5a92f7c1e..a9a3ac76e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -321,6 +321,12 @@ services: volumes: - ./deploy/compose/build/authenticator/authenticator:/app/authenticator:ro - ./src/backend/services/authenticator/config:/app/config:ro + # Full-auth plugin config (issuer + CA) — bind-mounted over the committed + # placeholder config so the `.authenticated()` admin surface verifies real + # gateway JWTs in dev (the authenticator trusts its own tokens). + - ./deploy/compose/authenticator-fullauth.yaml:/app/config/insight.yaml:ro + # Self-signed CA for the authn-tls discovery front. + - ./deploy/compose/authn-tls-certs:/certs:ro # dev signing key (current.pem) + dev service-token pubkey, both generated # by dev-compose.sh into this gitignored dir and mounted at signing_keys_path. - ./deploy/compose/authenticator-dev-keys:/app/keys:ro diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 0ca205df6..3c93511ae 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "cf-gears-authz-resolver", "cf-gears-gear-orchestrator", "cf-gears-grpc-hub", + "cf-gears-oidc-authn-plugin", "cf-gears-single-tenant-tr-plugin", "cf-gears-static-authz-plugin", "cf-gears-tenant-resolver", diff --git a/src/backend/services/authenticator/Cargo.toml b/src/backend/services/authenticator/Cargo.toml index 99281c0ee..56bc531d1 100644 --- a/src/backend/services/authenticator/Cargo.toml +++ b/src/backend/services/authenticator/Cargo.toml @@ -28,6 +28,7 @@ toolkit-canonical-errors = { workspace = true } # via inventory; mirrors the analytics service's gear set. api_gateway = { workspace = true } authn-resolver = { workspace = true } +oidc-authn-plugin = { workspace = true } authz-resolver = { workspace = true } tenant-resolver = { workspace = true } single-tenant-tr-plugin = { workspace = true } diff --git a/src/backend/services/authenticator/config/insight.yaml b/src/backend/services/authenticator/config/insight.yaml index 6d1602e61..661f7331c 100644 --- a/src/backend/services/authenticator/config/insight.yaml +++ b/src/backend/services/authenticator/config/insight.yaml @@ -9,10 +9,11 @@ # in this EPIC. The authenticator hosts its endpoints on this REST-host gear and # runs behind the nginx edge. # -# Auth is DISABLED on the host because every step-04 endpoint is `.public()` -# (the credential is the session cookie, checked in the handler), so no OIDC -# plugin is registered. The admin session-revoke surface (later step) will flip -# to an authenticated pipeline verifying gateway JWTs. +# Auth is ENABLED on the host for the `.authenticated()` admin surface +# (session revoke-by-user): the oidc-authn-plugin verifies the ES256 gateway +# JWT — the authenticator trusts its own tokens exactly like any downstream +# service. Every browser endpoint stays `.public()` (the credential is the +# session cookie, checked in the handler) and bypasses the plugin. # # Deployment-specific leaf values are injected via env overrides # (APP__gears__authenticator__config__*) from the umbrella Secret / compose; @@ -43,7 +44,7 @@ gears: title: "Insight Authenticator" version: "0.1.0" description: "OIDC login, opaque sessions, and the cookie-to-JWT exchange (BFF / token-handler)" - auth_disabled: true + auth_disabled: false gear-orchestrator: config: {} @@ -56,6 +57,40 @@ gears: config: vendor: "hyperspot" + # Gateway-JWT verification for the `.authenticated()` admin surface. Verifies + # the ES256 gateway JWT against our own JWKS (resolved via OIDC discovery on + # the issuer) and maps claims into the SecurityContext. The deployment + # supplies the real issuer + self-signed CA (dev/e2e) via the config layer; + # the placeholders below (`.invalid`, never resolvable) fail closed. + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + trusted_issuers: + - issuer: "https://gateway.invalid" + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + # Client-credentials exchange is UNUSED here (the authenticator makes no + # outbound S2S calls through the plugin) but the block is required config. + s2s_oauth: + discovery_url: "https://gateway.invalid" + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + authz-resolver: config: vendor: "hyperspot" @@ -121,4 +156,6 @@ gears: services: testclient: public_key_paths: ["testclient.pub.pem"] - roles: ["service"] + # session_admin authorizes the admin revoke-by-user operation — + # dev/e2e only; real registry entries earn it via a gitops PR. + roles: ["service", "session_admin"] diff --git a/src/backend/services/authenticator/helm/templates/configmap.yaml b/src/backend/services/authenticator/helm/templates/configmap.yaml index 6ed224c0c..a95995bef 100644 --- a/src/backend/services/authenticator/helm/templates/configmap.yaml +++ b/src/backend/services/authenticator/helm/templates/configmap.yaml @@ -15,10 +15,13 @@ data: # NOT the Insight platform gateway that nginx replaces. The authenticator # hosts on it, behind the nginx edge. # - # Every step-04 endpoint is `.public()` (the credential is the session cookie, - # checked in the handler), so the REST host runs with auth disabled and no - # OIDC plugin. The signing keys come from the mounted Secret at - # `signingKeysPath`. + # Browser endpoints are `.public()` (the credential is the session cookie, + # checked in the handler); the `.authenticated()` admin surface (session + # revoke-by-user) is verified by the oidc-authn-plugin against the + # authenticator's OWN issuer — through the TLS discovery front when enabled + # (the plugin resolves discovery over HTTPS only), else against a + # fail-closed `.invalid` placeholder. The signing keys come from the mounted + # Secret at `signingKeysPath`. authenticator.yaml: | server: home_dir: "/app/data" @@ -31,7 +34,7 @@ data: api-gateway: config: bind_addr: "0.0.0.0:{{ .Values.service.port }}" - auth_disabled: true + auth_disabled: false cors_enabled: false enable_docs: false openapi: @@ -50,6 +53,52 @@ data: config: vendor: "hyperspot" + # Gateway-JWT verification for the `.authenticated()` admin surface. The + # issuer is the authenticator's own gateway_issuer; with the TLS + # discovery front on, discovery resolves in-pod over HTTPS with the + # front's CA. Without it there is no HTTPS discovery source, so a + # fail-closed placeholder keeps the admin surface dark (public endpoints + # are unaffected). + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + trusted_issuers: +{{- if .Values.tlsDiscovery.enabled }} + - issuer: "https://{{ include "insight-authenticator.fullname" . }}:{{ .Values.tlsDiscovery.port }}" +{{- else }} + - issuer: "https://gateway.invalid" +{{- end }} + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s +{{- if .Values.tlsDiscovery.enabled }} + custom_ca_certificate_paths: ["/app/authn-ca/ca.crt"] +{{- end }} + # Client-credentials exchange is UNUSED here but the block is + # required config; never fetched unless an exchange is performed. + s2s_oauth: +{{- if .Values.tlsDiscovery.enabled }} + discovery_url: "https://{{ include "insight-authenticator.fullname" . }}:{{ .Values.tlsDiscovery.port }}" +{{- else }} + discovery_url: "https://gateway.invalid" +{{- end }} + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + authz-resolver: config: vendor: "hyperspot" diff --git a/src/backend/services/authenticator/helm/templates/deployment.yaml b/src/backend/services/authenticator/helm/templates/deployment.yaml index 1db7ff4ba..4ed8db244 100644 --- a/src/backend/services/authenticator/helm/templates/deployment.yaml +++ b/src/backend/services/authenticator/helm/templates/deployment.yaml @@ -51,6 +51,14 @@ spec: - name: signing-keys mountPath: {{ .Values.signingKeysPath | quote }} readOnly: true + {{- if .Values.tlsDiscovery.enabled }} + # CA of the in-pod TLS discovery front — the oidc-authn-plugin + # verifies the `.authenticated()` admin surface against our own + # issuer through it. + - name: authn-tls-cert + mountPath: /app/authn-ca + readOnly: true + {{- end }} ports: - name: http containerPort: {{ .Values.service.port }} diff --git a/src/backend/services/authenticator/src/api/error.rs b/src/backend/services/authenticator/src/api/error.rs index 5575679f2..8f7c858f8 100644 --- a/src/backend/services/authenticator/src/api/error.rs +++ b/src/backend/services/authenticator/src/api/error.rs @@ -17,3 +17,9 @@ pub struct OidcError; /// client assertion, replay, or a refused tenant scope. #[resource_error("gts.cf.insight.authenticator.service_token.v1~")] pub struct ServiceTokenError; + +/// Session-management failures (`/auth/sessions*`): an absent (or not-owned — +/// deliberately indistinguishable) session, or a caller without the authorized +/// admin role. +#[resource_error("gts.cf.insight.authenticator.session.v1~")] +pub struct SessionError; diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 38334ad79..0a0eeedea 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -1,8 +1,8 @@ -//! HTTP handlers for the step-04 surface: `/auth/login`, `/auth/callback`, -//! `/internal/authz`, `/.well-known/jwks.json`, `/auth/me`, `/auth/logout`. +//! HTTP handlers for the browser/gateway surface: `/auth/login`, +//! `/auth/callback`, `/auth/refresh`, `/auth/me`, `/auth/logout`, +//! `/internal/authz`, `/.well-known/jwks.json`. //! -//! Deferred (later steps): `/auth/refresh`, `/auth/sessions`, CSRF enforcement, -//! back-channel logout, `/internal/token`. +//! `/internal/token` lives on the dedicated token listener (`service_token`). use std::sync::Arc; @@ -20,7 +20,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::api::AppState; -use crate::api::error::{OidcError, PersonError}; +use crate::api::error::{OidcError, PersonError, SessionError}; use crate::cookie; use crate::identity::PersonResolution; use crate::jwt::GatewayClaims; @@ -99,6 +99,7 @@ pub struct CallbackParams { pub async fn callback( Extension(state): Extension>, jar: CookieJar, + headers: axum::http::HeaderMap, Query(params): Query, ) -> Response { if let Some(err) = params.error { @@ -181,7 +182,8 @@ pub async fn callback( // `return_to` was sanitized at login time and stored with the login state. let return_to = login_state.return_to.clone(); - match mint_and_store_session(&state, &idp, &resolution).await { + let client = ClientInfo::from_headers(&headers); + match mint_and_store_session(&state, &idp, &resolution, &client).await { Ok(token) => { let jar = jar.add(cookie::session_cookie( &token, @@ -198,12 +200,43 @@ pub async fn callback( } } +/// Client attribution captured at login for the session list (PRD 5.9): +/// the User-Agent and the client IP as the gateway saw it (first +/// `X-Forwarded-For` hop; nginx guards the header with `set_real_ip_from`). +struct ClientInfo { + user_agent: String, + ip: String, +} + +impl ClientInfo { + fn from_headers(headers: &axum::http::HeaderMap) -> Self { + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + }; + // Attribution only (never authorization) — cap length so a hostile + // header can't bloat the session record. + let mut user_agent = header("user-agent").to_owned(); + user_agent.truncate(256); + let ip = header("x-forwarded-for") + .split(',') + .next() + .unwrap_or_default() + .trim() + .to_owned(); + Self { user_agent, ip } + } +} + /// Build claims, sign the linked JWT, and persist the session in one pipeline. /// Returns the cookie token. async fn mint_and_store_session( state: &AppState, idp: &crate::oidc::AuthenticatedIdp, resolution: &PersonResolution, + client: &ClientInfo, ) -> anyhow::Result { let now = now_secs(); let cfg = &state.cfg; @@ -258,8 +291,8 @@ async fn mint_and_store_session( created_at: now, expires_at, absolute_expires_at, - user_agent: String::new(), - ip: String::new(), + user_agent: client.user_agent.clone(), + ip: client.ip.clone(), csrf_token, current_token: token.clone(), }; @@ -427,12 +460,7 @@ pub async fn me(Extension(state): Extension>, jar: CookieJar) -> R return unauthenticated(); } - let margin = state.cfg.session_refresh_safety_margin_seconds; - let half_jitter = state.cfg.refresh_jitter_seconds / 2; - let refresh_at = record - .expires_at - .saturating_sub(margin) - .saturating_add_signed(jitter_seconds(half_jitter)); + let refresh_at = refresh_at_for(&state.cfg, record.expires_at); let body = serde_json::json!({ "user": record.person_id, @@ -446,6 +474,77 @@ pub async fn me(Extension(state): Extension>, jar: CookieJar) -> R json_ok(body) } +// ── /auth/refresh ──────────────────────────────────────────────────────────── + +/// Rotate the session credential and extend the session (PRD 5.4, G10 model): +/// new CSPRNG token mapping, old mapping demoted to the grace TTL, session +/// `expires_at` advanced to `min(now + ttl, absolute_cap)` — one pipeline. The +/// stable `session_id` and the linked JWT are untouched. A stale token still +/// inside the grace window resolves to the same session and is answered with +/// the current state, no second rotation; past grace → 401 + clear cookie. +pub async fn refresh(Extension(state): Extension>, jar: CookieJar) -> Response { + let Some(token) = cookie::read(&jar) else { + return unauthenticated_clear_cookie(jar); + }; + let (session_id, record) = match state.sessions.resolve_by_token(&token).await { + Ok(Some(r)) => r, + Ok(None) => return unauthenticated_clear_cookie(jar), + Err(e) => return internal_problem("session_store", &e), + }; + let now = now_secs(); + if record.expires_at <= now || record.absolute_expires_at <= now { + return unauthenticated_clear_cookie(jar); + } + + // Grace path: the presented token has already been rotated past (the old + // mapping lives out its grace TTL). Answer with the current state and the + // current cookie value — rotating again would burn the grace guarantee. + if record.current_token != token { + tracing::debug!(session_id = %session_id, "refresh within rotation grace: no re-rotation"); + return refresh_ok(&state, jar, &record.current_token, record.expires_at, now); + } + + let new_token = csprng_token(); + let new_expires_at = (now + state.cfg.session_ttl_seconds).min(record.absolute_expires_at); + if let Err(e) = state + .sessions + .rotate_session( + &session_id, + &record, + &new_token, + new_expires_at, + state.cfg.refresh_grace_ms, + ) + .await + { + return internal_problem("rotate_session", &e); + } + tracing::debug!(session_id = %session_id, expires_at = new_expires_at, "session refreshed (credential rotated)"); + refresh_ok(&state, jar, &new_token, new_expires_at, now) +} + +/// `200 {expires_at, refresh_at}` + the (re-)issued session cookie. `Max-Age` +/// is the session's actual remaining life, so the cookie can never outlive the +/// absolute cap. +fn refresh_ok( + state: &AppState, + jar: CookieJar, + token: &str, + expires_at: u64, + now: u64, +) -> Response { + let body = serde_json::json!({ + "expires_at": expires_at, + "refresh_at": refresh_at_for(&state.cfg, expires_at), + }) + .to_string(); + let jar = jar.add(cookie::session_cookie( + token, + expires_at.saturating_sub(now), + )); + (jar, json_ok(body)).into_response() +} + // ── /auth/logout ───────────────────────────────────────────────────────────── /// Revoke the session, clear the cookie, and return the RP-logout URL. @@ -476,6 +575,177 @@ pub async fn logout(Extension(state): Extension>, jar: CookieJar) (jar, resp).into_response() } +// ── /auth/sessions (PRD 5.9) ───────────────────────────────────────────────── + +/// List the caller's active sessions from the per-user index (score > now): +/// created_at, expires_at, user_agent, ip, and a `current` flag. +pub async fn sessions_list(Extension(state): Extension>, jar: CookieJar) -> Response { + let Some(token) = cookie::read(&jar) else { + return unauthenticated(); + }; + let (current_id, record) = match state.sessions.resolve_by_token(&token).await { + Ok(Some(r)) => r, + Ok(None) => return unauthenticated(), + Err(e) => return internal_problem("session_store", &e), + }; + let now = now_secs(); + if record.expires_at <= now || record.absolute_expires_at <= now { + return unauthenticated(); + } + + let sessions = match state + .sessions + .list_user_sessions(&record.person_id, now) + .await + { + Ok(s) => s, + Err(e) => return internal_problem("session_list", &e), + }; + let items: Vec = sessions + .iter() + .map(|(sid, r)| { + serde_json::json!({ + "session_id": sid, + "created_at": r.created_at, + "expires_at": r.expires_at, + "user_agent": r.user_agent, + "ip": r.ip, + "current": *sid == current_id, + }) + }) + .collect(); + json_ok(serde_json::json!({ "sessions": items }).to_string()) +} + +/// Revoke one of the caller's sessions by id. A session that does not exist or +/// belongs to someone else is answered 404 (no existence oracle). Revoking the +/// current session also clears the cookie. +pub async fn sessions_revoke_one( + Extension(state): Extension>, + jar: CookieJar, + axum::extract::Path(target_id): axum::extract::Path, +) -> Response { + let Some(token) = cookie::read(&jar) else { + return unauthenticated(); + }; + let (current_id, record) = match state.sessions.resolve_by_token(&token).await { + Ok(Some(r)) => r, + Ok(None) => return unauthenticated(), + Err(e) => return internal_problem("session_store", &e), + }; + + let target = match state.sessions.load_session(&target_id).await { + Ok(t) => t, + Err(e) => return internal_problem("session_load", &e), + }; + let owned = target + .as_ref() + .is_some_and(|t| t.person_id == record.person_id); + if !owned { + return not_found(&target_id); + } + if let Err(e) = state.sessions.revoke_session(&target_id).await { + return internal_problem("session_revoke", &e); + } + tracing::info!( + target: "audit", + event = "session_revoked", + session_id = %target_id, + person_id = %record.person_id, + by = "self", + "session revoked" + ); + + let resp = json_ok(serde_json::json!({ "revoked": 1 }).to_string()); + if target_id == current_id { + return (jar.add(cookie::clear_cookie()), resp).into_response(); + } + resp +} + +/// Revoke every session of the current user ("log out everywhere") and clear +/// the cookie. +pub async fn sessions_revoke_all( + Extension(state): Extension>, + jar: CookieJar, +) -> Response { + let Some(token) = cookie::read(&jar) else { + return unauthenticated(); + }; + let (_, record) = match state.sessions.resolve_by_token(&token).await { + Ok(Some(r)) => r, + Ok(None) => return unauthenticated(), + Err(e) => return internal_problem("session_store", &e), + }; + + let revoked = match state.sessions.revoke_user_sessions(&record.person_id).await { + Ok(n) => n, + Err(e) => return internal_problem("session_revoke_all", &e), + }; + tracing::info!( + target: "audit", + event = "sessions_revoked_all", + person_id = %record.person_id, + revoked, + by = "self", + "all sessions revoked" + ); + let resp = json_ok(serde_json::json!({ "revoked": revoked }).to_string()); + (jar.add(cookie::clear_cookie()), resp).into_response() +} + +/// Admin/service revoke-by-user (PRD 5.9 "admin variant"): the host authn +/// pipeline has already verified the gateway JWT and built the +/// [`SecurityContext`]; this handler enforces the authorized role +/// (`admin_revoke_roles`) and delegates to the SDK contract +/// (`AuthenticatorClientV1::revoke_user_sessions`) — the same lever the +/// future permissions service pulls on grant changes (DD-AUTH-07). +pub async fn admin_revoke_user_sessions( + Extension(state): Extension>, + Extension(ctx): Extension, + axum::extract::Path(person_id): axum::extract::Path, +) -> Response { + let allowed = ctx + .token_scopes() + .iter() + .any(|scope| state.cfg.admin_revoke_roles.iter().any(|r| r == scope)); + if !allowed { + tracing::warn!( + target: "audit", + event = "admin_session_revoke_denied", + subject = %ctx.subject_id(), + subject_type = ctx.subject_type().unwrap_or(""), + person_id = %person_id, + "admin session revoke denied: missing authorized role" + ); + return SessionError::permission_denied() + .with_reason("missing_authorized_role") + .create() + .into_response(); + } + + match state + .authn_client + .revoke_user_sessions(&person_id.to_string()) + .await + { + Ok(revoked) => { + tracing::info!( + target: "audit", + event = "sessions_revoked_all", + person_id = %person_id, + revoked, + by = "admin", + subject = %ctx.subject_id(), + subject_type = ctx.subject_type().unwrap_or(""), + "all sessions revoked (admin)" + ); + json_ok(serde_json::json!({ "revoked": revoked }).to_string()) + } + Err(e) => e.into_response(), + } +} + // ── Pure helpers (unit-tested) ─────────────────────────────────────────────── /// Compute the `/internal/authz` 200 `Cache-Control`: @@ -492,6 +762,16 @@ pub fn cache_control_for(exp: u64, now: u64, authz_cache_max_age: u64) -> String } } +/// The server-supplied refresh moment: `expires_at − margin ± jitter/2` (G8 — +/// the deliberately big jitter spreads NAT'd-office refresh waves into a +/// uniform trickle and keeps an attacker from aligning to the rotation grace +/// window). Re-jittered on every call. +fn refresh_at_for(cfg: &crate::config::AuthenticatorConfig, expires_at: u64) -> u64 { + expires_at + .saturating_sub(cfg.session_refresh_safety_margin_seconds) + .saturating_add_signed(jitter_seconds(cfg.refresh_jitter_seconds / 2)) +} + /// Sanitize an SPA-supplied `return_to`: accept only a site-relative path (one /// leading `/`, not `//` — which would be protocol-relative / open-redirect). #[must_use] @@ -564,6 +844,21 @@ fn unauthenticated() -> Response { ) } +/// 404 that does not distinguish "absent" from "not yours" (no existence oracle). +fn not_found(resource: &str) -> Response { + SessionError::not_found("session not found") + .with_resource(resource) + .create() + .into_response() +} + +/// 401 that also clears the session cookie — for `/auth/refresh`, where a dead +/// credential must not linger in the browser (PRD 5.4 case 1). +fn unauthenticated_clear_cookie(jar: CookieJar) -> Response { + let jar = jar.add(cookie::clear_cookie()); + (jar, unauthenticated()).into_response() +} + fn internal_problem(context: &str, err: &anyhow::Error) -> Response { tracing::error!(context, error = %err, "authenticator internal error"); toolkit_canonical_errors::CanonicalError::internal(format!("{context}: {err}")) @@ -615,6 +910,18 @@ mod tests { assert_eq!(sanitize_return_to(None, "/home"), "/home"); } + #[test] + fn refresh_at_stays_inside_the_jitter_window() { + // margin 90, full jitter 120 (±60): refresh_at ∈ [exp−150, exp−30] — + // the late edge still leaves ≥30 s of session life (G8). + let cfg = crate::config::AuthenticatorConfig::default(); + let expires_at = 10_000; + for _ in 0..200 { + let at = refresh_at_for(&cfg, expires_at); + assert!((expires_at - 150..=expires_at - 30).contains(&at), "{at}"); + } + } + #[test] fn jwt_exp_reads_payload_without_verification() { // header.payload.sig with payload = {"exp": 4000000000} diff --git a/src/backend/services/authenticator/src/api/mod.rs b/src/backend/services/authenticator/src/api/mod.rs index 7e6a9b3fc..a5e991292 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -27,6 +27,10 @@ pub struct AppState { pub resolver: Arc, /// Parsed service-token registry (DD-AUTH-05); used by the token listener. pub service_registry: ServiceRegistry, + /// The SDK contract impl (also registered in the `ClientHub`): the admin + /// revoke-by-user operation goes through it, so the HTTP surface and + /// in-process consumers (the future permissions service) share one path. + pub authn_client: Arc, } /// Register the authenticator routes onto the host router. The `Extension` @@ -76,6 +80,53 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .handler(handlers::callback) .register(router, openapi); + router = OperationBuilder::get("/auth/sessions") + .operation_id("authenticator.sessions.list") + .summary("List the current user's active sessions") + .tag("auth") + .public() + .text_response(StatusCode::OK, "Active sessions", "application/json") + .error_401(openapi) + .handler(handlers::sessions_list) + .register(router, openapi); + + router = OperationBuilder::delete("/auth/sessions/{session_id}") + .operation_id("authenticator.sessions.revoke") + .summary("Revoke one of the current user's sessions") + .tag("auth") + .public() + .text_response(StatusCode::OK, "Revocation result", "application/json") + .error_401(openapi) + .error_404(openapi) + .handler(handlers::sessions_revoke_one) + .register(router, openapi); + + router = OperationBuilder::delete("/auth/sessions") + .operation_id("authenticator.sessions.revoke_all") + .summary("Revoke all sessions of the current user (log out everywhere)") + .tag("auth") + .public() + .text_response(StatusCode::OK, "Revocation result", "application/json") + .error_401(openapi) + .handler(handlers::sessions_revoke_all) + .register(router, openapi); + + // Admin/service variant (PRD 5.9): `.authenticated()` — the host authn + // pipeline verifies a gateway JWT (the authenticator trusts its own tokens + // exactly like any downstream service, G10) and the handler enforces the + // authorized role. + router = OperationBuilder::delete("/auth/admin/users/{person_id}/sessions") + .operation_id("authenticator.sessions.admin_revoke_by_user") + .summary("Revoke every session of a user (admin/service, gateway-JWT authenticated)") + .tag("auth") + .authenticated() + .no_license_required() + .text_response(StatusCode::OK, "Revocation result", "application/json") + .error_401(openapi) + .error_403(openapi) + .handler(handlers::admin_revoke_user_sessions) + .register(router, openapi); + router = OperationBuilder::get("/auth/me") .operation_id("authenticator.me") .summary("Current session summary for the SPA") @@ -86,6 +137,20 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .handler(handlers::me) .register(router, openapi); + router = OperationBuilder::post("/auth/refresh") + .operation_id("authenticator.refresh") + .summary("Rotate the session cookie and extend the session (grace-tolerant)") + .tag("auth") + .public() + .text_response( + StatusCode::OK, + "{expires_at, refresh_at} + re-issued cookie", + "application/json", + ) + .error_401(openapi) + .handler(handlers::refresh) + .register(router, openapi); + OperationBuilder::post("/auth/logout") .operation_id("authenticator.logout") .summary("Revoke the session, clear the cookie, return the RP-logout URL") diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index 3e8f07977..2bea33216 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -188,6 +188,11 @@ pub struct AuthenticatorConfig { // ── Cross-cutting ──────────────────────────────────────────────────── /// CSRF `Origin` allowlist (empty = token-required, fail closed). pub csrf_origins: Vec, + /// Roles (gateway-JWT `roles` scopes) authorized to call the admin + /// revoke-by-user operation. The service registry grants one of these to + /// the services that may force-logout users (e.g. the future permissions + /// service on grant changes, DD-AUTH-07). + pub admin_revoke_roles: Vec, // ── Dependencies ───────────────────────────────────────────────────── /// Redis connection URL (`redis://host:port`). @@ -253,6 +258,7 @@ impl Default for AuthenticatorConfig { ], default_return_to: "/".to_owned(), csrf_origins: Vec::new(), + admin_revoke_roles: vec!["session_admin".to_owned()], redis_url: String::new(), signing_keys_path: String::new(), identity_url: String::new(), diff --git a/src/backend/services/authenticator/src/gear.rs b/src/backend/services/authenticator/src/gear.rs index e259efe39..75e435ff0 100644 --- a/src/backend/services/authenticator/src/gear.rs +++ b/src/backend/services/authenticator/src/gear.rs @@ -84,8 +84,12 @@ impl Gear for AuthenticatorGear { ); // Register the inter-gear client contract in the hub (DESIGN §3.10). + // The same instance backs the admin revoke-by-user HTTP operation, so + // the SDK contract is the single revoke path. + let authn_client: Arc = + Arc::new(LocalClient::new(sessions.clone())); ctx.client_hub() - .register::(Arc::new(LocalClient::new(sessions.clone()))); + .register::(authn_client.clone()); let state = Arc::new(AppState { cfg, @@ -94,6 +98,7 @@ impl Gear for AuthenticatorGear { oidc, resolver, service_registry, + authn_client, }); self.state .set(state) diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index 09ec546b1..9b752407e 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -33,13 +33,15 @@ mod service_token; mod session; // System gears — linked via inventory for the REST host + auth pipeline. -// Mirrors the analytics service's set (the authenticator authenticates its own -// admin surface with the same pipeline in a later step). +// Mirrors the analytics service's set. The oidc-authn-plugin verifies gateway +// JWTs on the `.authenticated()` admin surface (session revoke-by-user) — the +// authenticator trusts its own tokens exactly like any downstream service. use api_gateway as _; use authn_resolver as _; use authz_resolver as _; use gear_orchestrator as _; use grpc_hub as _; +use oidc_authn_plugin as _; use single_tenant_tr_plugin as _; use static_authz_plugin as _; use tenant_resolver as _; diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 997f343cc..33247b694 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -5,10 +5,10 @@ //! linked JWT, indexes, and refresh schedule stay consistent. The store fails //! closed: a Redis error surfaces to the handler, which answers 401/503. //! -//! Step 04 implements create / resolve / exchange-reissue / revoke and the -//! login-state store. Rotation (`/auth/refresh`), the sid-index consumer -//! (back-channel logout), and the refresh-due consumer (IdP refresher) are -//! wired into the schema here but their *consumers* land in later steps. +//! Owns create / resolve / rotate (`/auth/refresh`) / exchange-reissue / +//! revoke and the login-state store. The sid-index consumer (back-channel +//! logout) and the refresh-due consumer (IdP refresher) key off the schema +//! written here. use std::collections::HashMap; @@ -419,6 +419,74 @@ impl SessionManager { Ok(set.is_some()) } + /// Rotate the session credential (`POST /auth/refresh`, DESIGN §3.6 + /// "Session refresh — rotation without churn"): write the new token + /// mapping, shorten the superseded mapping's TTL to the rotation grace, + /// advance the session's `expires_at` (record field, key TTL, and per-user + /// index score) — one pipeline. The stable `session_id`, the linked JWT, + /// and every other index stay untouched (G10). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn rotate_session( + &self, + session_id: &str, + record: &SessionRecord, + new_token: &str, + new_expires_at: u64, + grace_ms: u64, + ) -> anyhow::Result<()> { + let mut conn = self.conn.clone(); + let skey = session_key(session_id); + let expires_at = i64::try_from(new_expires_at).unwrap_or(i64::MAX); + + let mut pipe = redis::pipe(); + pipe.atomic(); + pipe.set(token_key(new_token), session_id).ignore(); + pipe.expire_at(token_key(new_token), expires_at).ignore(); + // The expiring old mapping IS the grace window (no swap keys). + pipe.pexpire( + token_key(&record.current_token), + i64::try_from(grace_ms).unwrap_or(250), + ) + .ignore(); + pipe.hset(&skey, "expires_at", new_expires_at.to_string()) + .ignore(); + pipe.hset(&skey, "current_token", new_token).ignore(); + pipe.expire_at(&skey, expires_at).ignore(); + pipe.zadd(user_sessions_key(&record.person_id), session_id, expires_at) + .ignore(); + pipe.query_async::<()>(&mut conn) + .await + .context("rotate session pipeline")?; + Ok(()) + } + + /// List a person's live sessions from the per-user index (score > `now`), + /// loading each record. Index members whose record has already expired are + /// skipped (the janitor trims them). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn list_user_sessions( + &self, + person_id: &str, + now: u64, + ) -> anyhow::Result> { + let mut conn = self.conn.clone(); + let session_ids: Vec = conn + .zrangebyscore(user_sessions_key(person_id), format!("({now}"), "+inf") + .await + .context("list user sessions by score")?; + let mut out = Vec::with_capacity(session_ids.len()); + for sid in session_ids { + if let Some(record) = self.load_session(&sid).await? { + out.push((sid, record)); + } + } + Ok(out) + } + /// Revoke one session — delete session, linked JWT, live token mapping, /// ZSET member, sid-index member, and refresh-due member in one pipeline /// (DESIGN §3.2 "Revoke"). Idempotent. Returns `true` if a session existed. diff --git a/src/backend/services/authenticator/tests/e2e_login_loop.rs b/src/backend/services/authenticator/tests/e2e_login_loop.rs index c3672e6db..dd8c56fc6 100644 --- a/src/backend/services/authenticator/tests/e2e_login_loop.rs +++ b/src/backend/services/authenticator/tests/e2e_login_loop.rs @@ -76,7 +76,9 @@ struct Jwk { struct Claims { sub: String, tenant_id: String, - roles: Vec, + /// Space-delimited on the wire (OAuth `scope` shape — the downstream + /// verifier's `token_scopes` mapping splits on whitespace). + roles: String, sid: String, aud: String, } @@ -174,7 +176,7 @@ async fn full_login_exchange_logout_loop() { assert!(!claims.sub.is_empty(), "JWT sub (person_id) must be set"); assert_eq!(claims.aud, "internal-services"); assert!( - claims.roles.contains(&"user".to_owned()), + claims.roles.split_whitespace().any(|r| r == "user"), "default role present" ); assert!(!claims.sid.is_empty(), "stable sid present"); diff --git a/src/backend/services/authenticator/tests/e2e_refresh.rs b/src/backend/services/authenticator/tests/e2e_refresh.rs new file mode 100644 index 000000000..4b6e2cfee --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_refresh.rs @@ -0,0 +1,206 @@ +//! End-to-end `/auth/refresh` rotation-with-grace against a running +//! authenticator + fakeidp + Redis (nginx+auth step 10, item 1). +//! +//! `#[ignore]` by default (needs the stack up): +//! +//! ```text +//! AUTH_BASE=http://localhost:8083 \ +//! cargo test -p authenticator --test e2e_refresh -- --ignored --nocapture +//! ``` +//! +//! Asserts the G10 rotation model: refresh rotates the cookie credential but +//! not the session (`sid` claim stable), the superseded token keeps resolving +//! during the grace window without a second rotation, and a token past grace +//! is refused with a cleared cookie. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde::Deserialize; + +const COOKIE: &str = "__Host-sid"; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_owned()) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +fn rewrite_host(url: &str) -> String { + match ( + std::env::var("FAKEIDP_REWRITE_FROM"), + std::env::var("FAKEIDP_REWRITE_TO"), + ) { + (Ok(from), Ok(to)) if !from.is_empty() => url.replace(&from, &to), + _ => url.to_owned(), + } +} + +fn cookie_from(resp: &reqwest::Response) -> Option { + for hv in resp.headers().get_all(reqwest::header::SET_COOKIE) { + let raw = hv.to_str().ok()?; + for part in raw.split(';') { + if let Some(v) = part.trim().strip_prefix(&format!("{COOKIE}=")) + && !v.is_empty() + { + return Some(v.to_owned()); + } + } + } + None +} + +/// Run the full fakeidp login loop; returns the session cookie token. +async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { + let login = http + .get(format!("{auth_base}/auth/login")) + .send() + .await + .unwrap(); + assert_eq!(login.status(), 302); + let authorize = rewrite_host(login.headers()[reqwest::header::LOCATION].to_str().unwrap()); + let sep = if authorize.contains('?') { '&' } else { '?' }; + let authorized = http + .get(format!("{authorize}{sep}user={user}")) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), 302); + let callback = rewrite_host( + authorized.headers()[reqwest::header::LOCATION] + .to_str() + .unwrap(), + ); + let cb = http.get(&callback).send().await.unwrap(); + assert_eq!(cb.status(), 302); + cookie_from(&cb).expect("callback must set __Host-sid") +} + +#[derive(Deserialize)] +struct RefreshBody { + expires_at: u64, + refresh_at: u64, +} + +#[derive(Deserialize)] +struct MeBody { + expires_at: u64, + refresh_at: u64, +} + +#[derive(Deserialize)] +struct JwtSid { + sid: String, +} + +/// Decode the (unverified) `sid` claim from a compact JWT. +fn jwt_sid(bearer: &str) -> String { + use base64::Engine as _; + let jwt = bearer.strip_prefix("Bearer ").unwrap(); + let payload = jwt.split('.').nth(1).unwrap(); + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .unwrap(); + serde_json::from_slice::(&bytes).unwrap().sid +} + +async fn authz_sid(http: &reqwest::Client, auth_base: &str, token: &str) -> Option { + let resp = http + .get(format!("{auth_base}/internal/authz")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap(); + if resp.status() != 200 { + return None; + } + Some(jwt_sid(resp.headers()["x-gateway-jwt"].to_str().unwrap())) +} + +#[tokio::test] +#[ignore = "requires a running authenticator + fakeidp + Redis stack"] +async fn refresh_rotates_with_grace_and_stable_session() { + let auth_base = env("AUTH_BASE", "http://localhost:8083"); + let test_user = env("E2E_USER", "dev@company.nonpresent"); + let http = client(); + + let old_token = login(&http, &auth_base, &test_user).await; + let sid_before = authz_sid(&http, &auth_base, &old_token) + .await + .expect("fresh session exchanges"); + + // 1. Refresh rotates the credential and returns the timing contract. + let refresh = http + .post(format!("{auth_base}/auth/refresh")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .send() + .await + .unwrap(); + assert_eq!(refresh.status(), 200, "refresh must succeed"); + let new_token = cookie_from(&refresh).expect("refresh must re-issue the cookie"); + assert_ne!(new_token, old_token, "credential must rotate"); + let body: RefreshBody = refresh.json().await.unwrap(); + // refresh_at ∈ [expires_at − margin − jitter/2, expires_at − margin + jitter/2] + // (defaults: margin 90, jitter ±60 → [exp−150, exp−30]). + assert!( + body.refresh_at < body.expires_at, + "refresh_at must precede expires_at" + ); + + // 2. The stable session survives rotation: same `sid` through the new token. + let sid_after = authz_sid(&http, &auth_base, &new_token) + .await + .expect("rotated session exchanges"); + assert_eq!(sid_before, sid_after, "sid must be stable across rotation"); + + // 3. Grace: an immediate second refresh with the OLD token resolves to the + // same session and does NOT rotate again (returns the current cookie). + let grace = http + .post(format!("{auth_base}/auth/refresh")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .send() + .await + .unwrap(); + if grace.status() == 200 { + let grace_token = cookie_from(&grace).expect("grace refresh re-issues the current cookie"); + assert_eq!( + grace_token, new_token, + "grace path must answer with the current credential, not rotate again" + ); + } else { + // The 250 ms default grace may already have elapsed under load — a 401 + // here is the past-grace contract, not a failure of the grace path. + assert_eq!(grace.status(), 401); + } + + // 4. Past grace the old token is dead: 401 + cleared cookie. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + let stale = http + .post(format!("{auth_base}/auth/refresh")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .send() + .await + .unwrap(); + assert_eq!(stale.status(), 401, "past-grace token must be refused"); + let cleared = stale + .headers() + .get_all(reqwest::header::SET_COOKIE) + .iter() + .any(|h| h.to_str().unwrap_or("").contains("Max-Age=0")); + assert!(cleared, "past-grace refusal must clear the cookie"); + + // 5. /auth/me returns the same timing fields for the live credential. + let me = http + .get(format!("{auth_base}/auth/me")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={new_token}")) + .send() + .await + .unwrap(); + assert_eq!(me.status(), 200); + let me_body: MeBody = me.json().await.unwrap(); + assert!(me_body.refresh_at < me_body.expires_at); +} diff --git a/src/backend/services/authenticator/tests/e2e_service_token.rs b/src/backend/services/authenticator/tests/e2e_service_token.rs index 008fee60f..62e1eab66 100644 --- a/src/backend/services/authenticator/tests/e2e_service_token.rs +++ b/src/backend/services/authenticator/tests/e2e_service_token.rs @@ -56,7 +56,8 @@ struct Jwk { struct Claims { sub: String, tenant_id: String, - roles: Vec, + /// Space-delimited on the wire (OAuth `scope` shape). + roles: String, sid: String, aud: String, } @@ -104,11 +105,15 @@ async fn service_token_full_loop() { .strip_prefix("Bearer ") .expect("bearer() returns a Bearer value"); let claims = verify_against_jwks(jwt).await; - assert_eq!(claims.sub, "service:testclient", "sub is service:"); + // `sub` is the stable per-service UUID (v5 over "service:"); `sid` + // carries the service: correlation handle. + let expected_sub = + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, b"service:testclient").to_string(); + assert_eq!(claims.sub, expected_sub, "sub is the per-service UUIDv5"); assert_eq!(claims.sid, "service:testclient", "sid is service:"); assert_eq!(claims.aud, "internal-services"); assert!( - claims.roles.contains(&"service".to_owned()), + claims.roles.split_whitespace().any(|r| r == "service"), "service role always present, got {:?}", claims.roles ); diff --git a/src/backend/services/authenticator/tests/e2e_sessions.rs b/src/backend/services/authenticator/tests/e2e_sessions.rs new file mode 100644 index 000000000..3add516f8 --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_sessions.rs @@ -0,0 +1,209 @@ +//! End-to-end session management against a running authenticator + fakeidp + +//! Redis (nginx+auth step 10, item 2). +//! +//! `#[ignore]` by default (needs the stack up; `run-e2e.sh` drives it): +//! +//! ```text +//! AUTH_BASE=http://localhost:8083 \ +//! cargo test -p authenticator --test e2e_sessions -- --ignored --nocapture +//! ``` +//! +//! Covers: listing active sessions (current flag, attribution fields), revoking +//! a specific other session, the no-existence-oracle 404, and "log out +//! everywhere". The admin revoke-by-user variant needs the gateway-JWT authn +//! pipeline (TLS discovery front) and is exercised in the compose e2e instead. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde::Deserialize; + +const COOKIE: &str = "__Host-sid"; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_owned()) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +fn rewrite_host(url: &str) -> String { + match ( + std::env::var("FAKEIDP_REWRITE_FROM"), + std::env::var("FAKEIDP_REWRITE_TO"), + ) { + (Ok(from), Ok(to)) if !from.is_empty() => url.replace(&from, &to), + _ => url.to_owned(), + } +} + +fn cookie_from(resp: &reqwest::Response) -> Option { + for hv in resp.headers().get_all(reqwest::header::SET_COOKIE) { + let raw = hv.to_str().ok()?; + for part in raw.split(';') { + if let Some(v) = part.trim().strip_prefix(&format!("{COOKIE}=")) + && !v.is_empty() + { + return Some(v.to_owned()); + } + } + } + None +} + +/// Run the full fakeidp login loop; returns the session cookie token. +async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { + let login = http + .get(format!("{auth_base}/auth/login")) + .header(reqwest::header::USER_AGENT, "e2e-sessions-test") + .send() + .await + .unwrap(); + assert_eq!(login.status(), 302); + let authorize = rewrite_host(login.headers()[reqwest::header::LOCATION].to_str().unwrap()); + let sep = if authorize.contains('?') { '&' } else { '?' }; + let authorized = http + .get(format!("{authorize}{sep}user={user}")) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), 302); + let callback = rewrite_host( + authorized.headers()[reqwest::header::LOCATION] + .to_str() + .unwrap(), + ); + let cb = http + .get(&callback) + .header(reqwest::header::USER_AGENT, "e2e-sessions-test") + .send() + .await + .unwrap(); + assert_eq!(cb.status(), 302); + cookie_from(&cb).expect("callback must set __Host-sid") +} + +#[derive(Deserialize)] +struct SessionItem { + session_id: String, + created_at: u64, + expires_at: u64, + user_agent: String, + #[allow(dead_code)] + ip: String, + current: bool, +} + +#[derive(Deserialize)] +struct SessionsBody { + sessions: Vec, +} + +async fn list(http: &reqwest::Client, auth_base: &str, token: &str) -> Vec { + let resp = http + .get(format!("{auth_base}/auth/sessions")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + resp.json::().await.unwrap().sessions +} + +#[tokio::test] +#[ignore = "requires a running authenticator + fakeidp + Redis stack"] +async fn sessions_list_revoke_and_logout_everywhere() { + let auth_base = env("AUTH_BASE", "http://localhost:8083"); + let test_user = env("E2E_USER", "dev@company.nonpresent"); + let http = client(); + + // Two devices: two independent logins for the same person. + let token_a = login(&http, &auth_base, &test_user).await; + let token_b = login(&http, &auth_base, &test_user).await; + + // 1. The list shows both sessions, flags the caller's as current, and + // carries the attribution captured at login. + // Device A's own id, resolved from its own list (the person may carry + // leftover sessions from earlier e2e tests — same deterministic stub user). + let session_a_id = list(&http, &auth_base, &token_a) + .await + .into_iter() + .find(|s| s.current) + .expect("device A must see itself as current") + .session_id; + + let sessions = list(&http, &auth_base, &token_b).await; + assert!( + sessions.len() >= 2, + "both live sessions must be listed, got {}", + sessions.len() + ); + let current = sessions + .iter() + .find(|s| s.current) + .expect("the caller's session must be flagged current"); + assert!(current.expires_at > current.created_at); + assert_eq!( + current.user_agent, "e2e-sessions-test", + "user_agent captured at login must be surfaced" + ); + let other = sessions + .iter() + .find(|s| s.session_id == session_a_id) + .expect("the other device's session must be listed"); + assert!(!other.current, "device A is not current for device B"); + + // 2. Revoking an unknown/foreign session id → 404 (no existence oracle). + let bogus = http + .delete(format!( + "{auth_base}/auth/sessions/00000000-0000-7000-8000-000000000000" + )) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .send() + .await + .unwrap(); + assert_eq!(bogus.status(), 404); + + // 3. Revoke the other device's session; it dies, the caller's survives. + let revoke = http + .delete(format!("{auth_base}/auth/sessions/{}", other.session_id)) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .send() + .await + .unwrap(); + assert_eq!(revoke.status(), 200); + let after = http + .get(format!("{auth_base}/internal/authz")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token_a}")) + .send() + .await + .unwrap(); + assert_eq!(after.status(), 401, "revoked device must be logged out"); + let survivors = list(&http, &auth_base, &token_b).await; + assert!(survivors.iter().all(|s| s.session_id != other.session_id)); + + // 4. Log out everywhere: every session dies, cookie cleared. + let all = http + .delete(format!("{auth_base}/auth/sessions")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .send() + .await + .unwrap(); + assert_eq!(all.status(), 200); + let cleared = all + .headers() + .get_all(reqwest::header::SET_COOKIE) + .iter() + .any(|h| h.to_str().unwrap_or("").contains("Max-Age=0")); + assert!(cleared, "log-out-everywhere must clear the cookie"); + let dead = http + .get(format!("{auth_base}/internal/authz")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .send() + .await + .unwrap(); + assert_eq!(dead.status(), 401); +} diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index 6f1483729..c71212748 100644 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -72,7 +72,7 @@ wait_ready fakeidp "http://localhost:$IDP_PORT/.well-known/openid-configuration" echo "==> identity stub :$IDENTITY_PORT (resolves any email to a person)" python3 "$HERE/identity-stub.py" "127.0.0.1:$IDENTITY_PORT" >/tmp/authenticator-e2e-identity.log 2>&1 & pids+=($!) -wait_ready identity-stub "http://localhost:$IDENTITY_PORT/v1/persons/probe@example.com" +wait_ready identity-stub "http://localhost:$IDENTITY_PORT/internal/persons/by-email/probe@example.com" echo "==> authenticator :$AUTH_PORT" APP__gears__authenticator__config__redis_url=redis://localhost:6399 \ @@ -97,6 +97,14 @@ echo "==> run the login loop" AUTH_BASE="http://localhost:$AUTH_PORT" E2E_USER=dev@company.nonpresent \ cargo test -p authenticator --test e2e_login_loop -- --ignored --nocapture +echo "==> run the refresh rotation-with-grace loop (step 10.1)" +AUTH_BASE="http://localhost:$AUTH_PORT" E2E_USER=dev@company.nonpresent \ + cargo test -p authenticator --test e2e_refresh -- --ignored --nocapture + +echo "==> run the session-management loop (step 10.2)" +AUTH_BASE="http://localhost:$AUTH_PORT" E2E_USER=dev@company.nonpresent \ + cargo test -p authenticator --test e2e_sessions -- --ignored --nocapture + echo "==> run the service-token loop (step 06)" # The token listener binds 8093 (config service_tokens.token_bind_addr); the dev # `testclient` registry entry resolves public_key_paths against the generated From e58e979dd862258378a9a1645d469d0529ace0ff Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:22:34 +0800 Subject: [PATCH 02/10] feat(authenticator): CSRF defense on state-changing /auth/* (step 10.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second line behind SameSite=Strict (PRD 5.11 / DESIGN 4.2, salvaged spec): middleware over the route table checks POST/PUT/PATCH/DELETE under /auth/*. X-CSRF-Token is compared in constant time (fixed-size SHA-256 digests) against the per-session token minted at login; with no header, the Origin allowlist (csrf_origins) is the fallback; empty allowlist (the default) is fail closed — token required. A presented-but-wrong token is never rescued by the Origin fallback. Requests without a resolvable session pass through (the handler answers 401 — nothing to forge); a Redis failure answers 503, never a bypass. The back-channel logout endpoint is exempt: IdP server-to-server, its credential is the signed logout_token. GET /auth/csrf issues the session's token; /auth/me echoes it so one call primes both the refresh timer and the CSRF header at page load. Config: csrf_origins in the committed config (empty = fail closed), the dev compose override (Vite + gateway origins keep browser flows working until the SPA header lands everywhere), and a new chart value csrfOrigins. The SPA side (store csrf_token from /auth/me, send X-CSRF-Token on logout) lands in insight-front (feat/auth-csrf-header). e2e now asserts 403-without / pass-with the header on refresh and log-out-everywhere; all loops green. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- deploy/compose/authenticator-fullauth.yaml | 4 + .../authenticator/config/insight.yaml | 3 + .../helm/templates/configmap.yaml | 6 + .../services/authenticator/helm/values.yaml | 5 + .../authenticator/src/api/handlers.rs | 22 ++ .../services/authenticator/src/api/mod.rs | 20 +- .../services/authenticator/src/csrf.rs | 196 ++++++++++++++++++ .../services/authenticator/src/main.rs | 1 + .../authenticator/tests/e2e_login_loop.rs | 5 +- .../authenticator/tests/e2e_refresh.rs | 31 +++ .../authenticator/tests/e2e_sessions.rs | 34 +++ 11 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/backend/services/authenticator/src/csrf.rs diff --git a/deploy/compose/authenticator-fullauth.yaml b/deploy/compose/authenticator-fullauth.yaml index beb673c9f..ce4fdd900 100644 --- a/deploy/compose/authenticator-fullauth.yaml +++ b/deploy/compose/authenticator-fullauth.yaml @@ -106,6 +106,10 @@ gears: jwt_audience: "internal-services" redirect_uri: "" default_return_to: "/" + # CSRF Origin-allowlist fallback (10.5): the browser sends Origin on all + # state-changing fetches, so dev flows keep working until the SPA sends + # X-CSRF-Token. Vite dev origin + the gateway entry. + csrf_origins: ["http://localhost:3000", "http://localhost:8080"] # Login scopes. offline_access is omitted (survives-logout token, wrong for a # BFF); add it only for an IdP that needs it for a refresh token, e.g. Entra. oidc_scopes: ["openid", "email", "profile"] diff --git a/src/backend/services/authenticator/config/insight.yaml b/src/backend/services/authenticator/config/insight.yaml index 661f7331c..d92a5702b 100644 --- a/src/backend/services/authenticator/config/insight.yaml +++ b/src/backend/services/authenticator/config/insight.yaml @@ -123,6 +123,9 @@ gears: jwt_audience: "internal-services" redirect_uri: "" default_return_to: "/" + # CSRF Origin-allowlist fallback for state-changing /auth/* (10.5). + # Empty = fail closed: the X-CSRF-Token header is required. + csrf_origins: [] # Login scopes. offline_access is omitted (survives-logout token, wrong for a # BFF); add it only for an IdP that needs it for a refresh token, e.g. Entra. oidc_scopes: ["openid", "email", "profile"] diff --git a/src/backend/services/authenticator/helm/templates/configmap.yaml b/src/backend/services/authenticator/helm/templates/configmap.yaml index a95995bef..010d1c181 100644 --- a/src/backend/services/authenticator/helm/templates/configmap.yaml +++ b/src/backend/services/authenticator/helm/templates/configmap.yaml @@ -123,6 +123,12 @@ data: authenticator: config: signing_keys_path: {{ .Values.signingKeysPath | quote }} + # CSRF Origin-allowlist fallback for state-changing /auth/* requests; + # empty = fail closed (X-CSRF-Token required). + {{- with .Values.csrfOrigins }} + csrf_origins: + {{- toYaml . | nindent 12 }} + {{- end }} # Service tokens (§10 G1). The registry is gitops-reviewable config # (public keys are not secrets) rendered from .Values.serviceTokens. service_tokens: diff --git a/src/backend/services/authenticator/helm/values.yaml b/src/backend/services/authenticator/helm/values.yaml index 38d6620e3..1123e81d9 100644 --- a/src/backend/services/authenticator/helm/values.yaml +++ b/src/backend/services/authenticator/helm/values.yaml @@ -32,6 +32,11 @@ serviceTokens: # Service tokens are always tenant-scoped; there is no per-service tenant flag. services: {} +# CSRF Origin-allowlist fallback (PRD 5.11) for state-changing /auth/* +# requests that carry no X-CSRF-Token: list the SPA's public origin(s), e.g. +# ["https://insight.example.com"]. Empty (default) = fail closed, header only. +csrfOrigins: [] + resources: requests: cpu: 50m diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 0a0eeedea..5d0bfd8da 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -469,11 +469,33 @@ pub async fn me(Extension(state): Extension>, jar: CookieJar) -> R "roles": record.roles, "expires_at": record.expires_at, "refresh_at": refresh_at, + "csrf_token": record.csrf_token, }) .to_string(); json_ok(body) } +// ── /auth/csrf ─────────────────────────────────────────────────────────────── + +/// Issue the CSRF token bound to the current session (PRD 5.11). The SPA sends +/// it back as `X-CSRF-Token` on state-changing `/auth/*` requests; `/auth/me` +/// echoes the same value so a page load primes both timers in one call. +pub async fn csrf(Extension(state): Extension>, jar: CookieJar) -> Response { + let Some(token) = cookie::read(&jar) else { + return unauthenticated(); + }; + let (_, record) = match state.sessions.resolve_by_token(&token).await { + Ok(Some(r)) => r, + Ok(None) => return unauthenticated(), + Err(e) => return internal_problem("session_store", &e), + }; + let now = now_secs(); + if record.expires_at <= now || record.absolute_expires_at <= now { + return unauthenticated(); + } + json_ok(serde_json::json!({ "csrf_token": record.csrf_token }).to_string()) +} + // ── /auth/refresh ──────────────────────────────────────────────────────────── /// Rotate the session credential and extend the session (PRD 5.4, G10 model): diff --git a/src/backend/services/authenticator/src/api/mod.rs b/src/backend/services/authenticator/src/api/mod.rs index a5e991292..8d11c85b5 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -41,7 +41,15 @@ pub fn register_routes( openapi: &dyn OpenApiRegistry, state: Arc, ) -> Router { - let api = build_operations(Router::new(), openapi).layer(Extension(state)); + // CSRF verification wraps the route table (state-changing `/auth/*` only — + // the middleware filters); the Extension layer runs first so handlers and + // middleware share the same state. + let api = build_operations(Router::new(), openapi) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::csrf::middleware, + )) + .layer(Extension(state)); host_router.merge(api) } @@ -127,6 +135,16 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .handler(handlers::admin_revoke_user_sessions) .register(router, openapi); + router = OperationBuilder::get("/auth/csrf") + .operation_id("authenticator.csrf") + .summary("Issue the CSRF token bound to the current session") + .tag("auth") + .public() + .text_response(StatusCode::OK, "CSRF token", "application/json") + .error_401(openapi) + .handler(handlers::csrf) + .register(router, openapi); + router = OperationBuilder::get("/auth/me") .operation_id("authenticator.me") .summary("Current session summary for the SPA") diff --git a/src/backend/services/authenticator/src/csrf.rs b/src/backend/services/authenticator/src/csrf.rs new file mode 100644 index 000000000..d0b195659 --- /dev/null +++ b/src/backend/services/authenticator/src/csrf.rs @@ -0,0 +1,196 @@ +//! CSRF defense for state-changing `/auth/*` methods (PRD 5.11, DESIGN §4.2). +//! +//! `SameSite=Strict` on the session cookie is the primary defense; this is the +//! second line: `X-CSRF-Token` compared in constant time against the token +//! bound to the session at login, with an `Origin`-allowlist fallback +//! (`csrf_origins`; empty = fail closed, token required). Both failing yields +//! 403. The per-session token is issued at login, fetched via `GET /auth/csrf`, +//! and echoed by `/auth/me`. +//! +//! The back-channel logout endpoint is exempt: it is IdP server-to-server and +//! its credential is the signed `logout_token`, not a browser session. + +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::Method; +use axum::middleware::Next; +use axum::response::{IntoResponse as _, Response}; +use axum_extra::extract::cookie::CookieJar; +use sha2::{Digest as _, Sha256}; + +use crate::api::AppState; +use crate::api::error::SessionError; +use crate::cookie; + +/// Paths under `/auth/` that skip CSRF checks: not browser-session-driven. +const EXEMPT_PATHS: &[&str] = &["/auth/oidc/back-channel-logout"]; + +/// The verdict of the pure check (unit-tested separately from the middleware). +#[derive(Debug, PartialEq, Eq)] +enum Verdict { + Pass, + Forbidden(&'static str), +} + +/// Pure CSRF decision for one state-changing request with a live session: +/// header token (constant-time) first, `Origin` allowlist as fallback, +/// fail closed when neither verifies. +fn verdict( + header_token: Option<&str>, + session_token: &str, + origin: Option<&str>, + allowlist: &[String], +) -> Verdict { + if let Some(presented) = header_token { + // Constant-time equality via fixed-size digests — the comparison cost + // is independent of where the strings first differ. + let a = Sha256::digest(presented.as_bytes()); + let b = Sha256::digest(session_token.as_bytes()); + if a == b && !session_token.is_empty() { + return Verdict::Pass; + } + return Verdict::Forbidden("csrf_token_mismatch"); + } + if let Some(origin) = origin + && allowlist.iter().any(|allowed| allowed == origin) + { + return Verdict::Pass; + } + Verdict::Forbidden("csrf_token_required") +} + +/// Axum middleware over the authenticator's route table. Only state-changing +/// `/auth/*` requests that present a resolvable session are checked — without +/// a session there is nothing to forge (the handler answers 401), and the +/// gateway-facing / well-known surfaces are not browser-state-changing. +pub async fn middleware( + State(state): State>, + jar: CookieJar, + request: Request, + next: Next, +) -> Response { + let method = request.method(); + let path = request.uri().path(); + let state_changing = matches!( + *method, + Method::POST | Method::PUT | Method::PATCH | Method::DELETE + ); + if !state_changing || !path.starts_with("/auth/") || EXEMPT_PATHS.contains(&path) { + return next.run(request).await; + } + + let Some(token) = cookie::read(&jar) else { + return next.run(request).await; // no session → handler 401s + }; + let record = match state.sessions.resolve_by_token(&token).await { + Ok(Some((_, record))) => record, + Ok(None) => return next.run(request).await, // dead session → handler 401s + Err(e) => { + // Store down: fail closed like every auth path (503, not a bypass). + tracing::warn!(error = %e, "csrf: session store unavailable"); + return toolkit_canonical_errors::CanonicalError::service_unavailable() + .with_detail("session store unavailable") + .create() + .into_response(); + } + }; + + let header_token = request + .headers() + .get("x-csrf-token") + .and_then(|v| v.to_str().ok()); + let origin = request + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()); + + match verdict( + header_token, + &record.csrf_token, + origin, + &state.cfg.csrf_origins, + ) { + Verdict::Pass => next.run(request).await, + Verdict::Forbidden(reason) => { + tracing::warn!( + target: "audit", + event = "csrf_rejected", + person_id = %record.person_id, + %path, + reason, + "state-changing /auth/* request failed CSRF verification" + ); + SessionError::permission_denied() + .with_reason(reason) + .create() + .into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SESSION_TOKEN: &str = "csrf-abc-123"; + + #[test] + fn matching_header_token_passes() { + assert_eq!( + verdict(Some(SESSION_TOKEN), SESSION_TOKEN, None, &[]), + Verdict::Pass + ); + } + + #[test] + fn mismatched_header_token_is_forbidden_even_with_allowed_origin() { + // A presented-but-wrong token is an attack signal; the Origin fallback + // must not rescue it. + let allow = vec!["https://app.example".to_owned()]; + assert_eq!( + verdict( + Some("wrong"), + SESSION_TOKEN, + Some("https://app.example"), + &allow + ), + Verdict::Forbidden("csrf_token_mismatch") + ); + } + + #[test] + fn origin_allowlist_is_the_fallback() { + let allow = vec!["https://app.example".to_owned()]; + assert_eq!( + verdict(None, SESSION_TOKEN, Some("https://app.example"), &allow), + Verdict::Pass + ); + assert_eq!( + verdict(None, SESSION_TOKEN, Some("https://evil.example"), &allow), + Verdict::Forbidden("csrf_token_required") + ); + } + + #[test] + fn empty_allowlist_fails_closed() { + // Default config: no origins → the header token is mandatory. + assert_eq!( + verdict(None, SESSION_TOKEN, Some("https://app.example"), &[]), + Verdict::Forbidden("csrf_token_required") + ); + assert_eq!( + verdict(None, SESSION_TOKEN, None, &[]), + Verdict::Forbidden("csrf_token_required") + ); + } + + #[test] + fn empty_session_token_never_matches() { + // A session with no CSRF token (defensive) must not pass an empty header. + assert_eq!( + verdict(Some(""), "", None, &[]), + Verdict::Forbidden("csrf_token_mismatch") + ); + } +} diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index 9b752407e..c6cddd53c 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -24,6 +24,7 @@ mod api; mod config; mod cookie; +mod csrf; mod gear; mod identity; mod jwt; diff --git a/src/backend/services/authenticator/tests/e2e_login_loop.rs b/src/backend/services/authenticator/tests/e2e_login_loop.rs index dd8c56fc6..e3911f37d 100644 --- a/src/backend/services/authenticator/tests/e2e_login_loop.rs +++ b/src/backend/services/authenticator/tests/e2e_login_loop.rs @@ -194,10 +194,13 @@ async fn full_login_exchange_logout_loop() { assert!(me_body.get("user").is_some()); assert!(me_body.get("refresh_at").is_some()); - // 7. /auth/logout revokes the session and clears the cookie. + // 7. /auth/logout revokes the session and clears the cookie. State-changing + // /auth/* requires the CSRF token (step 10.5) — /auth/me echoed it. + let csrf = me_body["csrf_token"].as_str().unwrap().to_owned(); let logout = http .post(format!("{auth_base}/auth/logout")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .header("X-CSRF-Token", &csrf) .send() .await .unwrap(); diff --git a/src/backend/services/authenticator/tests/e2e_refresh.rs b/src/backend/services/authenticator/tests/e2e_refresh.rs index 4b6e2cfee..3bc4e5195 100644 --- a/src/backend/services/authenticator/tests/e2e_refresh.rs +++ b/src/backend/services/authenticator/tests/e2e_refresh.rs @@ -80,6 +80,22 @@ async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { cookie_from(&cb).expect("callback must set __Host-sid") } +/// Fetch the session's CSRF token (state-changing /auth/* requires it, 10.5). +async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> String { + #[derive(Deserialize)] + struct CsrfBody { + csrf_token: String, + } + let resp = http + .get(format!("{auth_base}/auth/csrf")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "GET /auth/csrf must succeed"); + resp.json::().await.unwrap().csrf_token +} + #[derive(Deserialize)] struct RefreshBody { expires_at: u64, @@ -133,10 +149,23 @@ async fn refresh_rotates_with_grace_and_stable_session() { .await .expect("fresh session exchanges"); + let csrf = get_csrf(&http, &auth_base, &old_token).await; + + // 0. A state-changing /auth/* request without the CSRF token (and no + // allowlisted Origin) is rejected 403 before any rotation happens. + let no_csrf = http + .post(format!("{auth_base}/auth/refresh")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .send() + .await + .unwrap(); + assert_eq!(no_csrf.status(), 403, "refresh without CSRF must be 403"); + // 1. Refresh rotates the credential and returns the timing contract. let refresh = http .post(format!("{auth_base}/auth/refresh")) .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .header("X-CSRF-Token", &csrf) .send() .await .unwrap(); @@ -162,6 +191,7 @@ async fn refresh_rotates_with_grace_and_stable_session() { let grace = http .post(format!("{auth_base}/auth/refresh")) .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .header("X-CSRF-Token", &csrf) .send() .await .unwrap(); @@ -182,6 +212,7 @@ async fn refresh_rotates_with_grace_and_stable_session() { let stale = http .post(format!("{auth_base}/auth/refresh")) .header(reqwest::header::COOKIE, format!("{COOKIE}={old_token}")) + .header("X-CSRF-Token", &csrf) .send() .await .unwrap(); diff --git a/src/backend/services/authenticator/tests/e2e_sessions.rs b/src/backend/services/authenticator/tests/e2e_sessions.rs index 3add516f8..4a29c34e0 100644 --- a/src/backend/services/authenticator/tests/e2e_sessions.rs +++ b/src/backend/services/authenticator/tests/e2e_sessions.rs @@ -54,6 +54,22 @@ fn cookie_from(resp: &reqwest::Response) -> Option { None } +/// Fetch the session's CSRF token (state-changing /auth/* requires it, 10.5). +async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> String { + #[derive(Deserialize)] + struct CsrfBody { + csrf_token: String, + } + let resp = http + .get(format!("{auth_base}/auth/csrf")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "GET /auth/csrf must succeed"); + resp.json::().await.unwrap().csrf_token +} + /// Run the full fakeidp login loop; returns the session cookie token. async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { let login = http @@ -123,6 +139,7 @@ async fn sessions_list_revoke_and_logout_everywhere() { // Two devices: two independent logins for the same person. let token_a = login(&http, &auth_base, &test_user).await; let token_b = login(&http, &auth_base, &test_user).await; + let csrf_b = get_csrf(&http, &auth_base, &token_b).await; // 1. The list shows both sessions, flags the caller's as current, and // carries the attribution captured at login. @@ -162,6 +179,7 @@ async fn sessions_list_revoke_and_logout_everywhere() { "{auth_base}/auth/sessions/00000000-0000-7000-8000-000000000000" )) .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .header("X-CSRF-Token", &csrf_b) .send() .await .unwrap(); @@ -171,6 +189,7 @@ async fn sessions_list_revoke_and_logout_everywhere() { let revoke = http .delete(format!("{auth_base}/auth/sessions/{}", other.session_id)) .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .header("X-CSRF-Token", &csrf_b) .send() .await .unwrap(); @@ -186,9 +205,24 @@ async fn sessions_list_revoke_and_logout_everywhere() { assert!(survivors.iter().all(|s| s.session_id != other.session_id)); // 4. Log out everywhere: every session dies, cookie cleared. + // Without the CSRF token the destructive call is refused (10.5)… + let no_csrf = http + .delete(format!("{auth_base}/auth/sessions")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .send() + .await + .unwrap(); + assert_eq!( + no_csrf.status(), + 403, + "log-out-everywhere without CSRF must be 403" + ); + + // …and with it, every session dies. let all = http .delete(format!("{auth_base}/auth/sessions")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token_b}")) + .header("X-CSRF-Token", &csrf_b) .send() .await .unwrap(); From a05b6ee408899208bb304118397b19b872cc6ac6 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 17:20:19 +0800 Subject: [PATCH 03/10] fix(authenticator): rotation CAS + CSRF deploy note (review: M2, M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 (QA/security review): make /auth/refresh rotation a compare-and-swap on the session's current_token (atomic Lua) instead of an unconditional pipeline. Two concurrent refreshes of the same cookie (multi-tab, within the refresh burst) could both pass the grace check and both rotate, leaving the first new token mapping written with the full session TTL and never demoted to grace — a live parallel credential that revoke never touches. The CAS lets only one rotate; the loser re-loads and answers the grace path with the winner's current credential. No orphan mapping. M5: document the fail-closed CSRF deploy coordination in the chart values — roll the header-sending insight-front first, or set csrfOrigins, or logout/ refresh 403 during the transition. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- .../services/authenticator/helm/values.yaml | 7 +++ .../authenticator/src/api/handlers.rs | 19 +++++- .../services/authenticator/src/session.rs | 63 +++++++++++-------- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/backend/services/authenticator/helm/values.yaml b/src/backend/services/authenticator/helm/values.yaml index 1123e81d9..a1ee6816d 100644 --- a/src/backend/services/authenticator/helm/values.yaml +++ b/src/backend/services/authenticator/helm/values.yaml @@ -35,6 +35,13 @@ serviceTokens: # CSRF Origin-allowlist fallback (PRD 5.11) for state-changing /auth/* # requests that carry no X-CSRF-Token: list the SPA's public origin(s), e.g. # ["https://insight.example.com"]. Empty (default) = fail closed, header only. +# +# DEPLOY COORDINATION: CSRF is enforced fail-closed. An SPA that does not yet +# send X-CSRF-Token will get 403 on POST /auth/logout, /auth/refresh, and +# DELETE /auth/sessions once this chart is rolled. Either roll the matching +# insight-front (which sends the header) FIRST, or set csrfOrigins to the SPA's +# public origin here so the Origin fallback keeps those calls working during +# the transition. csrfOrigins: [] resources: diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 5d0bfd8da..2ed32196a 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -528,18 +528,33 @@ pub async fn refresh(Extension(state): Extension>, jar: CookieJar) let new_token = csprng_token(); let new_expires_at = (now + state.cfg.session_ttl_seconds).min(record.absolute_expires_at); - if let Err(e) = state + let rotated = match state .sessions .rotate_session( &session_id, &record, + &token, &new_token, new_expires_at, state.cfg.refresh_grace_ms, ) .await { - return internal_problem("rotate_session", &e); + Ok(rotated) => rotated, + Err(e) => return internal_problem("rotate_session", &e), + }; + if !rotated { + // Lost the compare-and-swap: a concurrent refresh already rotated this + // credential (multi-tab). Answer the grace path with the now-current + // credential rather than minting a second one. Re-load to read it. + tracing::debug!(session_id = %session_id, "refresh lost the rotation CAS: answering grace path"); + return match state.sessions.load_session(&session_id).await { + Ok(Some(current)) => { + refresh_ok(&state, jar, ¤t.current_token, current.expires_at, now) + } + Ok(None) => unauthenticated_clear_cookie(jar), + Err(e) => internal_problem("session_store", &e), + }; } tracing::debug!(session_id = %session_id, expires_at = new_expires_at, "session refreshed (credential rotated)"); refresh_ok(&state, jar, &new_token, new_expires_at, now) diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 33247b694..29c6a864f 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -423,43 +423,56 @@ impl SessionManager { /// "Session refresh — rotation without churn"): write the new token /// mapping, shorten the superseded mapping's TTL to the rotation grace, /// advance the session's `expires_at` (record field, key TTL, and per-user - /// index score) — one pipeline. The stable `session_id`, the linked JWT, - /// and every other index stay untouched (G10). + /// index score). The stable `session_id`, the linked JWT, and every other + /// index stay untouched (G10). + /// + /// **Compare-and-swap on `current_token`** (atomic Lua): the whole + /// rotation runs only if the session's stored `current_token` still equals + /// the credential the caller presented. Two concurrent refreshes of the + /// same cookie (multi-tab) therefore cannot both rotate — the loser gets + /// `Ok(false)` and the handler answers the grace path with the winner's + /// credential, so no orphan full-TTL token mapping is ever minted. /// /// # Errors - /// Fails on a Redis error. + /// Fails on a Redis error. `Ok(false)` = the presented token was no longer + /// current (lost the race / already rotated); nothing was written. pub async fn rotate_session( &self, session_id: &str, record: &SessionRecord, + presented_token: &str, new_token: &str, new_expires_at: u64, grace_ms: u64, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { + // KEYS: session hash, new-token mapping, old-token mapping, user ZSET. + // ARGV: expected current_token, session_id, expireAt (s), grace (ms). + const ROTATE_LUA: &str = r" + if redis.call('HGET', KEYS[1], 'current_token') ~= ARGV[1] then return 0 end + redis.call('SET', KEYS[2], ARGV[2]) + redis.call('EXPIREAT', KEYS[2], ARGV[3]) + redis.call('PEXPIRE', KEYS[3], ARGV[4]) + redis.call('HSET', KEYS[1], 'expires_at', ARGV[3], 'current_token', ARGV[5]) + redis.call('EXPIREAT', KEYS[1], ARGV[3]) + redis.call('ZADD', KEYS[4], ARGV[3], ARGV[2]) + return 1 + "; let mut conn = self.conn.clone(); - let skey = session_key(session_id); let expires_at = i64::try_from(new_expires_at).unwrap_or(i64::MAX); - - let mut pipe = redis::pipe(); - pipe.atomic(); - pipe.set(token_key(new_token), session_id).ignore(); - pipe.expire_at(token_key(new_token), expires_at).ignore(); - // The expiring old mapping IS the grace window (no swap keys). - pipe.pexpire( - token_key(&record.current_token), - i64::try_from(grace_ms).unwrap_or(250), - ) - .ignore(); - pipe.hset(&skey, "expires_at", new_expires_at.to_string()) - .ignore(); - pipe.hset(&skey, "current_token", new_token).ignore(); - pipe.expire_at(&skey, expires_at).ignore(); - pipe.zadd(user_sessions_key(&record.person_id), session_id, expires_at) - .ignore(); - pipe.query_async::<()>(&mut conn) + let rotated: i64 = redis::Script::new(ROTATE_LUA) + .key(session_key(session_id)) + .key(token_key(new_token)) + .key(token_key(&record.current_token)) + .key(user_sessions_key(&record.person_id)) + .arg(presented_token) + .arg(session_id) + .arg(expires_at) + .arg(i64::try_from(grace_ms).unwrap_or(250)) + .arg(new_token) + .invoke_async(&mut conn) .await - .context("rotate session pipeline")?; - Ok(()) + .context("rotate session (CAS)")?; + Ok(rotated == 1) } /// List a person's live sessions from the per-user index (score > `now`), From bc3acf30e7d19f028ce8a34df05c6c3a51f317ec Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:31:42 +0800 Subject: [PATCH 04/10] feat(authenticator): OIDC back-channel logout receiver (step 10.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /auth/oidc/back-channel-logout validates the logout_token per OIDC BCL 1.0: signature via the configured IdP's JWKS (fetched fresh per call — cold path, picks up key rotation), iss against the one trusted issuer, aud against our client_id, iat freshness inside a skew/max-age window (backchannel_clock_skew_seconds 60 / backchannel_token_max_age_seconds 300), the mandatory back-channel events member, sub-or-sid presence, and no nonce (a nonce marks a replayed id_token). Rejections are 400 with a coarse reason; JWKS unavailability is 503 (retryable by the IdP), never a bypass. Replay guard: asm:logout_jti:{iss}:{jti} SET NX EX with TTL = iat + max_age + skew − now; a replayed delivery answers 200 idempotently without another revoke. Success answers 200 no-store (BCL §2.7). Target resolution: (iss, sid) via the existing asm:sid_index; a sub-only token takes the documented fallback — a new asm:sub_index:{iss}:{sub} SET (maintained in the create/revoke pipelines) resolves the user's sessions, and EVERYTHING for that user is revoked through the standard pipeline with the operator-facing warn line (blast radius visible, not silent). The sub index replaces the PRD's 'resolve via Identity Service' sketch: Identity's lookup is email-keyed (a logout_token carries no email), and an index keeps the logout path free of a cross-service dependency; the spec text is updated in the step-10 docs pass. e2e via fakeidp /_control/backchannel: two live devices die on one signed logout_token; garbage tokens are 400. All six e2e loops pass locally. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- .../authenticator/src/api/handlers.rs | 162 ++++++++++ .../services/authenticator/src/api/mod.rs | 102 +++--- .../services/authenticator/src/backchannel.rs | 290 ++++++++++++++++++ .../services/authenticator/src/config.rs | 9 + .../services/authenticator/src/main.rs | 1 + .../services/authenticator/src/oidc.rs | 45 +++ .../services/authenticator/src/session.rs | 83 ++++- .../authenticator/tests/e2e_backchannel.rs | 139 +++++++++ .../services/authenticator/tests/run-e2e.sh | 6 + 9 files changed, 789 insertions(+), 48 deletions(-) create mode 100644 src/backend/services/authenticator/src/backchannel.rs create mode 100644 src/backend/services/authenticator/tests/e2e_backchannel.rs diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 2ed32196a..d72fd5871 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -783,6 +783,168 @@ pub async fn admin_revoke_user_sessions( } } +// ── /auth/oidc/back-channel-logout (PRD 5.10) ──────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct BackChannelForm { + #[serde(default)] + logout_token: Option, +} + +/// Receive an IdP back-channel `logout_token` (form-encoded, OIDC BCL §2.5): +/// validate it against the configured issuer's JWKS, replay-guard its `jti` +/// (one-shot — a replayed delivery answers 200 without another revoke), then +/// revoke the targeted sessions: by `(iss, sid)` via the sid index, or — the +/// documented sub-only fallback — everything for that user. +pub async fn back_channel_logout( + Extension(state): Extension>, + axum::extract::Form(form): axum::extract::Form, +) -> Response { + let Some(raw) = form.logout_token.as_deref().filter(|t| !t.is_empty()) else { + return OidcError::invalid_argument() + .with_field_violation("logout_token", "missing logout_token", "MISSING") + .create() + .into_response(); + }; + + // The IdP's keys — fetched per call (cold path, picks up rotation). + let jwks = match state.oidc.idp_jwks().await { + Ok(jwks) => jwks, + Err(e) => { + tracing::warn!( + error = format!("{e:#}"), + "back-channel: IdP JWKS unavailable" + ); + return toolkit_canonical_errors::CanonicalError::service_unavailable() + .with_detail("IdP JWKS unavailable") + .create() + .into_response(); + } + }; + + let now = now_secs(); + let cfg = &state.cfg; + let claims = match crate::backchannel::validate_logout_token( + &jwks, + raw, + state.oidc.issuer(), + state.oidc.client_id(), + now, + cfg.backchannel_clock_skew_seconds, + cfg.backchannel_token_max_age_seconds, + ) { + Ok(c) => c, + Err(reason) => { + tracing::warn!(reason, "back-channel: logout_token rejected"); + return OidcError::invalid_argument() + .with_field_violation("logout_token", reason, "INVALID_LOGOUT_TOKEN") + .create() + .into_response(); + } + }; + + // One-shot per (iss, jti): a replay answers 200 idempotently, no revoke. + let ttl = crate::backchannel::replay_guard_ttl( + claims.iat, + now, + cfg.backchannel_clock_skew_seconds, + cfg.backchannel_token_max_age_seconds, + ); + match state + .sessions + .guard_logout_jti(state.oidc.issuer(), &claims.jti, ttl) + .await + { + Ok(true) => {} + Ok(false) => { + tracing::info!(jti = %claims.jti, "back-channel: replayed logout_token (idempotent 200)"); + return no_content_ok(); + } + Err(e) => return internal_problem("logout_jti_guard", &e), + } + + let result = match &claims.sid { + Some(idp_sid) => revoke_by_sid_index(&state, idp_sid).await, + None => match &claims.sub { + Some(sub) => revoke_by_sub_fallback(&state, sub).await, + None => unreachable!("validator requires sub or sid"), + }, + }; + match result { + Ok(revoked) => { + tracing::info!( + target: "audit", + event = "back_channel_logout", + sid = claims.sid.as_deref().unwrap_or(""), + sub = claims.sub.as_deref().unwrap_or(""), + revoked, + "back-channel logout processed" + ); + no_content_ok() + } + Err(e) => internal_problem("back_channel_revoke", &e), + } +} + +/// Revoke every session indexed under the token's `(iss, sid)`. +async fn revoke_by_sid_index(state: &AppState, idp_sid: &str) -> anyhow::Result { + let session_ids = state + .sessions + .sessions_by_idp_sid(state.oidc.issuer(), idp_sid) + .await?; + let mut revoked = 0u64; + for sid in &session_ids { + if state.sessions.revoke_session(sid).await? { + revoked += 1; + } + } + Ok(revoked) +} + +/// The sub-only fallback (spec-compliant, blast radius documented): revoke +/// EVERYTHING for the users behind `(iss, sub)` — with the operator-facing +/// log line the runbook calls out, so a misconfigured IdP that omits `sid` +/// is visible, not silent. +async fn revoke_by_sub_fallback(state: &AppState, idp_sub: &str) -> anyhow::Result { + let session_ids = state + .sessions + .sessions_by_idp_sub(state.oidc.issuer(), idp_sub) + .await?; + // Resolve the distinct person(s) behind those sessions, then run the + // standard revoke-everything pipeline per person. + let mut persons: Vec = Vec::new(); + for sid in &session_ids { + if let Some(record) = state.sessions.load_session(sid).await? + && !persons.contains(&record.person_id) + { + persons.push(record.person_id); + } + } + let mut revoked = 0u64; + for person_id in &persons { + tracing::warn!( + target: "audit", + event = "back_channel_logout_sub_fallback", + idp_sub, + person_id = %person_id, + "back-channel logout_token carried no sid: revoking ALL sessions for this user \ + (OIDC-compliant fallback — configure the IdP to emit sid to narrow the blast radius)" + ); + revoked += state.sessions.revoke_user_sessions(person_id).await?; + } + Ok(revoked) +} + +/// 200 with an empty body and `no-store` (OIDC BCL §2.7 — the response must +/// not be cached). +fn no_content_ok() -> Response { + build_response( + StatusCode::OK, + vec![(CACHE_CONTROL.clone(), "no-store".to_owned())], + Body::empty(), + ) +} + // ── Pure helpers (unit-tested) ─────────────────────────────────────────────── /// Compute the `/internal/authz` 200 `Cache-Control`: diff --git a/src/backend/services/authenticator/src/api/mod.rs b/src/backend/services/authenticator/src/api/mod.rs index 8d11c85b5..6adf58299 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -59,6 +59,7 @@ pub fn register_routes( /// is the session cookie, checked inside the handler. fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { let router = register_auth_routes(router, openapi); + let router = register_session_routes(router, openapi); let router = register_internal_routes(router, openapi); register_well_known_routes(router, openapi) } @@ -88,6 +89,64 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .handler(handlers::callback) .register(router, openapi); + router = OperationBuilder::get("/auth/csrf") + .operation_id("authenticator.csrf") + .summary("Issue the CSRF token bound to the current session") + .tag("auth") + .public() + .text_response(StatusCode::OK, "CSRF token", "application/json") + .error_401(openapi) + .handler(handlers::csrf) + .register(router, openapi); + + router = OperationBuilder::get("/auth/me") + .operation_id("authenticator.me") + .summary("Current session summary for the SPA") + .tag("auth") + .public() + .text_response(StatusCode::OK, "Session summary", "application/json") + .error_401(openapi) + .handler(handlers::me) + .register(router, openapi); + + router = OperationBuilder::post("/auth/refresh") + .operation_id("authenticator.refresh") + .summary("Rotate the session cookie and extend the session (grace-tolerant)") + .tag("auth") + .public() + .text_response( + StatusCode::OK, + "{expires_at, refresh_at} + re-issued cookie", + "application/json", + ) + .error_401(openapi) + .handler(handlers::refresh) + .register(router, openapi); + + router = OperationBuilder::post("/auth/oidc/back-channel-logout") + .operation_id("authenticator.back_channel_logout") + .summary("Receive IdP back-channel logout tokens (OIDC BCL 1.0)") + .tag("auth") + .public() + .no_content_response(StatusCode::OK, "Logout processed (or idempotent replay)") + .handler(handlers::back_channel_logout) + .register(router, openapi); + + OperationBuilder::post("/auth/logout") + .operation_id("authenticator.logout") + .summary("Revoke the session, clear the cookie, return the RP-logout URL") + .tag("auth") + .public() + .text_response(StatusCode::OK, "RP-logout URL", "application/json") + .handler(handlers::logout) + .register(router, openapi) +} + +/// The session-management surface (PRD 5.9): list + revoke for the current +/// user, and the gateway-JWT-authenticated admin revoke-by-user variant. +fn register_session_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let mut router = router; + router = OperationBuilder::get("/auth/sessions") .operation_id("authenticator.sessions.list") .summary("List the current user's active sessions") @@ -135,48 +194,7 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .handler(handlers::admin_revoke_user_sessions) .register(router, openapi); - router = OperationBuilder::get("/auth/csrf") - .operation_id("authenticator.csrf") - .summary("Issue the CSRF token bound to the current session") - .tag("auth") - .public() - .text_response(StatusCode::OK, "CSRF token", "application/json") - .error_401(openapi) - .handler(handlers::csrf) - .register(router, openapi); - - router = OperationBuilder::get("/auth/me") - .operation_id("authenticator.me") - .summary("Current session summary for the SPA") - .tag("auth") - .public() - .text_response(StatusCode::OK, "Session summary", "application/json") - .error_401(openapi) - .handler(handlers::me) - .register(router, openapi); - - router = OperationBuilder::post("/auth/refresh") - .operation_id("authenticator.refresh") - .summary("Rotate the session cookie and extend the session (grace-tolerant)") - .tag("auth") - .public() - .text_response( - StatusCode::OK, - "{expires_at, refresh_at} + re-issued cookie", - "application/json", - ) - .error_401(openapi) - .handler(handlers::refresh) - .register(router, openapi); - - OperationBuilder::post("/auth/logout") - .operation_id("authenticator.logout") - .summary("Revoke the session, clear the cookie, return the RP-logout URL") - .tag("auth") - .public() - .text_response(StatusCode::OK, "RP-logout URL", "application/json") - .handler(handlers::logout) - .register(router, openapi) + router } /// The gateway-facing `/internal/*` surface (the `auth_request` target). diff --git a/src/backend/services/authenticator/src/backchannel.rs b/src/backend/services/authenticator/src/backchannel.rs new file mode 100644 index 000000000..9287a757d --- /dev/null +++ b/src/backend/services/authenticator/src/backchannel.rs @@ -0,0 +1,290 @@ +//! OIDC back-channel logout token validation (PRD 5.10, OIDC BCL 1.0 §2.4–2.6). +//! +//! The `logout_token` is a JWT from the IdP: signature via the IdP JWKS, `iss` +//! against the one configured issuer, `aud` against our `client_id`, `iat` +//! freshness inside a skew/max-age window, the mandatory back-channel `events` +//! member, at least one of `sub`/`sid`, and — per spec — **no `nonce`** (which +//! distinguishes a logout token from a stolen id_token). Replay protection +//! (`jti`, one-shot) lives in the session store; this module is the pure +//! validation half, unit-tested with locally-signed tokens. + +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde::Deserialize; + +/// The back-channel logout event URI (OIDC BCL §2.4). +const LOGOUT_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout"; + +/// The validated claims the handler acts on. +#[derive(Debug)] +pub struct LogoutClaims { + pub sub: Option, + pub sid: Option, + pub jti: String, + pub iat: u64, +} + +/// Raw claim shape (aud may be a string or an array per RFC 7519). +#[derive(Debug, Deserialize)] +struct RawClaims { + iss: String, + #[serde(default)] + aud: serde_json::Value, + iat: u64, + jti: String, + #[serde(default)] + sub: Option, + #[serde(default)] + sid: Option, + #[serde(default)] + events: Option, + #[serde(default)] + nonce: Option, +} + +/// Validate a `logout_token`. Returns a coarse reason on failure — the IdP +/// gets a 400 with no more detail than it needs. +/// +/// # Errors +/// Returns the failure reason as a static string. +pub fn validate_logout_token( + jwks: &JwkSet, + raw: &str, + expected_iss: &str, + expected_aud: &str, + now: u64, + clock_skew_seconds: u64, + max_age_seconds: u64, +) -> Result { + let header = decode_header(raw).map_err(|_| "malformed_token")?; + let alg = header.alg; + if !matches!(alg, Algorithm::RS256 | Algorithm::ES256) { + return Err("unsupported_alg"); + } + + // Pick the key by kid when present; otherwise try every key of the set. + let keys: Vec<&jsonwebtoken::jwk::Jwk> = match &header.kid { + Some(kid) => jwks + .keys + .iter() + .filter(|k| k.common.key_id.as_deref() == Some(kid)) + .collect(), + None => jwks.keys.iter().collect(), + }; + if keys.is_empty() { + return Err("unknown_kid"); + } + + let mut validation = Validation::new(alg); + // A logout token has no `exp` requirement; freshness is `iat`-based below. + validation.validate_exp = false; + validation.set_issuer(&[expected_iss]); + validation.set_audience(&[expected_aud]); + validation.set_required_spec_claims(&["iss", "aud", "iat", "jti"]); + validation.leeway = clock_skew_seconds; + + let mut claims: Option = None; + for jwk in keys { + let Ok(key) = DecodingKey::from_jwk(jwk) else { + continue; + }; + if let Ok(data) = decode::(raw, &key, &validation) { + claims = Some(data.claims); + break; + } + } + let claims = claims.ok_or("signature_verification_failed")?; + + // iat freshness: not from the future (beyond skew), not older than max age. + if claims.iat > now + clock_skew_seconds { + return Err("iat_in_future"); + } + if now.saturating_sub(claims.iat) > max_age_seconds + clock_skew_seconds { + return Err("token_too_old"); + } + + // The mandatory events member (OIDC BCL §2.4). + let has_event = claims + .events + .as_ref() + .and_then(|e| e.as_object()) + .is_some_and(|o| o.contains_key(LOGOUT_EVENT)); + if !has_event { + return Err("missing_backchannel_event"); + } + + // A logout token MUST NOT carry a nonce (it would be an id_token replay). + if claims.nonce.is_some() { + return Err("nonce_present"); + } + + // At least one of sub / sid must name the target. + if claims.sub.is_none() && claims.sid.is_none() { + return Err("no_sub_or_sid"); + } + + // `iss` was validated by the decoder; keep the claim only for logging. + let _ = claims.iss; + let _ = claims.aud; + + Ok(LogoutClaims { + sub: claims.sub, + sid: claims.sid, + jti: claims.jti, + iat: claims.iat, + }) +} + +/// TTL for the one-shot `(iss, jti)` replay guard: the token's remaining +/// acceptability window, `(iat + max_age + skew) − now`, floored at 1 s. +#[must_use] +pub fn replay_guard_ttl(iat: u64, now: u64, clock_skew_seconds: u64, max_age_seconds: u64) -> u64 { + (iat + max_age_seconds + clock_skew_seconds) + .saturating_sub(now) + .max(1) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode}; + use p256::SecretKey; + use p256::elliptic_curve::Generate as _; + use p256::elliptic_curve::sec1::ToSec1Point as _; + use p256::pkcs8::{EncodePrivateKey as _, LineEnding}; + + const ISS: &str = "https://idp.example"; + const AUD: &str = "insight-authenticator"; + const NOW: u64 = 1_000_000; + + /// A P-256 keypair as (signing key, single-key JWKS with kid "k1"). + fn material() -> (EncodingKey, JwkSet) { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + let secret = SecretKey::generate(); + let pem = secret.to_pkcs8_pem(LineEnding::LF).unwrap(); + let enc = EncodingKey::from_ec_pem(pem.as_bytes()).unwrap(); + let point = secret.public_key().to_sec1_point(false); + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", "kid": "k1", + "x": B64.encode(point.x().unwrap()), + "y": B64.encode(point.y().unwrap()), + }] + })) + .unwrap(); + (enc, jwks) + } + + fn sign(enc: &EncodingKey, claims: &serde_json::Value) -> String { + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("k1".to_owned()); + encode(&header, claims, enc).unwrap() + } + + fn base_claims() -> serde_json::Value { + serde_json::json!({ + "iss": ISS, "aud": AUD, "iat": NOW - 10, "jti": "jti-1", + "sub": "user-1", "sid": "idp-sid-1", + "events": { "http://schemas.openid.net/event/backchannel-logout": {} }, + }) + } + + fn validate(jwks: &JwkSet, raw: &str) -> Result { + validate_logout_token(jwks, raw, ISS, AUD, NOW, 60, 300) + } + + #[test] + fn accepts_a_valid_logout_token() { + let (enc, jwks) = material(); + let token = sign(&enc, &base_claims()); + let claims = validate(&jwks, &token).unwrap(); + assert_eq!(claims.sub.as_deref(), Some("user-1")); + assert_eq!(claims.sid.as_deref(), Some("idp-sid-1")); + assert_eq!(claims.jti, "jti-1"); + } + + #[test] + fn rejects_wrong_issuer_audience_and_signature() { + let (enc, jwks) = material(); + let mut c = base_claims(); + c["iss"] = "https://evil.example".into(); + assert!(validate(&jwks, &sign(&enc, &c)).is_err()); + + let mut c = base_claims(); + c["aud"] = "someone-else".into(); + assert!(validate(&jwks, &sign(&enc, &c)).is_err()); + + // Signed by a key the JWKS does not hold. + let (other_enc, _) = material(); + assert_eq!( + validate(&jwks, &sign(&other_enc, &base_claims())).unwrap_err(), + "signature_verification_failed" + ); + } + + #[test] + fn rejects_missing_event_and_present_nonce() { + let (enc, jwks) = material(); + let mut c = base_claims(); + c["events"] = serde_json::json!({ "urn:other": {} }); + assert_eq!( + validate(&jwks, &sign(&enc, &c)).unwrap_err(), + "missing_backchannel_event" + ); + + let mut c = base_claims(); + c["nonce"] = "n-1".into(); + assert_eq!( + validate(&jwks, &sign(&enc, &c)).unwrap_err(), + "nonce_present" + ); + } + + #[test] + fn rejects_stale_and_future_iat() { + let (enc, jwks) = material(); + let mut c = base_claims(); + c["iat"] = (NOW - 1000).into(); // past max_age (300) + skew (60) + assert_eq!( + validate(&jwks, &sign(&enc, &c)).unwrap_err(), + "token_too_old" + ); + + let mut c = base_claims(); + c["iat"] = (NOW + 500).into(); // beyond the future skew + assert_eq!( + validate(&jwks, &sign(&enc, &c)).unwrap_err(), + "iat_in_future" + ); + } + + #[test] + fn requires_sub_or_sid() { + let (enc, jwks) = material(); + let mut c = base_claims(); + c.as_object_mut().unwrap().remove("sub"); + c.as_object_mut().unwrap().remove("sid"); + assert_eq!( + validate(&jwks, &sign(&enc, &c)).unwrap_err(), + "no_sub_or_sid" + ); + + // sub-only and sid-only are each sufficient. + let mut c = base_claims(); + c.as_object_mut().unwrap().remove("sid"); + assert!(validate(&jwks, &sign(&enc, &c)).is_ok()); + let mut c = base_claims(); + c.as_object_mut().unwrap().remove("sub"); + assert!(validate(&jwks, &sign(&enc, &c)).is_ok()); + } + + #[test] + fn replay_ttl_covers_the_acceptability_window() { + // iat 10 s ago, max_age 300, skew 60 → guard lives (300+60)−10 = 350 s. + assert_eq!(replay_guard_ttl(NOW - 10, NOW, 60, 300), 350); + // Long-past iat still yields the 1 s floor. + assert_eq!(replay_guard_ttl(0, NOW, 60, 300), 1); + } +} diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index 2bea33216..82fe59f18 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -188,6 +188,13 @@ pub struct AuthenticatorConfig { // ── Cross-cutting ──────────────────────────────────────────────────── /// CSRF `Origin` allowlist (empty = token-required, fail closed). pub csrf_origins: Vec, + /// Back-channel logout: tolerated clock skew on the `logout_token`'s `iat` + /// (future-dated tokens inside this window are accepted). + pub backchannel_clock_skew_seconds: u64, + /// Back-channel logout: how long after `iat` a `logout_token` stays + /// acceptable. Also sizes the `jti` replay-guard TTL + /// (`iat + max_age + skew − now`). + pub backchannel_token_max_age_seconds: u64, /// Roles (gateway-JWT `roles` scopes) authorized to call the admin /// revoke-by-user operation. The service registry grants one of these to /// the services that may force-logout users (e.g. the future permissions @@ -258,6 +265,8 @@ impl Default for AuthenticatorConfig { ], default_return_to: "/".to_owned(), csrf_origins: Vec::new(), + backchannel_clock_skew_seconds: 60, + backchannel_token_max_age_seconds: 300, admin_revoke_roles: vec!["session_admin".to_owned()], redis_url: String::new(), signing_keys_path: String::new(), diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index c6cddd53c..828f6b88d 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -22,6 +22,7 @@ #![allow(clippy::doc_markdown)] mod api; +mod backchannel; mod config; mod cookie; mod csrf; diff --git a/src/backend/services/authenticator/src/oidc.rs b/src/backend/services/authenticator/src/oidc.rs index 41c2415fc..1f1b2b97e 100644 --- a/src/backend/services/authenticator/src/oidc.rs +++ b/src/backend/services/authenticator/src/oidc.rs @@ -222,6 +222,51 @@ impl OidcClient { }) } + /// The IdP issuer URL this client trusts (back-channel `iss` check). + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer_url + } + + /// The registered client id (back-channel `aud` check). + #[must_use] + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Fetch the IdP's JWKS (via discovery) for back-channel `logout_token` + /// verification. Cold path — back-channel logout is rare — so no cache: + /// a fresh fetch also picks up IdP key rotation immediately. + /// + /// # Errors + /// Fails when discovery or the JWKS endpoint is unreachable / malformed. + pub async fn idp_jwks(&self) -> anyhow::Result { + #[derive(serde::Deserialize)] + struct Disco { + jwks_uri: String, + } + let disco: Disco = self + .http + .get(format!( + "{}/.well-known/openid-configuration", + self.issuer_url + )) + .send() + .await + .context("fetch IdP discovery")? + .json() + .await + .context("decode IdP discovery")?; + self.http + .get(&disco.jwks_uri) + .send() + .await + .context("fetch IdP JWKS")? + .json() + .await + .context("decode IdP JWKS") + } + /// Build the RP-initiated logout URL. `end_session_endpoint` is not part of /// core discovery, so it is fetched here directly. Returns `None` when the /// IdP advertises no endpoint. diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 29c6a864f..197010f64 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -166,6 +166,12 @@ fn user_sessions_key(person_id: &str) -> String { fn sid_index_key(iss: &str, idp_sid: &str) -> String { format!("asm:sid_index:{iss}:{idp_sid}") } +fn sub_index_key(iss: &str, idp_sub: &str) -> String { + format!("asm:sub_index:{iss}:{idp_sub}") +} +fn logout_jti_key(iss: &str, jti: &str) -> String { + format!("asm:logout_jti:{iss}:{jti}") +} fn login_state_key(state: &str) -> String { format!("asm:login_state:{state}") } @@ -319,15 +325,18 @@ impl SessionManager { // User-session index (score = expiry). pipe.zadd(user_sessions_key(&r.person_id), &s.session_id, expires_at) .ignore(); - // Back-channel logout index (only when the IdP supplies `sid`). + // Back-channel logout indexes: by OIDC `sid` (when the IdP supplies + // one) and by `(iss, sub)` — the sub-only fallback path. + let absolute = i64::try_from(r.absolute_expires_at).unwrap_or(i64::MAX); if let Some(sid) = &r.idp_sid { let idx = sid_index_key(&r.idp_iss, sid); pipe.sadd(&idx, &s.session_id).ignore(); - pipe.expire_at( - &idx, - i64::try_from(r.absolute_expires_at).unwrap_or(i64::MAX), - ) - .ignore(); + pipe.expire_at(&idx, absolute).ignore(); + } + if !r.idp_sub.is_empty() { + let idx = sub_index_key(&r.idp_iss, &r.idp_sub); + pipe.sadd(&idx, &s.session_id).ignore(); + pipe.expire_at(&idx, absolute).ignore(); } // IdP refresh schedule (consumer lands in step 10). if let Some(due) = s.refresh_due_at { @@ -475,6 +484,64 @@ impl SessionManager { Ok(rotated == 1) } + // ── Back-channel logout (PRD 5.10) ───────────────────────────────────── + + /// One-shot replay guard for a back-channel `logout_token` `jti` + /// (`asm:logout_jti:{iss}:{jti}`, `SET NX EX`). Returns `true` on first + /// delivery; `false` when this `(iss, jti)` was already accepted. + /// + /// # Errors + /// Fails on a Redis error (the handler then fails closed). + pub async fn guard_logout_jti( + &self, + iss: &str, + jti: &str, + ttl_seconds: u64, + ) -> anyhow::Result { + let mut conn = self.conn.clone(); + let set: Option = redis::cmd("SET") + .arg(logout_jti_key(iss, jti)) + .arg("1") + .arg("NX") + .arg("EX") + .arg(ttl_seconds.max(1)) + .query_async(&mut conn) + .await + .context("guard logout jti (NX EX)")?; + Ok(set.is_some()) + } + + /// Sessions indexed under a back-channel `(iss, sid)` pair. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn sessions_by_idp_sid( + &self, + iss: &str, + idp_sid: &str, + ) -> anyhow::Result> { + let mut conn = self.conn.clone(); + conn.smembers(sid_index_key(iss, idp_sid)) + .await + .context("read sid index") + } + + /// Sessions indexed under a back-channel `(iss, sub)` pair (the sub-only + /// fallback). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn sessions_by_idp_sub( + &self, + iss: &str, + idp_sub: &str, + ) -> anyhow::Result> { + let mut conn = self.conn.clone(); + conn.smembers(sub_index_key(iss, idp_sub)) + .await + .context("read sub index") + } + /// List a person's live sessions from the per-user index (score > `now`), /// loading each record. Index members whose record has already expired are /// skipped (the janitor trims them). @@ -522,6 +589,10 @@ impl SessionManager { pipe.srem(sid_index_key(&r.idp_iss, sid), session_id) .ignore(); } + if !r.idp_sub.is_empty() { + pipe.srem(sub_index_key(&r.idp_iss, &r.idp_sub), session_id) + .ignore(); + } pipe.zrem(REFRESH_DUE_KEY, session_id).ignore(); pipe.query_async::<()>(&mut conn) .await diff --git a/src/backend/services/authenticator/tests/e2e_backchannel.rs b/src/backend/services/authenticator/tests/e2e_backchannel.rs new file mode 100644 index 000000000..c0d210f33 --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_backchannel.rs @@ -0,0 +1,139 @@ +//! End-to-end OIDC back-channel logout against a running authenticator + +//! fakeidp + Redis (nginx+auth step 10, item 3). +//! +//! `#[ignore]` by default (needs the stack up with +//! `FAKEIDP_BACKCHANNEL_URL` pointing at the authenticator; `run-e2e.sh` +//! wires it): +//! +//! ```text +//! AUTH_BASE=http://localhost:8083 FAKEIDP_PUBLIC=http://localhost:8084 \ +//! cargo test -p authenticator --test e2e_backchannel -- --ignored --nocapture +//! ``` +//! +//! Drives fakeidp's `POST /_control/backchannel/{email}` hook: the fake IdP +//! fires a signed `logout_token` at the authenticator, and every session of +//! that user dies through the standard revoke pipeline. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::doc_markdown)] + +const COOKIE: &str = "__Host-sid"; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_owned()) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +fn rewrite_host(url: &str) -> String { + match ( + std::env::var("FAKEIDP_REWRITE_FROM"), + std::env::var("FAKEIDP_REWRITE_TO"), + ) { + (Ok(from), Ok(to)) if !from.is_empty() => url.replace(&from, &to), + _ => url.to_owned(), + } +} + +fn cookie_from(resp: &reqwest::Response) -> Option { + for hv in resp.headers().get_all(reqwest::header::SET_COOKIE) { + let raw = hv.to_str().ok()?; + for part in raw.split(';') { + if let Some(v) = part.trim().strip_prefix(&format!("{COOKIE}=")) + && !v.is_empty() + { + return Some(v.to_owned()); + } + } + } + None +} + +async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { + let login = http + .get(format!("{auth_base}/auth/login")) + .send() + .await + .unwrap(); + assert_eq!(login.status(), 302); + let authorize = rewrite_host(login.headers()[reqwest::header::LOCATION].to_str().unwrap()); + let sep = if authorize.contains('?') { '&' } else { '?' }; + let authorized = http + .get(format!("{authorize}{sep}user={user}")) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), 302); + let callback = rewrite_host( + authorized.headers()[reqwest::header::LOCATION] + .to_str() + .unwrap(), + ); + let cb = http.get(&callback).send().await.unwrap(); + assert_eq!(cb.status(), 302); + cookie_from(&cb).expect("callback must set __Host-sid") +} + +async fn authz_status(http: &reqwest::Client, auth_base: &str, token: &str) -> u16 { + http.get(format!("{auth_base}/internal/authz")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap() + .status() + .as_u16() +} + +#[tokio::test] +#[ignore = "requires a running authenticator + fakeidp + Redis stack"] +async fn back_channel_logout_kills_the_users_sessions() { + let auth_base = env("AUTH_BASE", "http://localhost:8083"); + let idp_public = env("FAKEIDP_PUBLIC", "http://localhost:8084"); + let test_user = env("E2E_USER", "dev@company.nonpresent"); + let http = client(); + + // Two devices, both alive. + let token_a = login(&http, &auth_base, &test_user).await; + let token_b = login(&http, &auth_base, &test_user).await; + assert_eq!(authz_status(&http, &auth_base, &token_a).await, 200); + assert_eq!(authz_status(&http, &auth_base, &token_b).await, 200); + + // The IdP fires a back-channel logout_token at the authenticator. + let fired = http + .post(format!("{idp_public}/_control/backchannel/{test_user}")) + .send() + .await + .unwrap(); + assert_eq!(fired.status(), 200, "fakeidp control hook must succeed"); + let body: serde_json::Value = fired.json().await.unwrap(); + assert_eq!( + body["rp_status"], 200, + "the authenticator must answer the logout_token with 200, got {body}" + ); + + // fakeidp's users have ONE OIDC sid per user (users.yaml), so the sid-index + // path revokes every session created under it: both devices die. + assert_eq!( + authz_status(&http, &auth_base, &token_a).await, + 401, + "device A must be logged out by back-channel logout" + ); + assert_eq!( + authz_status(&http, &auth_base, &token_b).await, + 401, + "device B must be logged out by back-channel logout" + ); + + // A rejected (unsigned garbage) token is a 400, not a revoke. + let bad = http + .post(format!("{auth_base}/auth/oidc/back-channel-logout")) + .form(&[("logout_token", "garbage.token.value")]) + .send() + .await + .unwrap(); + assert_eq!(bad.status(), 400, "a malformed logout_token must be 400"); +} diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index c71212748..6ed65c5a5 100644 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -65,6 +65,7 @@ wait_ready() { # name url echo "==> fakeidp :$IDP_PORT" FAKEIDP_ISSUER="http://localhost:$IDP_PORT" FAKEIDP_BIND="0.0.0.0:$IDP_PORT" \ FAKEIDP_DEFAULT_AUD=insight-authenticator \ + FAKEIDP_BACKCHANNEL_URL="http://localhost:$AUTH_PORT/auth/oidc/back-channel-logout" \ ./target/release/fakeidp >/tmp/authenticator-e2e-fakeidp.log 2>&1 & pids+=($!) wait_ready fakeidp "http://localhost:$IDP_PORT/.well-known/openid-configuration" @@ -105,6 +106,11 @@ echo "==> run the session-management loop (step 10.2)" AUTH_BASE="http://localhost:$AUTH_PORT" E2E_USER=dev@company.nonpresent \ cargo test -p authenticator --test e2e_sessions -- --ignored --nocapture +echo "==> run the back-channel logout loop (step 10.3)" +AUTH_BASE="http://localhost:$AUTH_PORT" FAKEIDP_PUBLIC="http://localhost:$IDP_PORT" \ + E2E_USER=dev@company.nonpresent \ + cargo test -p authenticator --test e2e_backchannel -- --ignored --nocapture + echo "==> run the service-token loop (step 06)" # The token listener binds 8093 (config service_tokens.token_bind_addr); the dev # `testclient` registry entry resolves public_key_paths against the generated From 29f902debeacecf5e85aba3c9a28c0e48d6eb1db Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:41:52 +0800 Subject: [PATCH 05/10] feat(authenticator): IdP background token refresher (step 10.4, G5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunnableCapability::start now spawns the leader-elected refresher: one leader (Redis lock asm:leader:idp_refresher, SET NX PX + same-holder renew, TTL = 3 ticks) polls asm:idp_refresh_due (ZRANGEBYSCORE 0 now, bounded) every idp.refresher_tick_seconds (default 5) and spawns per-session refresh tasks behind a Semaphore(idp.refresh_concurrency) — politeness toward the customer IdP, not our capacity. Each task holds a per-session rotation lock (SET NX PX 30 s): refresh tokens are one-time-use at most IdPs, and two workers racing a rotation would burn the grant and falsely kill the session. Outcomes (fail open on transport, fail closed on verdict): - success → store the rotated refresh token + new access expiry, reset the failure counter, re-schedule margin-before-expiry with write-time jitter (idp.refresh_due_jitter_seconds, G5 anti-herding); - invalid_grant (definitive: revoked/expired/disabled) → revoke the owning session through the standard pipeline (audited); the user's other sessions hold their own grants and die at their own next refresh, so IdP-side deactivation converges within about one access-token lifetime; - transient (network, 5xx, 429) → exponential backoff min(15< --- src/backend/Cargo.lock | 1 + src/backend/services/authenticator/Cargo.toml | 1 + .../authenticator/src/api/handlers.rs | 26 +- .../services/authenticator/src/config.rs | 7 + .../services/authenticator/src/gear.rs | 6 +- .../services/authenticator/src/main.rs | 1 + .../services/authenticator/src/oidc.rs | 62 ++++ .../services/authenticator/src/refresher.rs | 328 ++++++++++++++++++ .../services/authenticator/src/session.rs | 175 ++++++++++ .../authenticator/tests/e2e_refresher.rs | 188 ++++++++++ .../services/authenticator/tests/run-e2e.sh | 10 + 11 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 src/backend/services/authenticator/src/refresher.rs create mode 100644 src/backend/services/authenticator/tests/e2e_refresher.rs diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 3c93511ae..612e5ca1e 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ "futures", "jsonwebtoken", "openidconnect", + "opentelemetry", "p256 0.14.0", "rand 0.8.6", "redis", diff --git a/src/backend/services/authenticator/Cargo.toml b/src/backend/services/authenticator/Cargo.toml index 56bc531d1..88b71c7d2 100644 --- a/src/backend/services/authenticator/Cargo.toml +++ b/src/backend/services/authenticator/Cargo.toml @@ -52,6 +52,7 @@ futures = { workspace = true } uuid = { workspace = true, features = ["v5"] } chrono = { workspace = true } tracing = { workspace = true } +opentelemetry = "0.31" tracing-subscriber = { workspace = true } clap = { workspace = true } reqwest = { workspace = true } diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index d72fd5871..c864310e7 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -241,7 +241,23 @@ async fn mint_and_store_session( let now = now_secs(); let cfg = &state.cfg; let expires_at = now + cfg.session_ttl_seconds; - let absolute_expires_at = now + cfg.session_absolute_lifetime_seconds; + let mut absolute_expires_at = now + cfg.session_absolute_lifetime_seconds; + + // No refresh token → the refresher can't keep the IdP vouching for the + // user. `strict` (default) caps the session at the IdP access-token + // lifetime; `login_only` lets it live to the absolute cap (killed only by + // back-channel logout / manual revoke). (PRD 5.12 policy knob.) + if idp.refresh_token.is_none() + && cfg.idp.no_refresh_token_policy == crate::config::NoRefreshTokenPolicy::Strict + && let Some(ttl) = idp.expires_in + { + absolute_expires_at = absolute_expires_at.min(now + ttl); + tracing::debug!( + cap = absolute_expires_at, + "no IdP refresh token: strict policy caps the session at the IdP token lifetime" + ); + } + let expires_at = expires_at.min(absolute_expires_at); let session_id = Uuid::now_v7().to_string(); let token = csprng_token(); @@ -267,11 +283,13 @@ async fn mint_and_store_session( }; let jwt = state.keystore.sign(&claims)?; - // Schedule the IdP background refresh (consumer lands in step 10). - let refresh_due_at = if cfg.idp.refresh_enabled { + // Schedule the background refresh — only when there is a grant to refresh + // (no refresh token → the policy above already decided the lifetime). + // Due-times are jittered at write so sessions never herd (G5). + let refresh_due_at = if cfg.idp.refresh_enabled && idp.refresh_token.is_some() { idp.expires_in.map(|ttl| { let base = now + ttl.saturating_sub(cfg.idp.refresh_safety_margin_seconds); - base.saturating_add_signed(jitter_seconds(30)) + base.saturating_add_signed(jitter_seconds(cfg.idp.refresh_due_jitter_seconds)) }) } else { None diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index 82fe59f18..f436476ee 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -50,6 +50,11 @@ pub struct IdpConfig { pub refresh_concurrency: u32, /// Behavior when the IdP issues no refresh token. pub no_refresh_token_policy: NoRefreshTokenPolicy, + /// Refresher pass interval (leader polls the due schedule this often). + pub refresher_tick_seconds: u64, + /// Jitter (± this window) applied to due-times when WRITTEN to the + /// schedule, so sessions do not herd after a deploy or Redis restore (G5). + pub refresh_due_jitter_seconds: u64, } impl Default for IdpConfig { @@ -64,6 +69,8 @@ impl Default for IdpConfig { refresh_safety_margin_seconds: 60, refresh_concurrency: 128, no_refresh_token_policy: NoRefreshTokenPolicy::Strict, + refresher_tick_seconds: 5, + refresh_due_jitter_seconds: 30, } } } diff --git a/src/backend/services/authenticator/src/gear.rs b/src/backend/services/authenticator/src/gear.rs index 75e435ff0..64f48f2c8 100644 --- a/src/backend/services/authenticator/src/gear.rs +++ b/src/backend/services/authenticator/src/gear.rs @@ -137,8 +137,10 @@ impl RunnableCapability for AuthenticatorGear { .get() .ok_or_else(|| anyhow::anyhow!("authenticator gear not initialized"))? .clone(); - service_token::spawn(state, cancel).await?; - tracing::info!("authenticator runnable: service-token listener started (step 06)"); + service_token::spawn(state.clone(), cancel.clone()).await?; + // Leader-elected background workers (step 10): the IdP refresher (G5). + crate::refresher::spawn(state, cancel); + tracing::info!("authenticator runnable: service-token listener + idp refresher started"); Ok(()) } diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index 828f6b88d..49adadc0e 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -31,6 +31,7 @@ mod identity; mod jwt; mod local_client; mod oidc; +mod refresher; mod service_token; mod session; diff --git a/src/backend/services/authenticator/src/oidc.rs b/src/backend/services/authenticator/src/oidc.rs index 1f1b2b97e..6b864b5cd 100644 --- a/src/backend/services/authenticator/src/oidc.rs +++ b/src/backend/services/authenticator/src/oidc.rs @@ -50,6 +50,22 @@ pub struct AuthenticatedIdp { pub expires_in: Option, } +/// One background-refresh attempt's outcome (G5 transient-vs-definitive). +#[derive(Debug)] +pub enum RefreshOutcome { + /// The grant succeeded; store the rotated token + new expiry back. + Refreshed { + /// The rotated refresh token; `None` = the IdP kept the old one valid. + new_refresh_token: Option, + /// New access-token lifetime (drives the next schedule entry). + expires_in: Option, + }, + /// Definitive refusal (revoked / expired / user disabled): kill the session. + InvalidGrant(String), + /// Transport / 5xx / 429: back off and retry, never revoke. + Transient(String), +} + /// The OIDC client — holds config; builds the `openidconnect` client per op /// (discovery is a cold-path login/callback concern). #[derive(Clone)] @@ -222,6 +238,52 @@ impl OidcClient { }) } + /// Run a `refresh_token` grant for the background refresher (G5). The + /// outcome distinguishes a **definitive** IdP verdict (`invalid_grant`: + /// revoked / expired / user disabled → the caller kills the session) from + /// **transient** failures (network, 5xx, 429 → the caller backs off and + /// retries; nobody is logged out by a blip). + pub async fn refresh_grant(&self, refresh_token: &str) -> RefreshOutcome { + use openidconnect::RequestTokenError::ServerResponse; + use openidconnect::core::CoreErrorResponseType; + + let metadata = match self.metadata().await { + Ok(m) => m, + Err(e) => return RefreshOutcome::Transient(format!("discovery: {e:#}")), + }; + let client = CoreClient::from_provider_metadata( + metadata, + ClientId::new(self.client_id.clone()), + self.secret(), + ); + let rt = openidconnect::RefreshToken::new(refresh_token.to_owned()); + let request = match client.exchange_refresh_token(&rt) { + Ok(r) => r, + Err(e) => return RefreshOutcome::Transient(format!("build refresh request: {e}")), + }; + let result = request.request_async(&self.http).await; + + match result { + Ok(token) => RefreshOutcome::Refreshed { + // Most IdPs rotate (one-time-use); keeping the old token when + // none is returned matches RFC 6749 §6. + new_refresh_token: token.refresh_token().map(|r| r.secret().clone()), + expires_in: token.expires_in().map(|d| d.as_secs()), + }, + Err(ServerResponse(r)) if *r.error() == CoreErrorResponseType::InvalidGrant => { + RefreshOutcome::InvalidGrant( + r.error_description() + .map(ToString::to_string) + .unwrap_or_default(), + ) + } + // Every other token-endpoint error (invalid_client, 5xx-shaped + // bodies, 429) and all transport/parse errors are transient: fail + // open on transport, fail closed only on the definitive verdict. + Err(e) => RefreshOutcome::Transient(format!("{e}")), + } + } + /// The IdP issuer URL this client trusts (back-channel `iss` check). #[must_use] pub fn issuer(&self) -> &str { diff --git a/src/backend/services/authenticator/src/refresher.rs b/src/backend/services/authenticator/src/refresher.rs new file mode 100644 index 000000000..2cc37415b --- /dev/null +++ b/src/backend/services/authenticator/src/refresher.rs @@ -0,0 +1,328 @@ +//! IdP background token refresher (PRD 5.12, G5 — the decided design). +//! +//! One leader (Redis lock, DD-BFF-09) polls the `asm:idp_refresh_due` ZSET +//! every tick and spawns per-session refresh tasks behind a semaphore +//! (`idp.refresh_concurrency` — politeness toward the customer IdP, not our +//! capacity). Each task takes a per-session lock (refresh tokens are +//! one-time-use at most IdPs; racing a rotation burns the grant), runs the +//! grant, and: +//! +//! - **success** → store the rotated token + new expiry, re-schedule with +//! write-time jitter; +//! - **`invalid_grant`** (definitive: revoked / expired / user disabled) → +//! revoke the session that owns the grant through the standard pipeline — +//! the user's other sessions each hold their own grant and die at their own +//! next refresh, so IdP-side deactivation converges within roughly one IdP +//! access-token lifetime; +//! - **transient** (network, 5xx, 429) → exponential backoff and retry, +//! NEVER revoke — a five-minute IdP blip must not log out the installation. +//! +//! Metrics: `idp_refresh_total{result}`, an `idp_refresh_consecutive_failures` +//! gauge (alert before the mass logout, not after), and +//! `idp_refresh_invalid_grant_total`. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use opentelemetry::KeyValue; +use opentelemetry::metrics::Counter; +use tokio::sync::Semaphore; +use tokio_util::sync::CancellationToken; + +use crate::api::AppState; +use crate::oidc::RefreshOutcome; +use crate::session::SessionManager; + +const LEADER_KEY: &str = "asm:leader:idp_refresher"; +/// Per-session lock TTL — covers one grant round-trip with generous margin. +const SESSION_LOCK_TTL_MS: u64 = 30_000; +/// Backoff for transient failures: `min(base << failures, max)` seconds. +const BACKOFF_BASE_SECONDS: u64 = 15; +const BACKOFF_MAX_SECONDS: u64 = 300; +/// Schedule entries drained per tick (leader-side bound; the semaphore is the +/// real throttle). +const BATCH_LIMIT: usize = 512; + +/// Instruments + the consecutive-transient-failure gauge state. +struct Metrics { + refresh_total: Counter, + invalid_grant_total: Counter, + consecutive_failures: Arc, +} + +impl Metrics { + fn new() -> Self { + let meter = opentelemetry::global::meter("authenticator.idp_refresher"); + let consecutive_failures = Arc::new(AtomicU64::new(0)); + let gauge_state = consecutive_failures.clone(); + meter + .u64_observable_gauge("idp_refresh_consecutive_failures") + .with_description( + "Consecutive transient IdP refresh failures (rises before a mass logout)", + ) + .with_callback(move |observer| { + observer.observe(gauge_state.load(Ordering::Relaxed), &[]); + }) + .build(); + Self { + refresh_total: meter + .u64_counter("idp_refresh_total") + .with_description("IdP background refresh outcomes") + .build(), + invalid_grant_total: meter + .u64_counter("idp_refresh_invalid_grant_total") + .with_description("Definitive IdP refusals (each kills the owning session)") + .build(), + consecutive_failures, + } + } + + fn record(&self, result: &'static str) { + self.refresh_total + .add(1, &[KeyValue::new("result", result)]); + match result { + "transient" => { + self.consecutive_failures.fetch_add(1, Ordering::Relaxed); + } + _ => self.consecutive_failures.store(0, Ordering::Relaxed), + } + if result == "invalid_grant" { + self.invalid_grant_total.add(1, &[]); + } + } +} + +/// Spawn the refresher loop; returns immediately (the gear's `start` must be +/// prompt). The loop runs on every pod but only the elected leader drains the +/// schedule. Cancellation stops the loop at the next tick. +pub fn spawn(state: Arc, cancel: CancellationToken) { + if !state.cfg.idp.refresh_enabled { + tracing::info!("idp refresher disabled by config (idp.refresh_enabled=false)"); + return; + } + tokio::spawn(run(state, cancel)); +} + +async fn run(state: Arc, cancel: CancellationToken) { + let tick = Duration::from_secs(state.cfg.idp.refresher_tick_seconds.max(1)); + // Holder id: unique per process — pod name is not observable here, a UUID is. + let holder = uuid::Uuid::now_v7().to_string(); + let semaphore = Arc::new(Semaphore::new( + usize::try_from(state.cfg.idp.refresh_concurrency.max(1)).unwrap_or(128), + )); + let metrics = Arc::new(Metrics::new()); + tracing::info!( + tick_seconds = tick.as_secs(), + concurrency = state.cfg.idp.refresh_concurrency, + "idp refresher started (leader-elected)" + ); + + loop { + tokio::select! { + () = cancel.cancelled() => { + tracing::info!("idp refresher stopping"); + return; + } + () = tokio::time::sleep(tick) => {} + } + + // Leader lock TTL = 3 ticks: a dead leader is replaced within ~2 ticks, + // a live one renews every tick. + let lease_ms = u64::try_from(tick.as_millis()).unwrap_or(5_000) * 3; + let lead = state.sessions.try_lead(LEADER_KEY, &holder, lease_ms).await; + match lead { + Ok(true) => {} + Ok(false) => continue, + Err(e) => { + tracing::warn!(error = %e, "idp refresher: leader election failed (skipping pass)"); + continue; + } + } + + let now = now_secs(); + let due = match state.sessions.due_refresh_sessions(now, BATCH_LIMIT).await { + Ok(due) => due, + Err(e) => { + tracing::warn!(error = %e, "idp refresher: schedule read failed"); + continue; + } + }; + for session_id in due { + let Ok(permit) = semaphore.clone().acquire_owned().await else { + return; // semaphore closed — only on shutdown + }; + let state = state.clone(); + let metrics = metrics.clone(); + tokio::spawn(async move { + let _permit = permit; + refresh_one(&state, &metrics, &session_id).await; + }); + } + } +} + +/// Refresh a single due session under its rotation lock. +async fn refresh_one(state: &Arc, metrics: &Metrics, session_id: &str) { + let sessions = &state.sessions; + match sessions + .lock_session_refresh(session_id, SESSION_LOCK_TTL_MS) + .await + { + Ok(true) => {} + Ok(false) => return, // another worker is mid-rotation + Err(e) => { + tracing::warn!(error = %e, session_id, "refresh lock failed"); + return; + } + } + + let result = do_refresh(state, metrics, session_id).await; + if let Err(e) = result { + tracing::warn!(error = %e, session_id, "idp refresh: store error"); + } + if let Err(e) = sessions.unlock_session_refresh(session_id).await { + tracing::debug!(error = %e, session_id, "refresh unlock failed (lock TTL covers it)"); + } +} + +async fn do_refresh( + state: &Arc, + metrics: &Metrics, + session_id: &str, +) -> anyhow::Result<()> { + let sessions: &SessionManager = &state.sessions; + let now = now_secs(); + + // A vanished / expired session has nothing to refresh. + let Some(record) = sessions.load_session(session_id).await? else { + sessions.unschedule_refresh(session_id).await?; + return Ok(()); + }; + if record.expires_at <= now || record.absolute_expires_at <= now { + sessions.unschedule_refresh(session_id).await?; + return Ok(()); + } + let Some(refresh_token) = record.idp_refresh_token.as_deref() else { + // Scheduled by mistake (no grant to refresh) — policy handled at login. + sessions.unschedule_refresh(session_id).await?; + return Ok(()); + }; + + match state.oidc.refresh_grant(refresh_token).await { + RefreshOutcome::Refreshed { + new_refresh_token, + expires_in, + } => { + metrics.record("ok"); + let access_expires_at = expires_in.map(|ttl| now + ttl); + let next_due = next_due_at( + now, + expires_in, + state.cfg.idp.refresh_safety_margin_seconds, + state.cfg.idp.refresh_due_jitter_seconds, + ); + sessions + .store_idp_refresh( + session_id, + new_refresh_token.as_deref(), + access_expires_at, + next_due, + ) + .await?; + tracing::debug!(session_id, next_due, "idp refresh ok"); + } + RefreshOutcome::InvalidGrant(detail) => { + metrics.record("invalid_grant"); + // Definitive verdict: the IdP no longer vouches for this grant. + // Kill the owning session through the standard pipeline; the + // user's other sessions die at their own next refresh. + sessions.revoke_session(session_id).await?; + tracing::warn!( + target: "audit", + event = "idp_refresh_invalid_grant", + session_id, + person_id = %record.person_id, + detail = %detail, + "IdP refused the refresh grant definitively: session revoked" + ); + } + RefreshOutcome::Transient(detail) => { + metrics.record("transient"); + let failures = sessions.bump_refresh_failures(session_id).await?; + let retry_at = now + backoff_seconds(failures); + sessions.reschedule_refresh(session_id, retry_at).await?; + tracing::warn!( + session_id, + failures, + retry_at, + detail = %detail, + "idp refresh transient failure: backing off (never revoking)" + ); + } + } + Ok(()) +} + +/// The next schedule entry: `now + (expires_in − margin)`, jittered at write +/// (G5). An IdP that reports no lifetime is re-checked one margin from now. +fn next_due_at(now: u64, expires_in: Option, margin: u64, jitter_window: u64) -> u64 { + let base = match expires_in { + Some(ttl) => now + ttl.saturating_sub(margin), + None => now + margin.max(60), + }; + base.saturating_add_signed(jitter(jitter_window)) +} + +/// Exponential transient backoff: `min(15 << failures, 300)` seconds, jittered. +fn backoff_seconds(failures: u64) -> u64 { + #[allow(clippy::cast_possible_truncation)] + let shift = failures.min(8) as u32; + let base = (BACKOFF_BASE_SECONDS << shift).min(BACKOFF_MAX_SECONDS); + base.saturating_add_signed(jitter(base / 4)) +} + +/// Uniform jitter in `[-window, +window]` seconds. +fn jitter(window: u64) -> i64 { + if window == 0 { + return 0; + } + let w = i64::try_from(window).unwrap_or(0); + rand::Rng::gen_range(&mut rand::thread_rng(), -w..=w) +} + +fn now_secs() -> u64 { + u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_due_lands_margin_before_expiry_with_jitter() { + // ttl 600, margin 60, jitter ±30 → due ∈ now + [510, 570]. + for _ in 0..100 { + let due = next_due_at(1_000, Some(600), 60, 30); + assert!((1_510..=1_570).contains(&due), "{due}"); + } + } + + #[test] + fn unknown_lifetime_rechecks_after_one_margin() { + let due = next_due_at(1_000, None, 60, 0); + assert_eq!(due, 1_060); + } + + #[test] + fn backoff_grows_and_caps() { + // Deterministic core (jitter is ±base/4): failures 0 → ~15 s, large → + // capped at ~300 s. + for _ in 0..50 { + let b0 = backoff_seconds(0); + assert!((11..=19).contains(&b0), "{b0}"); + let b9 = backoff_seconds(9); + assert!((225..=375).contains(&b9), "{b9}"); + } + } +} diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 197010f64..38b3e14f7 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -484,6 +484,181 @@ impl SessionManager { Ok(rotated == 1) } + // ── Background workers (G5 refresher, janitor) ───────────────────────── + + /// Try to take (or renew) a leader lock. `SET key holder NX PX ttl` wins a + /// free lock; an already-held lock renews only for the same `holder` + /// (DD-BFF-09 — one leader per pass, Redis is already a hard dependency). + /// + /// # Errors + /// Fails on a Redis error (the worker then skips this pass). + pub async fn try_lead(&self, key: &str, holder: &str, ttl_ms: u64) -> anyhow::Result { + let mut conn = self.conn.clone(); + let won: Option = redis::cmd("SET") + .arg(key) + .arg(holder) + .arg("NX") + .arg("PX") + .arg(ttl_ms.max(1)) + .query_async(&mut conn) + .await + .context("acquire leader lock")?; + if won.is_some() { + return Ok(true); + } + let current: Option = conn.get(key).await.context("read leader lock")?; + if current.as_deref() == Some(holder) { + let _: bool = conn + .pexpire(key, i64::try_from(ttl_ms).unwrap_or(1)) + .await + .context("renew leader lock")?; + return Ok(true); + } + Ok(false) + } + + /// Sessions due for IdP refresh (`ZRANGEBYSCORE asm:idp_refresh_due 0 now`, + /// bounded). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn due_refresh_sessions( + &self, + now: u64, + limit: usize, + ) -> anyhow::Result> { + let mut conn = self.conn.clone(); + conn.zrangebyscore_limit( + REFRESH_DUE_KEY, + 0, + i64::try_from(now).unwrap_or(i64::MAX), + 0, + isize::try_from(limit).unwrap_or(isize::MAX), + ) + .await + .context("read refresh schedule") + } + + /// Per-session refresh lock (`SET NX PX`): refresh-token rotation is + /// one-time-use at most IdPs; two workers racing the same rotation would + /// burn the grant and falsely kill the session. Returns `true` when this + /// caller holds the lock. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn lock_session_refresh( + &self, + session_id: &str, + ttl_ms: u64, + ) -> anyhow::Result { + let mut conn = self.conn.clone(); + let set: Option = redis::cmd("SET") + .arg(format!("asm:refresh_lock:{session_id}")) + .arg("1") + .arg("NX") + .arg("PX") + .arg(ttl_ms.max(1)) + .query_async(&mut conn) + .await + .context("acquire per-session refresh lock")?; + Ok(set.is_some()) + } + + /// Release the per-session refresh lock. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn unlock_session_refresh(&self, session_id: &str) -> anyhow::Result<()> { + let mut conn = self.conn.clone(); + let _: i64 = conn + .del(format!("asm:refresh_lock:{session_id}")) + .await + .context("release per-session refresh lock")?; + Ok(()) + } + + /// Persist a successful IdP refresh: rotated refresh token (when the IdP + /// returned one), new access-token expiry, reset failure counter, and the + /// next schedule entry — one pipeline. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn store_idp_refresh( + &self, + session_id: &str, + new_refresh_token: Option<&str>, + access_expires_at: Option, + next_due: u64, + ) -> anyhow::Result<()> { + let mut conn = self.conn.clone(); + let skey = session_key(session_id); + let mut pipe = redis::pipe(); + pipe.atomic(); + if let Some(token) = new_refresh_token { + pipe.hset(&skey, "idp_refresh_token", token).ignore(); + } + if let Some(exp) = access_expires_at { + pipe.hset(&skey, "idp_access_expires_at", exp.to_string()) + .ignore(); + } + pipe.hset(&skey, "idp_refresh_failures", "0").ignore(); + pipe.zadd( + REFRESH_DUE_KEY, + session_id, + i64::try_from(next_due).unwrap_or(i64::MAX), + ) + .ignore(); + pipe.query_async::<()>(&mut conn) + .await + .context("store IdP refresh pipeline")?; + Ok(()) + } + + /// Bump the per-session transient-failure counter; returns the new count + /// (sizes the exponential backoff). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn bump_refresh_failures(&self, session_id: &str) -> anyhow::Result { + let mut conn = self.conn.clone(); + let failures: i64 = conn + .hincr(session_key(session_id), "idp_refresh_failures", 1) + .await + .context("bump refresh failures")?; + Ok(u64::try_from(failures).unwrap_or(0)) + } + + /// Re-schedule a session's next refresh attempt. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn reschedule_refresh(&self, session_id: &str, due_at: u64) -> anyhow::Result<()> { + let mut conn = self.conn.clone(); + let _: i64 = conn + .zadd( + REFRESH_DUE_KEY, + session_id, + i64::try_from(due_at).unwrap_or(i64::MAX), + ) + .await + .context("reschedule refresh")?; + Ok(()) + } + + /// Drop a session from the refresh schedule (dead session, or nothing to + /// refresh). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn unschedule_refresh(&self, session_id: &str) -> anyhow::Result<()> { + let mut conn = self.conn.clone(); + let _: i64 = conn + .zrem(REFRESH_DUE_KEY, session_id) + .await + .context("unschedule refresh")?; + Ok(()) + } + // ── Back-channel logout (PRD 5.10) ───────────────────────────────────── /// One-shot replay guard for a back-channel `logout_token` `jti` diff --git a/src/backend/services/authenticator/tests/e2e_refresher.rs b/src/backend/services/authenticator/tests/e2e_refresher.rs new file mode 100644 index 000000000..20deecd9b --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_refresher.rs @@ -0,0 +1,188 @@ +//! End-to-end IdP background refresher (nginx+auth step 10, item 4) against a +//! running authenticator + fakeidp + Redis with a FAST refresh lifecycle +//! (`run-e2e.sh` sets `FAKEIDP_TOKEN_TTL=15`, margin 10 s, tick 1 s, jitter +//! ±1 s, so a session's IdP tokens refresh every ~5 s). +//! +//! ```text +//! AUTH_BASE=http://localhost:8083 FAKEIDP_PUBLIC=http://localhost:8084 \ +//! cargo test -p authenticator --test e2e_refresher -- --ignored --nocapture +//! ``` +//! +//! Drives fakeidp's control hooks (the reason fakeidp exists, G6): +//! `/_control/outage` — transient failures must log nobody out; and +//! `/_control/revoke/{user}` — the definitive `invalid_grant` verdict must +//! kill the user's sessions on the next scheduled refresh, while another +//! user's session survives. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::doc_markdown)] + +use std::time::Duration; + +const COOKIE: &str = "__Host-sid"; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_owned()) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +fn rewrite_host(url: &str) -> String { + match ( + std::env::var("FAKEIDP_REWRITE_FROM"), + std::env::var("FAKEIDP_REWRITE_TO"), + ) { + (Ok(from), Ok(to)) if !from.is_empty() => url.replace(&from, &to), + _ => url.to_owned(), + } +} + +fn cookie_from(resp: &reqwest::Response) -> Option { + for hv in resp.headers().get_all(reqwest::header::SET_COOKIE) { + let raw = hv.to_str().ok()?; + for part in raw.split(';') { + if let Some(v) = part.trim().strip_prefix(&format!("{COOKIE}=")) + && !v.is_empty() + { + return Some(v.to_owned()); + } + } + } + None +} + +async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { + let login = http + .get(format!("{auth_base}/auth/login")) + .send() + .await + .unwrap(); + assert_eq!(login.status(), 302); + let authorize = rewrite_host(login.headers()[reqwest::header::LOCATION].to_str().unwrap()); + let sep = if authorize.contains('?') { '&' } else { '?' }; + let authorized = http + .get(format!("{authorize}{sep}user={user}")) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), 302); + let callback = rewrite_host( + authorized.headers()[reqwest::header::LOCATION] + .to_str() + .unwrap(), + ); + let cb = http.get(&callback).send().await.unwrap(); + assert_eq!(cb.status(), 302); + cookie_from(&cb).expect("callback must set __Host-sid") +} + +async fn authz_status(http: &reqwest::Client, auth_base: &str, token: &str) -> u16 { + http.get(format!("{auth_base}/internal/authz")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap() + .status() + .as_u16() +} + +async fn control(http: &reqwest::Client, idp: &str, path: &str, body: Option) { + let req = http.post(format!("{idp}{path}")); + let req = match body { + Some(json) => req.json(&json), + None => req, + }; + let resp = req.send().await.unwrap(); + assert!( + resp.status().is_success(), + "control hook {path} failed: {}", + resp.status() + ); +} + +/// Poll `authz` until it returns `expected` or the deadline passes. +async fn wait_for_status( + http: &reqwest::Client, + auth_base: &str, + token: &str, + expected: u16, + deadline: Duration, +) -> bool { + let start = std::time::Instant::now(); + while start.elapsed() < deadline { + if authz_status(http, auth_base, token).await == expected { + return true; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + false +} + +#[tokio::test] +#[ignore = "requires the fast-lifecycle e2e stack (run-e2e.sh)"] +async fn refresher_outage_survives_and_invalid_grant_kills() { + let auth_base = env("AUTH_BASE", "http://localhost:8083"); + let idp = env("FAKEIDP_PUBLIC", "http://localhost:8084"); + let victim = "alice@example.com"; + let survivor = "bob@example.com"; + let http = client(); + + let victim_token = login(&http, &auth_base, victim).await; + let survivor_token = login(&http, &auth_base, survivor).await; + assert_eq!(authz_status(&http, &auth_base, &victim_token).await, 200); + assert_eq!(authz_status(&http, &auth_base, &survivor_token).await, 200); + + // 1. Outage: the IdP token endpoint returns 5xx. Refresh attempts fail + // TRANSIENTLY for ~12 s (several due cycles at the fast lifecycle) — + // nobody may be logged out by a blip. + control( + &http, + &idp, + "/_control/outage", + Some(serde_json::json!({"mode": "5xx"})), + ) + .await; + tokio::time::sleep(Duration::from_secs(12)).await; + assert_eq!( + authz_status(&http, &auth_base, &victim_token).await, + 200, + "an IdP outage must not log users out (fail open on transport)" + ); + assert_eq!(authz_status(&http, &auth_base, &survivor_token).await, 200); + control( + &http, + &idp, + "/_control/outage", + Some(serde_json::json!({"mode": "off"})), + ) + .await; + + // 2. Definitive verdict: revoke the victim at the IdP. The next scheduled + // refresh gets invalid_grant and the session dies through the standard + // pipeline. Generous deadline: the outage above pushed the session into + // exponential backoff (~15–40 s). + control(&http, &idp, &format!("/_control/revoke/{victim}"), None).await; + let died = wait_for_status( + &http, + &auth_base, + &victim_token, + 401, + Duration::from_secs(90), + ) + .await; + assert!( + died, + "the revoked user's session must die on the next scheduled refresh" + ); + + // 3. The other user's session lives on — the kill is per grant, not global. + assert_eq!( + authz_status(&http, &auth_base, &survivor_token).await, + 200, + "an unrelated user must survive another user's invalid_grant kill" + ); +} diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index 6ed65c5a5..60ac04124 100644 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -63,9 +63,12 @@ wait_ready() { # name url } echo "==> fakeidp :$IDP_PORT" +# Short IdP token TTL so the background refresher (step 10.4) cycles within +# seconds instead of minutes; see the matching margin/tick overrides below. FAKEIDP_ISSUER="http://localhost:$IDP_PORT" FAKEIDP_BIND="0.0.0.0:$IDP_PORT" \ FAKEIDP_DEFAULT_AUD=insight-authenticator \ FAKEIDP_BACKCHANNEL_URL="http://localhost:$AUTH_PORT/auth/oidc/back-channel-logout" \ + FAKEIDP_TOKEN_TTL=15 \ ./target/release/fakeidp >/tmp/authenticator-e2e-fakeidp.log 2>&1 & pids+=($!) wait_ready fakeidp "http://localhost:$IDP_PORT/.well-known/openid-configuration" @@ -84,6 +87,9 @@ APP__gears__authenticator__config__idp__issuer_url="http://localhost:$IDP_PORT" APP__gears__authenticator__config__idp__client_id=insight-authenticator \ APP__gears__authenticator__config__redirect_uri="http://localhost:$AUTH_PORT/auth/callback" \ APP__gears__authenticator__config__service_tokens__public_key_dir="$SVC_KEYS_DIR" \ +APP__gears__authenticator__config__idp__refresh_safety_margin_seconds=10 \ +APP__gears__authenticator__config__idp__refresh_due_jitter_seconds=1 \ +APP__gears__authenticator__config__idp__refresher_tick_seconds=1 \ ./target/release/authenticator -c services/authenticator/config/insight.yaml run \ >/tmp/authenticator-e2e-auth.log 2>&1 & pids+=($!) @@ -111,6 +117,10 @@ AUTH_BASE="http://localhost:$AUTH_PORT" FAKEIDP_PUBLIC="http://localhost:$IDP_PO E2E_USER=dev@company.nonpresent \ cargo test -p authenticator --test e2e_backchannel -- --ignored --nocapture +echo "==> run the IdP background-refresher loop (step 10.4: outage + invalid_grant)" +AUTH_BASE="http://localhost:$AUTH_PORT" FAKEIDP_PUBLIC="http://localhost:$IDP_PORT" \ + cargo test -p authenticator --test e2e_refresher -- --ignored --nocapture + echo "==> run the service-token loop (step 06)" # The token listener binds 8093 (config service_tokens.token_bind_addr); the dev # `testclient` registry entry resolves public_key_paths against the generated From 524b4336bf47bb349c06c50bea448312e8f7e17c Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:43:37 +0800 Subject: [PATCH 06/10] feat(authenticator): leader-elected index janitor (step 10.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-key TTLs remove session records and token mappings, but the per-user ZSET indexes and the refresh schedule keep dead members until trimmed. One leader (asm:leader:janitor, the same SET NX PX + same-holder-renew election as the refresher) runs a pass every janitor_interval_seconds (default 30 s): SCAN asm:user_sessions:* (bounded batches, never KEYS) + ZREMRANGEBYSCORE 0 now per index, plus dropping refresh-schedule entries overdue by more than 10 min (live sessions are rescheduled every attempt — a long-overdue entry has no owner). Metrics: auth_janitor_removed_total counter and auth_janitor_backlog_size gauge (expired-but-untrimmed members seen by the last pass — rises when no pod is running passes, per DESIGN 4.3). EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- .../services/authenticator/src/config.rs | 3 + .../services/authenticator/src/gear.rs | 10 ++- .../services/authenticator/src/janitor.rs | 83 +++++++++++++++++++ .../services/authenticator/src/main.rs | 1 + .../services/authenticator/src/session.rs | 63 ++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/backend/services/authenticator/src/janitor.rs diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index f436476ee..3ecd055bf 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -195,6 +195,8 @@ pub struct AuthenticatorConfig { // ── Cross-cutting ──────────────────────────────────────────────────── /// CSRF `Origin` allowlist (empty = token-required, fail closed). pub csrf_origins: Vec, + /// Janitor pass interval (leader-elected trim of expired index members). + pub janitor_interval_seconds: u64, /// Back-channel logout: tolerated clock skew on the `logout_token`'s `iat` /// (future-dated tokens inside this window are accepted). pub backchannel_clock_skew_seconds: u64, @@ -272,6 +274,7 @@ impl Default for AuthenticatorConfig { ], default_return_to: "/".to_owned(), csrf_origins: Vec::new(), + janitor_interval_seconds: 30, backchannel_clock_skew_seconds: 60, backchannel_token_max_age_seconds: 300, admin_revoke_roles: vec!["session_admin".to_owned()], diff --git a/src/backend/services/authenticator/src/gear.rs b/src/backend/services/authenticator/src/gear.rs index 64f48f2c8..dfd7100f4 100644 --- a/src/backend/services/authenticator/src/gear.rs +++ b/src/backend/services/authenticator/src/gear.rs @@ -138,9 +138,13 @@ impl RunnableCapability for AuthenticatorGear { .ok_or_else(|| anyhow::anyhow!("authenticator gear not initialized"))? .clone(); service_token::spawn(state.clone(), cancel.clone()).await?; - // Leader-elected background workers (step 10): the IdP refresher (G5). - crate::refresher::spawn(state, cancel); - tracing::info!("authenticator runnable: service-token listener + idp refresher started"); + // Leader-elected background workers (step 10): the IdP refresher (G5) + // and the index janitor (DESIGN §4.3). + crate::refresher::spawn(state.clone(), cancel.clone()); + crate::janitor::spawn(state, cancel); + tracing::info!( + "authenticator runnable: service-token listener + idp refresher + janitor started" + ); Ok(()) } diff --git a/src/backend/services/authenticator/src/janitor.rs b/src/backend/services/authenticator/src/janitor.rs new file mode 100644 index 000000000..ef622e7d9 --- /dev/null +++ b/src/backend/services/authenticator/src/janitor.rs @@ -0,0 +1,83 @@ +//! Index janitor (PRD 5.5.8, DESIGN §4.3). +//! +//! Per-key Redis TTLs remove session records and token mappings, but ZSET +//! index members (`asm:user_sessions:*`) and refresh-schedule orphans linger +//! until trimmed. One leader (Redis lock, DD-BFF-09 — same election as the +//! refresher) runs a pass every `janitor_interval_seconds` (default 30 s) and +//! emits removed/backlog metrics; a rising backlog means no pod is running +//! passes. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use tokio_util::sync::CancellationToken; + +use crate::api::AppState; + +const LEADER_KEY: &str = "asm:leader:janitor"; +/// A refresh-schedule entry still due after this long has no live owner (the +/// refresher reschedules live sessions every attempt). +const ORPHAN_GRACE_SECONDS: u64 = 600; + +/// Spawn the janitor loop; returns immediately. +pub fn spawn(state: Arc, cancel: CancellationToken) { + tokio::spawn(run(state, cancel)); +} + +async fn run(state: Arc, cancel: CancellationToken) { + let tick = Duration::from_secs(state.cfg.janitor_interval_seconds.max(1)); + let holder = uuid::Uuid::now_v7().to_string(); + + let meter = opentelemetry::global::meter("authenticator.janitor"); + let removed_total = meter + .u64_counter("auth_janitor_removed_total") + .with_description("Expired index members / schedule orphans trimmed") + .build(); + let backlog_state = Arc::new(AtomicU64::new(0)); + let gauge_state = backlog_state.clone(); + meter + .u64_observable_gauge("auth_janitor_backlog_size") + .with_description("Expired-but-untrimmed index members seen by the last pass") + .with_callback(move |observer| { + observer.observe(gauge_state.load(Ordering::Relaxed), &[]); + }) + .build(); + + tracing::info!( + interval_seconds = tick.as_secs(), + "janitor started (leader-elected)" + ); + + loop { + tokio::select! { + () = cancel.cancelled() => { + tracing::info!("janitor stopping"); + return; + } + () = tokio::time::sleep(tick) => {} + } + + let lease_ms = u64::try_from(tick.as_millis()).unwrap_or(30_000) * 3; + match state.sessions.try_lead(LEADER_KEY, &holder, lease_ms).await { + Ok(true) => {} + Ok(false) => continue, + Err(e) => { + tracing::warn!(error = %e, "janitor: leader election failed (skipping pass)"); + continue; + } + } + + let now = u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0); + match state.sessions.janitor_pass(now, ORPHAN_GRACE_SECONDS).await { + Ok((removed, backlog)) => { + removed_total.add(removed, &[]); + backlog_state.store(backlog, Ordering::Relaxed); + if removed > 0 { + tracing::debug!(removed, backlog, "janitor pass trimmed expired members"); + } + } + Err(e) => tracing::warn!(error = %e, "janitor pass failed"), + } + } +} diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index 49adadc0e..2cbd1ea9f 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -28,6 +28,7 @@ mod cookie; mod csrf; mod gear; mod identity; +mod janitor; mod jwt; mod local_client; mod oidc; diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 38b3e14f7..f040e65a0 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -659,6 +659,69 @@ impl SessionManager { Ok(()) } + /// One janitor pass (DESIGN §4.3): trim expired members from every + /// `asm:user_sessions:*` ZSET (`ZREMRANGEBYSCORE 0 now` — per-key TTLs + /// removed the records, the index members linger) and drop long-overdue + /// orphans from the refresh schedule (live sessions are re-scheduled by + /// the refresher; an entry still due after `orphan_grace` has no owner). + /// Returns (removed members, overdue-backlog size before trimming). + /// + /// # Errors + /// Fails on a Redis error. + pub async fn janitor_pass(&self, now: u64, orphan_grace: u64) -> anyhow::Result<(u64, u64)> { + let mut conn = self.conn.clone(); + let now_i = i64::try_from(now).unwrap_or(i64::MAX); + let mut removed = 0u64; + let mut backlog = 0u64; + + // SCAN, never KEYS — bounded batches on a shared Redis. + let mut cursor: u64 = 0; + loop { + let (next, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg("asm:user_sessions:*") + .arg("COUNT") + .arg(100) + .query_async(&mut conn) + .await + .context("scan user-session indexes")?; + for key in keys { + let expired: u64 = conn + .zcount(&key, 0, now_i) + .await + .context("count expired index members")?; + if expired > 0 { + backlog += expired; + let n: u64 = conn + .zrembyscore(&key, 0, now_i) + .await + .context("trim expired index members")?; + removed += n; + } + } + cursor = next; + if cursor == 0 { + break; + } + } + + // Refresh-schedule orphans: overdue by more than the grace window. + let orphan_cutoff = i64::try_from(now.saturating_sub(orphan_grace)).unwrap_or(0); + let overdue: u64 = conn + .zcount(REFRESH_DUE_KEY, 0, now_i) + .await + .context("count overdue refresh entries")?; + backlog += overdue; + let orphans: u64 = conn + .zrembyscore(REFRESH_DUE_KEY, 0, orphan_cutoff) + .await + .context("trim refresh-schedule orphans")?; + removed += orphans; + + Ok((removed, backlog)) + } + // ── Back-channel logout (PRD 5.10) ───────────────────────────────────── /// One-shot replay guard for a back-channel `logout_token` `jti` From 5b980a1ef7d92e0345baf8eb08f713c4489ecf17 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 17:24:07 +0800 Subject: [PATCH 07/10] fix(authenticator): refresher + back-channel robustness (review: H1, H2, M1, M3, M4, L2, L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1: give the OIDC HTTP client a 10s total / 5s connect timeout (reqwest has none by default). A hung IdP connection otherwise outlives the refresher's 30s per-session lock — a second worker re-runs the grant with the same one-time-use refresh token, the IdP burns it → false logout — and holds a semaphore permit forever, wedging the whole refresher. H2: store_idp_refresh and bump_refresh_failures are now atomic Lua guarded on the session still existing. A revoke landing during the IdP round trip could otherwise HSET the deleted session key back into existence as a TTL-less hash holding the freshly-rotated, live IdP refresh token — a permanent, janitor-invisible secret for a logged-out user. store returns false on the revoked-mid-flight race; the caller unschedules. M3: the IdP rotates the grant before we store it, so a store failure loses the new token and the next attempt re-sends the spent one → false logout. Retry the store (3x, 200ms) before giving up. M4: the janitor no longer blind-ZREMRANGEBYSCOREs overdue refresh-due entries — it removes an entry only when its session hash is actually gone. Blind purge would silently kill IdP refresh for live-but-behind sessions after a Redis restore or while the refresher is disabled/wedged. M1: back-channel logout releases the (iss,jti) replay guard if the revoke that followed the claim fails, so the IdP's retry actually revokes instead of getting an idempotent 200 with nothing done. L2: try_lead is now a single compare-and-pexpire Lua (no GET-then-PEXPIRE race that briefly allowed two leaders). L3: next_due_at floored at now + margin/2 so an IdP with ≤margin access-token lifetimes can't be refreshed every tick. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- .../authenticator/src/api/handlers.rs | 17 +- .../services/authenticator/src/oidc.rs | 11 +- .../services/authenticator/src/refresher.rs | 80 ++++++--- .../services/authenticator/src/session.rs | 160 ++++++++++++------ 4 files changed, 190 insertions(+), 78 deletions(-) diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index c864310e7..65b523da3 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -814,6 +814,9 @@ pub struct BackChannelForm { /// (one-shot — a replayed delivery answers 200 without another revoke), then /// revoke the targeted sessions: by `(iss, sid)` via the sid index, or — the /// documented sub-only fallback — everything for that user. +// Linear validate → replay-guard → resolve → revoke flow with per-step error +// mapping; splitting it would scatter the sequence without making it clearer. +#[allow(clippy::too_many_lines)] pub async fn back_channel_logout( Extension(state): Extension>, axum::extract::Form(form): axum::extract::Form, @@ -900,7 +903,19 @@ pub async fn back_channel_logout( ); no_content_ok() } - Err(e) => internal_problem("back_channel_revoke", &e), + Err(e) => { + // Release the replay guard so the IdP's retry actually revokes + // (review M1) — the claim was consumed above, but the revoke did + // not happen. Revoke is idempotent, so re-processing is safe. + if let Err(re) = state + .sessions + .release_logout_jti(state.oidc.issuer(), &claims.jti) + .await + { + tracing::warn!(error = %re, "back-channel: failed to release jti guard after revoke error"); + } + internal_problem("back_channel_revoke", &e) + } } } diff --git a/src/backend/services/authenticator/src/oidc.rs b/src/backend/services/authenticator/src/oidc.rs index 6b864b5cd..c0303c42e 100644 --- a/src/backend/services/authenticator/src/oidc.rs +++ b/src/backend/services/authenticator/src/oidc.rs @@ -86,9 +86,18 @@ impl OidcClient { /// Fails when the underlying `reqwest` client cannot be constructed. pub fn new(idp: &IdpConfig) -> anyhow::Result { // Do not follow redirects: the RP must never chase the IdP's 3xx itself - // (SSRF-safety guidance from the openidconnect docs). + // (SSRF-safety guidance from the openidconnect docs). A total timeout is + // mandatory (reqwest has none by default): the background refresher runs + // each grant under a 30 s per-session lock, so a hung IdP connection + // (half-open TCP, no RST) must fail well before that — otherwise the + // request outlives its lock, a second worker re-runs the grant with the + // same one-time-use refresh token, and the IdP burns it → false logout. + // It also caps semaphore-permit hold time so hung calls can't wedge the + // whole refresher (G5). let http = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) .build() .context("build OIDC HTTP client")?; Ok(Self { diff --git a/src/backend/services/authenticator/src/refresher.rs b/src/backend/services/authenticator/src/refresher.rs index 2cc37415b..88a183925 100644 --- a/src/backend/services/authenticator/src/refresher.rs +++ b/src/backend/services/authenticator/src/refresher.rs @@ -186,6 +186,10 @@ async fn refresh_one(state: &Arc, metrics: &Metrics, session_id: &str) } } +// Linear per-session flow (load → grant → success/invalid_grant/transient), +// each arm with its own store + logging; splitting it would scatter the outcome +// handling without making it clearer. +#[allow(clippy::too_many_lines)] async fn do_refresh( state: &Arc, metrics: &Metrics, @@ -222,15 +226,44 @@ async fn do_refresh( state.cfg.idp.refresh_safety_margin_seconds, state.cfg.idp.refresh_due_jitter_seconds, ); - sessions - .store_idp_refresh( - session_id, - new_refresh_token.as_deref(), - access_expires_at, - next_due, - ) - .await?; - tracing::debug!(session_id, next_due, "idp refresh ok"); + // The IdP has ALREADY rotated the grant; the old token is spent. If + // the store fails now, the next attempt would re-send the spent + // token → invalid_grant → false logout (review M3). So retry the + // store a few times before giving up; the guard returns false only + // when the session was concurrently revoked (then just unschedule). + let mut stored = false; + for attempt in 0..3u32 { + match sessions + .store_idp_refresh( + session_id, + new_refresh_token.as_deref(), + access_expires_at, + next_due, + ) + .await + { + Ok(true) => { + stored = true; + break; + } + Ok(false) => { + // Session revoked mid-flight — nothing to persist. + sessions.unschedule_refresh(session_id).await.ok(); + stored = true; + break; + } + Err(e) if attempt == 2 => { + tracing::error!(error = %e, session_id, "idp refresh: store failed after retries — the rotated token is lost, session will be logged out on the next attempt"); + } + Err(e) => { + tracing::warn!(error = %e, session_id, attempt, "idp refresh store failed, retrying"); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + } + if stored { + tracing::debug!(session_id, next_due, "idp refresh ok"); + } } RefreshOutcome::InvalidGrant(detail) => { metrics.record("invalid_grant"); @@ -249,16 +282,19 @@ async fn do_refresh( } RefreshOutcome::Transient(detail) => { metrics.record("transient"); - let failures = sessions.bump_refresh_failures(session_id).await?; - let retry_at = now + backoff_seconds(failures); - sessions.reschedule_refresh(session_id, retry_at).await?; - tracing::warn!( - session_id, - failures, - retry_at, - detail = %detail, - "idp refresh transient failure: backing off (never revoking)" - ); + // A revoke mid-flight makes bump return None — don't resurrect a + // counter-only zombie or reschedule a dead session. + if let Some(failures) = sessions.bump_refresh_failures(session_id).await? { + let retry_at = now + backoff_seconds(failures); + sessions.reschedule_refresh(session_id, retry_at).await?; + tracing::warn!( + session_id, + failures, + retry_at, + detail = %detail, + "idp refresh transient failure: backing off (never revoking)" + ); + } } } Ok(()) @@ -266,12 +302,16 @@ async fn do_refresh( /// The next schedule entry: `now + (expires_in − margin)`, jittered at write /// (G5). An IdP that reports no lifetime is re-checked one margin from now. +/// Floored at `now + margin/2` (min 5 s) so an IdP issuing very short-lived +/// access tokens (`expires_in ≤ margin`) can't drive a refresh every tick +/// (review L3) — we'd hammer the IdP and never make progress. fn next_due_at(now: u64, expires_in: Option, margin: u64, jitter_window: u64) -> u64 { let base = match expires_in { Some(ttl) => now + ttl.saturating_sub(margin), None => now + margin.max(60), }; - base.saturating_add_signed(jitter(jitter_window)) + let floor = now + (margin / 2).max(5); + base.max(floor).saturating_add_signed(jitter(jitter_window)) } /// Exponential transient backoff: `min(15 << failures, 300)` seconds, jittered. diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index f040e65a0..274946231 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -493,28 +493,27 @@ impl SessionManager { /// # Errors /// Fails on a Redis error (the worker then skips this pass). pub async fn try_lead(&self, key: &str, holder: &str, ttl_ms: u64) -> anyhow::Result { + // Atomic acquire-or-renew: SET NX wins a free lock; otherwise renew the + // TTL **only if we still hold it** — compare-and-pexpire in one script + // so the lock can't expire between a GET and a PEXPIRE and let a stale + // holder extend the new leader's key (brief dual leadership). + const LEAD_LUA: &str = r" + if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return 1 end + if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('PEXPIRE', KEYS[1], ARGV[2]) + return 1 + end + return 0 + "; let mut conn = self.conn.clone(); - let won: Option = redis::cmd("SET") - .arg(key) + let led: i64 = redis::Script::new(LEAD_LUA) + .key(key) .arg(holder) - .arg("NX") - .arg("PX") .arg(ttl_ms.max(1)) - .query_async(&mut conn) + .invoke_async(&mut conn) .await - .context("acquire leader lock")?; - if won.is_some() { - return Ok(true); - } - let current: Option = conn.get(key).await.context("read leader lock")?; - if current.as_deref() == Some(holder) { - let _: bool = conn - .pexpire(key, i64::try_from(ttl_ms).unwrap_or(1)) - .await - .context("renew leader lock")?; - return Ok(true); - } - Ok(false) + .context("acquire/renew leader lock")?; + Ok(led == 1) } /// Sessions due for IdP refresh (`ZRANGEBYSCORE asm:idp_refresh_due 0 now`, @@ -579,7 +578,12 @@ impl SessionManager { /// Persist a successful IdP refresh: rotated refresh token (when the IdP /// returned one), new access-token expiry, reset failure counter, and the - /// next schedule entry — one pipeline. + /// next schedule entry — one atomic Lua step, **guarded on the session + /// still existing**. Returns `false` when the session was revoked while the + /// grant was in flight: without the guard, `HSET` on the deleted key would + /// resurrect a TTL-less hash holding the freshly-rotated (live) IdP refresh + /// token — a permanent, janitor-invisible secret for a logged-out user + /// (review H2). On `false` the caller drops the schedule entry. /// /// # Errors /// Fails on a Redis error. @@ -589,43 +593,49 @@ impl SessionManager { new_refresh_token: Option<&str>, access_expires_at: Option, next_due: u64, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { + // KEYS: session hash, refresh-due ZSET. + // ARGV: session_id, next_due, refresh_token|"", access_exp|"". + const STORE_LUA: &str = r" + if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end + if ARGV[3] ~= '' then redis.call('HSET', KEYS[1], 'idp_refresh_token', ARGV[3]) end + if ARGV[4] ~= '' then redis.call('HSET', KEYS[1], 'idp_access_expires_at', ARGV[4]) end + redis.call('HSET', KEYS[1], 'idp_refresh_failures', '0') + redis.call('ZADD', KEYS[2], ARGV[2], ARGV[1]) + return 1 + "; let mut conn = self.conn.clone(); - let skey = session_key(session_id); - let mut pipe = redis::pipe(); - pipe.atomic(); - if let Some(token) = new_refresh_token { - pipe.hset(&skey, "idp_refresh_token", token).ignore(); - } - if let Some(exp) = access_expires_at { - pipe.hset(&skey, "idp_access_expires_at", exp.to_string()) - .ignore(); - } - pipe.hset(&skey, "idp_refresh_failures", "0").ignore(); - pipe.zadd( - REFRESH_DUE_KEY, - session_id, - i64::try_from(next_due).unwrap_or(i64::MAX), - ) - .ignore(); - pipe.query_async::<()>(&mut conn) + let stored: i64 = redis::Script::new(STORE_LUA) + .key(session_key(session_id)) + .key(REFRESH_DUE_KEY) + .arg(session_id) + .arg(i64::try_from(next_due).unwrap_or(i64::MAX)) + .arg(new_refresh_token.unwrap_or("")) + .arg(access_expires_at.map(|e| e.to_string()).unwrap_or_default()) + .invoke_async(&mut conn) .await - .context("store IdP refresh pipeline")?; - Ok(()) + .context("store IdP refresh (guarded)")?; + Ok(stored == 1) } /// Bump the per-session transient-failure counter; returns the new count - /// (sizes the exponential backoff). + /// (sizes the exponential backoff), or `None` if the session no longer + /// exists (so a revoke mid-flight can't resurrect a counter-only zombie). /// /// # Errors /// Fails on a Redis error. - pub async fn bump_refresh_failures(&self, session_id: &str) -> anyhow::Result { + pub async fn bump_refresh_failures(&self, session_id: &str) -> anyhow::Result> { + const BUMP_LUA: &str = r" + if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end + return redis.call('HINCRBY', KEYS[1], 'idp_refresh_failures', 1) + "; let mut conn = self.conn.clone(); - let failures: i64 = conn - .hincr(session_key(session_id), "idp_refresh_failures", 1) + let failures: i64 = redis::Script::new(BUMP_LUA) + .key(session_key(session_id)) + .invoke_async(&mut conn) .await - .context("bump refresh failures")?; - Ok(u64::try_from(failures).unwrap_or(0)) + .context("bump refresh failures (guarded)")?; + Ok((failures >= 0).then(|| u64::try_from(failures).unwrap_or(0))) } /// Re-schedule a session's next refresh attempt. @@ -706,18 +716,39 @@ impl SessionManager { } } - // Refresh-schedule orphans: overdue by more than the grace window. + // Refresh-schedule orphans: an entry overdue by more than the grace + // window is trimmed **only if its session hash is actually gone** + // (review M4). Blind ZREMRANGEBYSCORE would silently delete live-but- + // behind entries — after a Redis restore from an old backup, or while + // the refresher is disabled/wedged — permanently stopping IdP refresh + // for those sessions with no signal (voiding the G5 guarantee). let orphan_cutoff = i64::try_from(now.saturating_sub(orphan_grace)).unwrap_or(0); - let overdue: u64 = conn - .zcount(REFRESH_DUE_KEY, 0, now_i) + let overdue: Vec = conn + .zrangebyscore(REFRESH_DUE_KEY, 0, now_i) .await - .context("count overdue refresh entries")?; - backlog += overdue; - let orphans: u64 = conn - .zrembyscore(REFRESH_DUE_KEY, 0, orphan_cutoff) - .await - .context("trim refresh-schedule orphans")?; - removed += orphans; + .context("list overdue refresh entries")?; + backlog += overdue.len() as u64; + for sid in &overdue { + // Only past the grace window, and only when the owner is gone. + let score: Option = conn + .zscore(REFRESH_DUE_KEY, sid) + .await + .context("read refresh-due score")?; + if score.is_none_or(|s| s > orphan_cutoff) { + continue; + } + let exists: bool = conn + .exists(session_key(sid)) + .await + .context("check session existence for orphan")?; + if !exists { + let n: u64 = conn + .zrem(REFRESH_DUE_KEY, sid) + .await + .context("trim refresh-schedule orphan")?; + removed += n; + } + } Ok((removed, backlog)) } @@ -749,6 +780,23 @@ impl SessionManager { Ok(set.is_some()) } + /// Release a back-channel `jti` guard (`DEL`). Called when the revoke that + /// followed a first-delivery claim then failed — otherwise the IdP's retry + /// of the same `logout_token` would hit the still-set guard and get an + /// idempotent 200 without ever revoking (review M1). Revoke is idempotent, + /// so re-processing on retry is safe. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn release_logout_jti(&self, iss: &str, jti: &str) -> anyhow::Result<()> { + let mut conn = self.conn.clone(); + let _: i64 = conn + .del(logout_jti_key(iss, jti)) + .await + .context("release logout jti")?; + Ok(()) + } + /// Sessions indexed under a back-channel `(iss, sid)` pair. /// /// # Errors From 8550fa4840109f94dbe15f94b4898b23a941d909 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:50:44 +0800 Subject: [PATCH 08/10] =?UTF-8?q?feat(authenticator):=20layer-2=20rate=20l?= =?UTF-8?q?imiting=20=E2=80=94=20Redis=20token=20buckets=20+=20login-state?= =?UTF-8?q?=20cap=20(step=2010.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway's per-IP limit_req zone stays the coarse flood guard (G8); this is the precise, multi-replica-correct layer in Redis (DESIGN 4.4 / §9.3): - an atomic Lua token bucket keyed by what identifies the caller — the STABLE session id on /auth/refresh (rotation doesn't reset it; default 5-burst, 6/min) and the OIDC state on /auth/callback (default 5-burst, 10/min). Never IP: corporate NAT makes per-IP keys wrong at this layer. - a global live login-state cap on /auth/login (default 1000): pre-auth there is no per-caller key, so the guarded resource is the store itself. A new asm:login_state_live ZSET (score = expiry, maintained in the put/take pipelines, trimmed by the janitor) counts live entries and excess logins get 429 before any state is written — the slow-trickle Redis-exhaustion attack the edge cannot see. Everything is tunable under rate_limit.* (burst 0 disables a bucket). Trips answer 429 problem+json with a quota violation + retry hint. The limiter fails OPEN on a Redis error (the coarse layer still guards; a Redis blip must not become a 429 storm) — auth itself keeps failing closed. e2e: one session's refresh bucket trips past the burst while a second session is unaffected; hammering one bogus callback state flips 400 → 429. All eight e2e loops pass locally. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- .../authenticator/src/api/handlers.rs | 82 +++++++++ .../services/authenticator/src/config.rs | 38 ++++ .../services/authenticator/src/main.rs | 1 + .../services/authenticator/src/ratelimit.rs | 117 ++++++++++++ .../services/authenticator/src/session.rs | 47 ++++- .../authenticator/tests/e2e_ratelimit.rs | 173 ++++++++++++++++++ .../services/authenticator/tests/run-e2e.sh | 4 + 7 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 src/backend/services/authenticator/src/ratelimit.rs create mode 100644 src/backend/services/authenticator/tests/e2e_ratelimit.rs diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 65b523da3..98b70c3e5 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -44,6 +44,22 @@ pub async fn login( ) -> Response { let return_to = sanitize_return_to(params.return_to.as_deref(), &state.cfg.default_return_to); + // Layer-2 cap (DESIGN §4.4): pre-auth there is no per-caller key, so the + // guarded resource is the login-state store itself — refuse before any + // state is written. + let now = now_secs(); + match state.sessions.live_login_states(now).await { + Ok(live) if live >= state.cfg.rate_limit.login_state_max => { + tracing::warn!( + live, + "login-state cap reached: refusing /auth/login with 429" + ); + return too_many_requests("login_state_cap", 30); + } + Ok(_) => {} + Err(e) => return internal_problem("login_state_count", &e), + } + // openidconnect generates the state, nonce, and PKCE pair; we stash the // verifier + nonce under the state key for the callback to replay. let start = match state @@ -65,6 +81,7 @@ pub async fn login( return_to, }, 300, + now, ) .await { @@ -115,6 +132,21 @@ pub async fn callback( .into_response(); }; + // Layer-2 bucket keyed by the presented `state` (DESIGN §4.4): caps how + // often one state value can drive the code-exchange path. Fail open on a + // Redis error — the coarse gateway layer still guards, and the state + // lookup below fails closed anyway. + if !rate_limit_or_open(&state, "callback", &oidc_state, { + crate::ratelimit::BucketSpec { + burst: state.cfg.rate_limit.callback_burst, + per_minute: state.cfg.rate_limit.callback_per_minute, + } + }) + .await + { + return too_many_requests("callback_rate_limited", 10); + } + // Validate state -> recover PKCE verifier + nonce (one-shot). let login_state = match state.sessions.take_login_state(&oidc_state).await { Ok(Some(ls)) => ls, @@ -536,6 +568,19 @@ pub async fn refresh(Extension(state): Extension>, jar: CookieJar) return unauthenticated_clear_cookie(jar); } + // Layer-2 bucket keyed by the stable session (DESIGN §4.4 — never IP: + // corporate NAT makes per-IP keys wrong at the precise layer). + if !rate_limit_or_open(&state, "refresh", &session_id, { + crate::ratelimit::BucketSpec { + burst: state.cfg.rate_limit.refresh_burst, + per_minute: state.cfg.rate_limit.refresh_per_minute, + } + }) + .await + { + return too_many_requests("refresh_rate_limited", 10); + } + // Grace path: the presented token has already been rotated past (the old // mapping lives out its grace TTL). Answer with the current state and the // current cookie value — rotating again would burn the grace guarantee. @@ -1076,6 +1121,43 @@ fn unauthenticated() -> Response { ) } +/// Take a token from a layer-2 bucket; a Redis failure fails OPEN (`true`) — +/// the gateway's coarse layer still guards, and turning a Redis blip into a +/// 429 storm would be a self-inflicted outage. (Auth itself always fails +/// closed; this is only the limiter.) +async fn rate_limit_or_open( + state: &AppState, + class: &str, + key: &str, + spec: crate::ratelimit::BucketSpec, +) -> bool { + match state + .sessions + .rate_limit_take(class, key, spec, now_secs()) + .await + { + Ok(allowed) => { + if !allowed { + tracing::warn!(class, "layer-2 rate limit tripped"); + } + allowed + } + Err(e) => { + tracing::warn!(class, error = %e, "rate limiter unavailable: failing open"); + true + } + } +} + +/// 429 with a quota violation + retry hint (RFC 9457 problem body). +fn too_many_requests(subject: &str, retry_after_seconds: u64) -> Response { + SessionError::resource_exhausted("rate limited") + .with_quota_violation(subject, "too many requests") + .with_quota_violation_retry_after_seconds(retry_after_seconds) + .create() + .into_response() +} + /// 404 that does not distinguish "absent" from "not yours" (no existence oracle). fn not_found(resource: &str) -> Response { SessionError::not_found("session not found") diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index 3ecd055bf..ceadb7bba 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -143,6 +143,41 @@ impl Default for ServiceTokensConfig { } } +/// Layer-2 rate limiting (DESIGN §4.4, G8): the precise, multi-replica-correct +/// guards behind the gateway's coarse per-IP zone. Buckets key on what +/// identifies the caller (session / OIDC state), never IP. A burst of 0 +/// disables that bucket. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct RateLimitConfig { + /// Cap on concurrent live `asm:login_state:*` entries; excess + /// `/auth/login` gets 429 before any state is written (stops a + /// slow-trickle Redis-exhaustion attack the edge cannot see). + pub login_state_max: u64, + /// `/auth/refresh` bucket per session: burst size. + pub refresh_burst: u32, + /// `/auth/refresh` bucket per session: sustained refills per minute. + pub refresh_per_minute: u32, + /// `/auth/callback` bucket per OIDC `state`: burst size. + pub callback_burst: u32, + /// `/auth/callback` bucket per OIDC `state`: sustained refills per minute. + pub callback_per_minute: u32, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + login_state_max: 1000, + // The SPA refreshes about once per 8 min; 5-burst + 6/min absorbs + // multi-tab races and retries with an order of magnitude to spare. + refresh_burst: 5, + refresh_per_minute: 6, + callback_burst: 5, + callback_per_minute: 10, + } + } +} + /// The authenticator gear configuration. Deserialized from /// `gears.authenticator.config`. #[derive(Debug, Clone, Deserialize)] @@ -197,6 +232,8 @@ pub struct AuthenticatorConfig { pub csrf_origins: Vec, /// Janitor pass interval (leader-elected trim of expired index members). pub janitor_interval_seconds: u64, + /// Layer-2 rate limiting knobs (DESIGN §4.4). + pub rate_limit: RateLimitConfig, /// Back-channel logout: tolerated clock skew on the `logout_token`'s `iat` /// (future-dated tokens inside this window are accepted). pub backchannel_clock_skew_seconds: u64, @@ -275,6 +312,7 @@ impl Default for AuthenticatorConfig { default_return_to: "/".to_owned(), csrf_origins: Vec::new(), janitor_interval_seconds: 30, + rate_limit: RateLimitConfig::default(), backchannel_clock_skew_seconds: 60, backchannel_token_max_age_seconds: 300, admin_revoke_roles: vec!["session_admin".to_owned()], diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index 2cbd1ea9f..d797b253f 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -32,6 +32,7 @@ mod janitor; mod jwt; mod local_client; mod oidc; +mod ratelimit; mod refresher; mod service_token; mod session; diff --git a/src/backend/services/authenticator/src/ratelimit.rs b/src/backend/services/authenticator/src/ratelimit.rs new file mode 100644 index 000000000..1276fd36a --- /dev/null +++ b/src/backend/services/authenticator/src/ratelimit.rs @@ -0,0 +1,117 @@ +//! Rate limiting, layer 2 (PRD `nfr-auth-rate-limit`, DESIGN §4.4, G8). +//! +//! The gateway's per-IP `limit_req` zone is the coarse flood guard (layer 1); +//! this is the precise, multi-replica-correct layer in Redis: +//! +//! - a **token bucket** (atomic Lua script) keyed by what actually identifies +//! the caller — the session for `/auth/refresh`, the OIDC `state` for +//! `/auth/callback`. Never IP: corporate NAT makes per-IP keys wrong at the +//! precise layer. +//! - a **global live login-state cap** for `/auth/login`: pre-auth there is +//! no per-caller key, so the guarded resource is the store itself — +//! `asm:login_state_live` (ZSET, score = expiry) counts live entries and +//! excess logins get 429 before any state is written, stopping a +//! slow-trickle Redis-exhaustion attack the edge cannot see. + +use anyhow::Context as _; +use redis::aio::ConnectionManager; + +/// Atomic token-bucket take. KEYS[1] = bucket; ARGV = capacity, +/// refill-per-second, now (epoch seconds), key TTL. Returns 1 when a token +/// was taken, 0 when the bucket is empty. State is one HASH per key, expiring +/// once idle long enough to refill fully. +const TOKEN_BUCKET_LUA: &str = r" +local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') +local capacity = tonumber(ARGV[1]) +local refill = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local tokens = tonumber(data[1]) +local ts = tonumber(data[2]) +if tokens == nil then tokens = capacity end +if ts == nil then ts = now end +if now > ts then + tokens = math.min(capacity, tokens + (now - ts) * refill) +end +local allowed = 0 +if tokens >= 1 then + tokens = tokens - 1 + allowed = 1 +end +redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[4])) +return allowed +"; + +/// One bucket class: capacity (burst) + refill rate. +#[derive(Debug, Clone, Copy)] +pub struct BucketSpec { + pub burst: u32, + pub per_minute: u32, +} + +impl BucketSpec { + fn refill_per_second(self) -> f64 { + f64::from(self.per_minute) / 60.0 + } + + /// Key TTL: long enough to refill from empty, floored at one minute. + fn ttl_seconds(self) -> u64 { + if self.per_minute == 0 { + return 3600; + } + // Whole seconds to refill `burst` at `per_minute` per minute. + (u64::from(self.burst) * 60) + .div_ceil(u64::from(self.per_minute)) + .max(60) + } +} + +/// Take one token from `asm:rl:{class}:{key}`. `Ok(true)` = allowed. +/// A zero/absent spec (burst 0) disables the bucket (always allowed). +/// +/// # Errors +/// Fails on a Redis error — the caller decides fail-open vs fail-closed. +pub async fn take( + conn: &ConnectionManager, + class: &str, + key: &str, + spec: BucketSpec, + now: u64, +) -> anyhow::Result { + if spec.burst == 0 { + return Ok(true); + } + let mut conn = conn.clone(); + let script = redis::Script::new(TOKEN_BUCKET_LUA); + let allowed: i64 = script + .key(format!("asm:rl:{class}:{key}")) + .arg(spec.burst) + .arg(spec.refill_per_second()) + .arg(now) + .arg(spec.ttl_seconds()) + .invoke_async(&mut conn) + .await + .context("token bucket take")?; + Ok(allowed == 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ttl_covers_a_full_refill() { + // burst 5 at 6/min → refill 0.1/s → 50 s to refill, floored to 60. + let spec = BucketSpec { + burst: 5, + per_minute: 6, + }; + assert_eq!(spec.ttl_seconds(), 60); + // burst 60 at 6/min → 600 s. + let spec = BucketSpec { + burst: 60, + per_minute: 6, + }; + assert_eq!(spec.ttl_seconds(), 600); + } +} diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 274946231..c28e136f3 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -175,6 +175,9 @@ fn logout_jti_key(iss: &str, jti: &str) -> String { fn login_state_key(state: &str) -> String { format!("asm:login_state:{state}") } +/// Live login-state index (ZSET, score = expiry) backing the layer-2 cap: +/// counting `asm:login_state:*` cheaply requires an index, not SCAN-per-login. +const LOGIN_STATE_LIVE_KEY: &str = "asm:login_state_live"; fn service_jti_key(service: &str, jti: &str) -> String { format!("asm:svc_jti:{service}:{jti}") } @@ -226,6 +229,7 @@ impl SessionManager { state: &str, ls: &LoginState, ttl_seconds: u64, + now: u64, ) -> anyhow::Result<()> { let mut conn = self.conn.clone(); let key = login_state_key(state); @@ -235,12 +239,45 @@ impl SessionManager { .ignore() .expire(&key, i64::try_from(ttl_seconds).unwrap_or(300)) .ignore() + // Live index (score = expiry) backing the layer-2 login cap. + .zadd( + LOGIN_STATE_LIVE_KEY, + state, + i64::try_from(now + ttl_seconds).unwrap_or(i64::MAX), + ) + .ignore() .query_async::<()>(&mut conn) .await .context("store login state")?; Ok(()) } + /// Count live (unexpired) login states — the layer-2 cap input. + /// + /// # Errors + /// Fails on a Redis error. + pub async fn live_login_states(&self, now: u64) -> anyhow::Result { + let mut conn = self.conn.clone(); + conn.zcount(LOGIN_STATE_LIVE_KEY, format!("({now}"), "+inf") + .await + .context("count live login states") + } + + /// Take one token from the `class`/`key` bucket (layer-2 rate limit). + /// + /// # Errors + /// Fails on a Redis error — callers fail open (the coarse gateway layer + /// still guards) rather than turning a Redis blip into a lockout. + pub async fn rate_limit_take( + &self, + class: &str, + key: &str, + spec: crate::ratelimit::BucketSpec, + now: u64, + ) -> anyhow::Result { + crate::ratelimit::take(&self.conn, class, key, spec, now).await + } + /// Atomically read and delete the login state for `state` (one-shot). /// /// # Errors @@ -250,10 +287,11 @@ impl SessionManager { let mut conn = self.conn.clone(); let key = login_state_key(state); // HGETALL then DEL in one atomic transaction. - let (map, _deleted): (HashMap, i64) = redis::pipe() + let (map, _deleted, _unindexed): (HashMap, i64, i64) = redis::pipe() .atomic() .hgetall(&key) .del(&key) + .zrem(LOGIN_STATE_LIVE_KEY, state) .query_async(&mut conn) .await .context("take login state")?; @@ -716,6 +754,13 @@ impl SessionManager { } } + // Expired login-state index members (the HASH keys expired via TTL). + let stale_states: u64 = conn + .zrembyscore(LOGIN_STATE_LIVE_KEY, 0, now_i) + .await + .context("trim expired login-state index")?; + removed += stale_states; + // Refresh-schedule orphans: an entry overdue by more than the grace // window is trimmed **only if its session hash is actually gone** // (review M4). Blind ZREMRANGEBYSCORE would silently delete live-but- diff --git a/src/backend/services/authenticator/tests/e2e_ratelimit.rs b/src/backend/services/authenticator/tests/e2e_ratelimit.rs new file mode 100644 index 000000000..ff97e17fb --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_ratelimit.rs @@ -0,0 +1,173 @@ +//! End-to-end layer-2 rate limiting (nginx+auth step 10, item 6) against a +//! running authenticator + fakeidp + Redis. +//! +//! ```text +//! AUTH_BASE=http://localhost:8083 \ +//! cargo test -p authenticator --test e2e_ratelimit -- --ignored --nocapture +//! ``` +//! +//! Asserts the Redis token buckets (defaults: refresh 5-burst/6-per-min per +//! session, callback 5-burst/10-per-min per state) answer 429 past the burst, +//! and that the limit keys on the session — a second session is unaffected. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde::Deserialize; + +const COOKIE: &str = "__Host-sid"; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_owned()) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +fn rewrite_host(url: &str) -> String { + match ( + std::env::var("FAKEIDP_REWRITE_FROM"), + std::env::var("FAKEIDP_REWRITE_TO"), + ) { + (Ok(from), Ok(to)) if !from.is_empty() => url.replace(&from, &to), + _ => url.to_owned(), + } +} + +fn cookie_from(resp: &reqwest::Response) -> Option { + for hv in resp.headers().get_all(reqwest::header::SET_COOKIE) { + let raw = hv.to_str().ok()?; + for part in raw.split(';') { + if let Some(v) = part.trim().strip_prefix(&format!("{COOKIE}=")) + && !v.is_empty() + { + return Some(v.to_owned()); + } + } + } + None +} + +async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { + let login = http + .get(format!("{auth_base}/auth/login")) + .send() + .await + .unwrap(); + assert_eq!(login.status(), 302); + let authorize = rewrite_host(login.headers()[reqwest::header::LOCATION].to_str().unwrap()); + let sep = if authorize.contains('?') { '&' } else { '?' }; + let authorized = http + .get(format!("{authorize}{sep}user={user}")) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), 302); + let callback = rewrite_host( + authorized.headers()[reqwest::header::LOCATION] + .to_str() + .unwrap(), + ); + let cb = http.get(&callback).send().await.unwrap(); + assert_eq!(cb.status(), 302); + cookie_from(&cb).expect("callback must set __Host-sid") +} + +async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> String { + #[derive(Deserialize)] + struct CsrfBody { + csrf_token: String, + } + let resp = http + .get(format!("{auth_base}/auth/csrf")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + resp.json::().await.unwrap().csrf_token +} + +/// One refresh attempt; returns (status, rotated cookie when present). +async fn refresh( + http: &reqwest::Client, + auth_base: &str, + token: &str, + csrf: &str, +) -> (u16, Option) { + let resp = http + .post(format!("{auth_base}/auth/refresh")) + .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) + .header("X-CSRF-Token", csrf) + .send() + .await + .unwrap(); + let status = resp.status().as_u16(); + let cookie = cookie_from(&resp); + (status, cookie) +} + +#[tokio::test] +#[ignore = "requires a running authenticator + fakeidp + Redis stack"] +async fn refresh_and_callback_buckets_trip_past_burst() { + let auth_base = env("AUTH_BASE", "http://localhost:8083"); + let test_user = env("E2E_USER", "dev@company.nonpresent"); + let http = client(); + + // 1. Refresh bucket: hammer one session until the (default 5-burst) + // bucket trips. The credential rotates on each 200; the bucket keys on + // the STABLE session id, so rotation does not reset it. + let mut token = login(&http, &auth_base, &test_user).await; + let csrf = get_csrf(&http, &auth_base, &token).await; + let mut tripped_at = None; + for attempt in 1..=8 { + let (status, cookie) = refresh(&http, &auth_base, &token, &csrf).await; + match status { + 200 => token = cookie.expect("200 refresh re-issues the cookie"), + 429 => { + tripped_at = Some(attempt); + break; + } + other => panic!("unexpected refresh status {other} on attempt {attempt}"), + } + } + let tripped_at = tripped_at.expect("the refresh bucket must trip within 8 rapid attempts"); + assert!( + tripped_at > 3, + "the burst must absorb a few legitimate retries, tripped at {tripped_at}" + ); + + // 2. A different session is unaffected (the bucket keys per session). + let other = login(&http, &auth_base, &test_user).await; + let other_csrf = get_csrf(&http, &auth_base, &other).await; + let (status, _) = refresh(&http, &auth_base, &other, &other_csrf).await; + assert_eq!(status, 200, "another session must have its own bucket"); + + // 3. Callback bucket: hammering one (bogus) state flips from 400 + // (unknown state) to 429 once the per-state bucket empties. + let mut saw_429 = false; + for _ in 1..=8 { + let resp = http + .get(format!( + "{auth_base}/auth/callback?code=x&state=rl-e2e-bogus-state" + )) + .send() + .await + .unwrap(); + match resp.status().as_u16() { + 400 => {} + 429 => { + saw_429 = true; + break; + } + other => panic!("unexpected callback status {other}"), + } + } + assert!( + saw_429, + "the per-state callback bucket must trip within 8 attempts" + ); +} diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index 60ac04124..1f499200f 100644 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -117,6 +117,10 @@ AUTH_BASE="http://localhost:$AUTH_PORT" FAKEIDP_PUBLIC="http://localhost:$IDP_PO E2E_USER=dev@company.nonpresent \ cargo test -p authenticator --test e2e_backchannel -- --ignored --nocapture +echo "==> run the layer-2 rate-limit loop (step 10.6)" +AUTH_BASE="http://localhost:$AUTH_PORT" E2E_USER=dev@company.nonpresent \ + cargo test -p authenticator --test e2e_ratelimit -- --ignored --nocapture + echo "==> run the IdP background-refresher loop (step 10.4: outage + invalid_grant)" AUTH_BASE="http://localhost:$AUTH_PORT" FAKEIDP_PUBLIC="http://localhost:$IDP_PORT" \ cargo test -p authenticator --test e2e_refresher -- --ignored --nocapture From 80c62ffc27f02f3af95386e6d23ae4f7d000c97d Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 16:59:38 +0800 Subject: [PATCH 09/10] feat(authenticator): audit events to the platform Redpanda topic (step 10.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New audit emitter (DESIGN 3.2 'Audit Emitter', PRD nfr-auth-audit): every auth-relevant action publishes to insight.audit.events with the platform envelope — schema tag insight.audit.event.v1, event_id (UUIDv7), RFC3339-ms timestamp, correlation_id (the gateway's X-Correlation-Id when the request carried one, else the event id), tenant_id, actor person/ip/user-agent, service=authenticator, category=auth, action, outcome, resource, details — field names mirroring the Audit Service's ClickHouse events schema. Covered actions: login success/failure (unknown person), session_refresh, logout, session_revoke (single / all / admin-by-user with the acting subject), back_channel_logout (with the sub-only-fallback marker), idp_refresh_invalid_grant kills, and service_token_issued. Publishing never touches auth latency or availability: emit() drops the event into a bounded channel; a background task owns the rdkafka FutureProducer (Kafka-compatible rdkafka API only — the backend PRD's Redpanda-to-Kafka migration constraint; vendored librdkafka via cmake-build, cmake added to the builder image). Queue-full or delivery failure drops the event and bumps auth_audit_dropped_total; with no audit.brokers configured the emitter is disabled and events remain in the structured log (the existing target:audit lines stay as the operator-facing trace). Wiring: audit.brokers/audit.topic config (empty = disabled), compose sets redpanda:9092, the subchart gains audit.* values, and the umbrella folds the global redpanda.brokers into the authenticator config Secret. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- charts/insight/templates/secrets.yaml | 3 + docker-compose.yml | 3 + src/backend/Cargo.lock | 66 +++++ src/backend/services/authenticator/Cargo.toml | 3 + src/backend/services/authenticator/Dockerfile | 5 +- .../helm/templates/configmap.yaml | 4 + .../services/authenticator/helm/values.yaml | 7 + .../authenticator/src/api/handlers.rs | 152 +++++++++- .../services/authenticator/src/api/mod.rs | 2 + .../services/authenticator/src/audit.rs | 262 ++++++++++++++++++ .../services/authenticator/src/config.rs | 23 ++ .../services/authenticator/src/gear.rs | 5 + .../services/authenticator/src/main.rs | 1 + .../services/authenticator/src/refresher.rs | 12 + .../authenticator/src/service_token.rs | 15 + 15 files changed, 555 insertions(+), 8 deletions(-) create mode 100644 src/backend/services/authenticator/src/audit.rs diff --git a/charts/insight/templates/secrets.yaml b/charts/insight/templates/secrets.yaml index bd2ca6b70..ac7db3ba9 100644 --- a/charts/insight/templates/secrets.yaml +++ b/charts/insight/templates/secrets.yaml @@ -205,6 +205,9 @@ stringData: APP__gears__authenticator__config__redirect_uri: {{ tpl (required "authenticator.oidc.redirectUri is required" .Values.authenticator.oidc.redirectUri) . | quote }} # Service-token endpoint audience callers sign assertions for. APP__gears__authenticator__config__service_tokens__audience: {{ printf "http://%s-authenticator.%s.svc.cluster.local:8093/internal/token" .Release.Name .Release.Namespace | quote }} + # Audit events to the platform Redpanda topic (step 10.8). Empty = disabled + # (structured log only) — the global redpanda.brokers wires it when set. + APP__gears__authenticator__config__audit__brokers: {{ .Values.redpanda.brokers | default "" | quote }} {{- if .Values.identity.deploy }} --- diff --git a/docker-compose.yml b/docker-compose.yml index a9a3ac76e..033a0a8fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -318,6 +318,9 @@ services: # Resolves the testclient public_key_paths against the mounted dev keys # (dev-compose.sh generates testclient.pub.pem there; nothing committed). APP__gears__authenticator__config__service_tokens__public_key_dir: "/app/keys" + # Audit events to the platform topic (step 10.8). Delivery failures are + # dropped + counted, so a stack without Redpanda still works. + APP__gears__authenticator__config__audit__brokers: "${AUDIT_BROKERS:-redpanda:9092}" volumes: - ./deploy/compose/build/authenticator/authenticator:/app/authenticator:ro - ./src/backend/services/authenticator/config:/app/config:ro diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 612e5ca1e..2a30297ee 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -307,6 +307,7 @@ dependencies = [ "opentelemetry", "p256 0.14.0", "rand 0.8.6", + "rdkafka", "redis", "reqwest 0.12.28", "serde", @@ -3343,6 +3344,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3677,6 +3690,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -4622,6 +4657,37 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rdkafka" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1856d72dbbbea0d2a5b2eaf6af7fb3847ef2746e883b11781446a51dbc85c0" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "log", + "rdkafka-sys", + "serde", + "serde_derive", + "serde_json", + "slab", + "tokio", +] + +[[package]] +name = "rdkafka-sys" +version = "4.10.0+2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234cf318915c1059d4921ef7f75616b5219b10b46e9f3a511a15eb4b56a3f77" +dependencies = [ + "cmake", + "libc", + "libz-sys", + "num_enum", + "pkg-config", +] + [[package]] name = "redis" version = "0.27.6" diff --git a/src/backend/services/authenticator/Cargo.toml b/src/backend/services/authenticator/Cargo.toml index 88b71c7d2..1cfa0d5cc 100644 --- a/src/backend/services/authenticator/Cargo.toml +++ b/src/backend/services/authenticator/Cargo.toml @@ -53,6 +53,9 @@ uuid = { workspace = true, features = ["v5"] } chrono = { workspace = true } tracing = { workspace = true } opentelemetry = "0.31" +# Audit events to the platform Redpanda topic (Kafka-compatible rdkafka API +# only — backend PRD migration constraint). cmake-build vendors librdkafka. +rdkafka = { version = "0.38", features = ["cmake-build", "tokio"] } tracing-subscriber = { workspace = true } clap = { workspace = true } reqwest = { workspace = true } diff --git a/src/backend/services/authenticator/Dockerfile b/src/backend/services/authenticator/Dockerfile index 114b16e9a..ad44e84ab 100644 --- a/src/backend/services/authenticator/Dockerfile +++ b/src/backend/services/authenticator/Dockerfile @@ -6,9 +6,10 @@ # Stage 1: Builder FROM rust:1.95-bookworm AS builder -# protobuf-compiler is required by grpc-hub -> prost-build. +# protobuf-compiler is required by grpc-hub -> prost-build; cmake builds the +# vendored librdkafka (audit events to Redpanda, step 10.8). RUN apt-get update && \ - apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev && \ + apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev cmake && \ rm -rf /var/lib/apt/lists/* WORKDIR /build diff --git a/src/backend/services/authenticator/helm/templates/configmap.yaml b/src/backend/services/authenticator/helm/templates/configmap.yaml index 010d1c181..96a4d5bef 100644 --- a/src/backend/services/authenticator/helm/templates/configmap.yaml +++ b/src/backend/services/authenticator/helm/templates/configmap.yaml @@ -123,6 +123,10 @@ data: authenticator: config: signing_keys_path: {{ .Values.signingKeysPath | quote }} + # Audit events to the platform Redpanda topic (PRD nfr-auth-audit). + audit: + brokers: {{ .Values.audit.brokers | quote }} + topic: {{ .Values.audit.topic | quote }} # CSRF Origin-allowlist fallback for state-changing /auth/* requests; # empty = fail closed (X-CSRF-Token required). {{- with .Values.csrfOrigins }} diff --git a/src/backend/services/authenticator/helm/values.yaml b/src/backend/services/authenticator/helm/values.yaml index a1ee6816d..f00cb079c 100644 --- a/src/backend/services/authenticator/helm/values.yaml +++ b/src/backend/services/authenticator/helm/values.yaml @@ -44,6 +44,13 @@ serviceTokens: # the transition. csrfOrigins: [] +# Audit publishing (PRD nfr-auth-audit): auth events to the platform Redpanda +# topic. Empty brokers (default) disables publishing — events stay in the +# structured log. The umbrella wires the bundled Redpanda. +audit: + brokers: "" + topic: "insight.audit.events" + resources: requests: cpu: 50m diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 98b70c3e5..77edd774c 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -21,6 +21,7 @@ use uuid::Uuid; use crate::api::AppState; use crate::api::error::{OidcError, PersonError, SessionError}; +use crate::audit::AuditEvent; use crate::cookie; use crate::identity::PersonResolution; use crate::jwt::GatewayClaims; @@ -204,6 +205,22 @@ pub async fn callback( email = %idp.identity.email, "login denied: no matching person in Identity" ); + let client = ClientInfo::from_headers(&headers); + state.audit.emit(AuditEvent { + action: "login", + outcome: "failure", + tenant_id: idp.identity.tenant_id.clone(), + actor_person_id: String::new(), + actor_ip: client.ip, + actor_user_agent: client.user_agent, + correlation_id: correlation_id(&headers), + resource_type: "session", + resource_id: String::new(), + details: serde_json::json!({ + "reason": "unknown_person", + "idp_sub": idp.identity.sub, + }), + }); return PersonError::permission_denied() .with_reason("unknown_person") .create() @@ -216,7 +233,19 @@ pub async fn callback( let return_to = login_state.return_to.clone(); let client = ClientInfo::from_headers(&headers); match mint_and_store_session(&state, &idp, &resolution, &client).await { - Ok(token) => { + Ok((session_id, token)) => { + state.audit.emit(AuditEvent { + action: "login", + outcome: "success", + tenant_id: resolution.tenant_id.clone(), + actor_person_id: resolution.person_id.clone(), + actor_ip: client.ip, + actor_user_agent: client.user_agent, + correlation_id: correlation_id(&headers), + resource_type: "session", + resource_id: session_id, + details: serde_json::json!({ "idp_sub": idp.identity.sub }), + }); let jar = jar.add(cookie::session_cookie( &token, state.cfg.session_ttl_seconds, @@ -262,6 +291,38 @@ impl ClientInfo { } } +/// The gateway-minted request correlation id (edge Lua, `X-Correlation-Id`). +fn correlation_id(headers: &axum::http::HeaderMap) -> String { + headers + .get("x-correlation-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() +} + +/// An audit event attributed to a live session record. +fn session_audit( + action: &'static str, + outcome: &'static str, + record: &SessionRecord, + resource_id: &str, + correlation_id: String, + details: serde_json::Value, +) -> AuditEvent { + AuditEvent { + action, + outcome, + tenant_id: record.tenant_id.clone(), + actor_person_id: record.person_id.clone(), + actor_ip: record.ip.clone(), + actor_user_agent: record.user_agent.clone(), + correlation_id, + resource_type: "session", + resource_id: resource_id.to_owned(), + details, + } +} + /// Build claims, sign the linked JWT, and persist the session in one pipeline. /// Returns the cookie token. async fn mint_and_store_session( @@ -269,7 +330,7 @@ async fn mint_and_store_session( idp: &crate::oidc::AuthenticatedIdp, resolution: &PersonResolution, client: &ClientInfo, -) -> anyhow::Result { +) -> anyhow::Result<(String, String)> { let now = now_secs(); let cfg = &state.cfg; let expires_at = now + cfg.session_ttl_seconds; @@ -350,7 +411,7 @@ async fn mint_and_store_session( state .sessions .create_session(&NewSession { - session_id, + session_id: session_id.clone(), token: token.clone(), record, jwt, @@ -359,7 +420,7 @@ async fn mint_and_store_session( }) .await?; - Ok(token) + Ok((session_id, token)) } // ── /internal/authz ───────────────────────────────────────────────────────── @@ -554,7 +615,11 @@ pub async fn csrf(Extension(state): Extension>, jar: CookieJar) -> /// stable `session_id` and the linked JWT are untouched. A stale token still /// inside the grace window resolves to the same session and is answered with /// the current state, no second rotation; past grace → 401 + clear cookie. -pub async fn refresh(Extension(state): Extension>, jar: CookieJar) -> Response { +pub async fn refresh( + Extension(state): Extension>, + jar: CookieJar, + headers: axum::http::HeaderMap, +) -> Response { let Some(token) = cookie::read(&jar) else { return unauthenticated_clear_cookie(jar); }; @@ -620,6 +685,14 @@ pub async fn refresh(Extension(state): Extension>, jar: CookieJar) }; } tracing::debug!(session_id = %session_id, expires_at = new_expires_at, "session refreshed (credential rotated)"); + state.audit.emit(session_audit( + "session_refresh", + "success", + &record, + &session_id, + correlation_id(&headers), + serde_json::json!({ "expires_at": new_expires_at }), + )); refresh_ok(&state, jar, &new_token, new_expires_at, now) } @@ -648,7 +721,11 @@ fn refresh_ok( // ── /auth/logout ───────────────────────────────────────────────────────────── /// Revoke the session, clear the cookie, and return the RP-logout URL. -pub async fn logout(Extension(state): Extension>, jar: CookieJar) -> Response { +pub async fn logout( + Extension(state): Extension>, + jar: CookieJar, + headers: axum::http::HeaderMap, +) -> Response { let mut rp_logout_url = serde_json::Value::Null; if let Some(token) = cookie::read(&jar) @@ -656,6 +733,14 @@ pub async fn logout(Extension(state): Extension>, jar: CookieJar) { let _ = state.sessions.revoke_session(&session_id).await; tracing::info!(session_id = %session_id, "logout: session revoked"); + state.audit.emit(session_audit( + "logout", + "success", + &record, + &session_id, + correlation_id(&headers), + serde_json::json!({}), + )); if let Some(url) = state .oidc .rp_logout_url(&record.id_token, &state.cfg.default_return_to) @@ -723,6 +808,7 @@ pub async fn sessions_list(Extension(state): Extension>, jar: Cook pub async fn sessions_revoke_one( Extension(state): Extension>, jar: CookieJar, + headers: axum::http::HeaderMap, axum::extract::Path(target_id): axum::extract::Path, ) -> Response { let Some(token) = cookie::read(&jar) else { @@ -755,6 +841,14 @@ pub async fn sessions_revoke_one( by = "self", "session revoked" ); + state.audit.emit(session_audit( + "session_revoke", + "success", + &record, + &target_id, + correlation_id(&headers), + serde_json::json!({ "by": "self", "scope": "single" }), + )); let resp = json_ok(serde_json::json!({ "revoked": 1 }).to_string()); if target_id == current_id { @@ -768,6 +862,7 @@ pub async fn sessions_revoke_one( pub async fn sessions_revoke_all( Extension(state): Extension>, jar: CookieJar, + headers: axum::http::HeaderMap, ) -> Response { let Some(token) = cookie::read(&jar) else { return unauthenticated(); @@ -790,6 +885,14 @@ pub async fn sessions_revoke_all( by = "self", "all sessions revoked" ); + state.audit.emit(session_audit( + "session_revoke", + "success", + &record, + &record.person_id.clone(), + correlation_id(&headers), + serde_json::json!({ "by": "self", "scope": "all", "revoked": revoked }), + )); let resp = json_ok(serde_json::json!({ "revoked": revoked }).to_string()); (jar.add(cookie::clear_cookie()), resp).into_response() } @@ -803,6 +906,7 @@ pub async fn sessions_revoke_all( pub async fn admin_revoke_user_sessions( Extension(state): Extension>, Extension(ctx): Extension, + headers: axum::http::HeaderMap, axum::extract::Path(person_id): axum::extract::Path, ) -> Response { let allowed = ctx @@ -840,6 +944,23 @@ pub async fn admin_revoke_user_sessions( subject_type = ctx.subject_type().unwrap_or(""), "all sessions revoked (admin)" ); + state.audit.emit(AuditEvent { + action: "session_revoke", + outcome: "success", + tenant_id: ctx.subject_tenant_id().to_string(), + actor_person_id: ctx.subject_id().to_string(), + actor_ip: String::new(), + actor_user_agent: String::new(), + correlation_id: correlation_id(&headers), + resource_type: "session", + resource_id: person_id.to_string(), + details: serde_json::json!({ + "by": "admin", + "scope": "all", + "revoked": revoked, + "subject_type": ctx.subject_type().unwrap_or(""), + }), + }); json_ok(serde_json::json!({ "revoked": revoked }).to_string()) } Err(e) => e.into_response(), @@ -946,6 +1067,25 @@ pub async fn back_channel_logout( revoked, "back-channel logout processed" ); + state.audit.emit(AuditEvent { + action: "back_channel_logout", + outcome: "success", + tenant_id: String::new(), + actor_person_id: String::new(), + actor_ip: String::new(), + actor_user_agent: String::new(), + correlation_id: String::new(), + resource_type: "session", + resource_id: claims + .sid + .clone() + .or(claims.sub.clone()) + .unwrap_or_default(), + details: serde_json::json!({ + "revoked": revoked, + "sub_only_fallback": claims.sid.is_none(), + }), + }); no_content_ok() } Err(e) => { diff --git a/src/backend/services/authenticator/src/api/mod.rs b/src/backend/services/authenticator/src/api/mod.rs index 6adf58299..d7c691841 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -31,6 +31,8 @@ pub struct AppState { /// revoke-by-user operation goes through it, so the HTTP surface and /// in-process consumers (the future permissions service) share one path. pub authn_client: Arc, + /// Audit publisher (Redpanda; no-op when unconfigured). + pub audit: crate::audit::AuditEmitter, } /// Register the authenticator routes onto the host router. The `Extension` diff --git a/src/backend/services/authenticator/src/audit.rs b/src/backend/services/authenticator/src/audit.rs new file mode 100644 index 000000000..1ef2bc55c --- /dev/null +++ b/src/backend/services/authenticator/src/audit.rs @@ -0,0 +1,262 @@ +//! Audit event emitter (PRD `nfr-auth-audit`, DESIGN §3.2 "Audit Emitter"). +//! +//! Every auth-relevant action lands on the platform audit topic +//! (`insight.audit.events`, Redpanda) with the platform envelope (backend +//! DESIGN §3.8: JSON, versioned, `tenant_id` + `timestamp` + +//! `correlation_id` on every message; field set mirrors the Audit Service's +//! ClickHouse `insight_audit.events` schema). The Audit Service consumes and +//! stores; this side only publishes. +//! +//! Publishing is strictly non-blocking for the auth paths: `emit` drops the +//! event into a bounded channel and returns; a background task owns the +//! rdkafka producer. A full channel or a broker outage drops events (counted +//! by `auth_audit_dropped_total`) — auth availability is never coupled to +//! Redpanda availability. With no brokers configured the emitter is disabled +//! (dev stacks without Redpanda), and events still appear in the structured +//! log via the existing `target: "audit"` lines at the call sites. + +use opentelemetry::metrics::Counter; +use rdkafka::ClientConfig; +use rdkafka::producer::{FutureProducer, FutureRecord}; +use serde::Serialize; +use tokio::sync::mpsc; + +/// The audit envelope version tag. +const SCHEMA: &str = "insight.audit.event.v1"; +/// Producer-side delivery timeout per event. +const SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +/// Bounded queue between auth paths and the producer task. +const QUEUE_DEPTH: usize = 1024; + +/// One auth audit event, as produced by the call sites. The emitter wraps it +/// in the platform envelope (event id, timestamp, service, category, schema). +#[derive(Debug)] +pub struct AuditEvent { + /// `login`, `session_refresh`, `logout`, `session_revoke`, + /// `back_channel_logout`, `idp_refresh_invalid_grant`, + /// `service_token_issued`, … + pub action: &'static str, + /// `success` | `failure`. + pub outcome: &'static str, + /// The signed tenant (empty when unresolved, e.g. a failed login). + pub tenant_id: String, + /// Internal person id (empty for service principals / failed logins). + pub actor_person_id: String, + pub actor_ip: String, + pub actor_user_agent: String, + /// Request correlation id (gateway `X-Correlation-Id`); empty → the + /// envelope's `event_id` doubles as the correlation id. + pub correlation_id: String, + /// `session`, `service_token`, … + pub resource_type: &'static str, + /// Stable id of the acted-on resource (session_id, service name, …). + pub resource_id: String, + /// Free-form context (reason codes, counts) — serialized into `details`. + pub details: serde_json::Value, +} + +/// The wire envelope (JSON) — field names mirror the Audit Service schema. +#[derive(Debug, Serialize)] +struct Envelope { + schema: &'static str, + event_id: String, + timestamp: String, + correlation_id: String, + tenant_id: String, + actor_person_id: String, + actor_ip: String, + actor_user_agent: String, + service: &'static str, + action: &'static str, + category: &'static str, + outcome: &'static str, + resource_type: &'static str, + resource_id: String, + details: String, +} + +/// Build the platform envelope for one event (pure; unit-tested). +fn envelope(event: &AuditEvent, event_id: String, timestamp: String) -> Envelope { + let correlation_id = if event.correlation_id.is_empty() { + event_id.clone() + } else { + event.correlation_id.clone() + }; + Envelope { + schema: SCHEMA, + event_id, + timestamp, + correlation_id, + tenant_id: event.tenant_id.clone(), + actor_person_id: event.actor_person_id.clone(), + actor_ip: event.actor_ip.clone(), + actor_user_agent: event.actor_user_agent.clone(), + service: "authenticator", + action: event.action, + category: "auth", + outcome: event.outcome, + resource_type: event.resource_type, + resource_id: event.resource_id.clone(), + details: event.details.to_string(), + } +} + +/// Cheap-to-clone handle; the producer lives in the background task. +#[derive(Clone)] +pub struct AuditEmitter { + tx: Option>, + dropped: Counter, +} + +impl AuditEmitter { + /// Build the emitter. Empty `brokers` = disabled (a no-op handle). + /// + /// # Errors + /// Fails when the Kafka producer cannot be constructed from the config + /// (malformed broker list) — a misconfigured audit sink should fail the + /// gear at boot, not silently drop every event. + pub fn new(brokers: &str, topic: &str) -> anyhow::Result { + let meter = opentelemetry::global::meter("authenticator.audit"); + let dropped = meter + .u64_counter("auth_audit_dropped_total") + .with_description("Audit events dropped (queue full or delivery failure)") + .build(); + + if brokers.trim().is_empty() { + tracing::warn!( + "audit emitter disabled: no audit.brokers configured \ + (events remain in the structured log only)" + ); + return Ok(Self { tx: None, dropped }); + } + + let producer: FutureProducer = ClientConfig::new() + .set("bootstrap.servers", brokers) + .set("message.timeout.ms", "5000") + // Audit is compliance data: require the leader ack, retry inside + // the client, keep ordering per key. + .set("acks", "1") + .create() + .map_err(|e| anyhow::anyhow!("build audit producer for '{brokers}': {e}"))?; + + let (tx, mut rx) = mpsc::channel::(QUEUE_DEPTH); + let topic = topic.to_owned(); + let topic_for_log = topic.clone(); + let dropped_in_task = dropped.clone(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let env = envelope( + &event, + uuid::Uuid::now_v7().to_string(), + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + ); + let Ok(payload) = serde_json::to_vec(&env) else { + continue; + }; + // Key by tenant: per-tenant ordering, balanced partitions. + let record = FutureRecord::to(&topic) + .key(&env.tenant_id) + .payload(&payload); + if let Err((e, _)) = producer.send(record, SEND_TIMEOUT).await { + dropped_in_task.add(1, &[]); + tracing::warn!(error = %e, action = env.action, "audit event delivery failed (dropped)"); + } + } + }); + tracing::info!(%brokers, topic = %topic_for_log, "audit emitter started"); + Ok(Self { + tx: Some(tx), + dropped, + }) + } + + /// A disabled emitter (tests / tooling). + #[cfg(test)] + #[must_use] + pub fn disabled() -> Self { + let meter = opentelemetry::global::meter("authenticator.audit"); + Self { + tx: None, + dropped: meter.u64_counter("auth_audit_dropped_total").build(), + } + } + + /// Queue one event; never blocks and never fails the caller. + pub fn emit(&self, event: AuditEvent) { + let Some(tx) = &self.tx else { return }; + if let Err(e) = tx.try_send(event) { + self.dropped.add(1, &[]); + tracing::warn!(error = %e, "audit queue full: event dropped"); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn event() -> AuditEvent { + AuditEvent { + action: "login", + outcome: "success", + tenant_id: "t-1".to_owned(), + actor_person_id: "p-1".to_owned(), + actor_ip: "10.0.0.1".to_owned(), + actor_user_agent: "ua".to_owned(), + correlation_id: String::new(), + resource_type: "session", + resource_id: "s-1".to_owned(), + details: serde_json::json!({"idp_sub": "sub-1"}), + } + } + + #[test] + fn envelope_carries_the_platform_fields() { + let env = envelope( + &event(), + "evt-1".to_owned(), + "2026-01-01T00:00:00.000Z".to_owned(), + ); + let json = serde_json::to_value(&env).unwrap(); + for field in [ + "schema", + "event_id", + "timestamp", + "correlation_id", + "tenant_id", + "actor_person_id", + "actor_ip", + "actor_user_agent", + "service", + "action", + "category", + "outcome", + "resource_type", + "resource_id", + "details", + ] { + assert!(json.get(field).is_some(), "missing envelope field {field}"); + } + assert_eq!(json["schema"], SCHEMA); + assert_eq!(json["service"], "authenticator"); + assert_eq!(json["category"], "auth"); + // No explicit correlation id → the event id doubles as one. + assert_eq!(json["correlation_id"], "evt-1"); + // details is a JSON *string* (the ClickHouse column is String). + assert!(json["details"].is_string()); + } + + #[test] + fn explicit_correlation_id_wins() { + let mut e = event(); + e.correlation_id = "corr-9".to_owned(); + let env = envelope(&e, "evt-1".to_owned(), "t".to_owned()); + assert_eq!(env.correlation_id, "corr-9"); + } + + #[test] + fn disabled_emitter_swallows_events() { + AuditEmitter::disabled().emit(event()); + } +} diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index ceadb7bba..bd7baed70 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -143,6 +143,26 @@ impl Default for ServiceTokensConfig { } } +/// Audit publishing (PRD `nfr-auth-audit`): the Redpanda sink for auth events. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AuditConfig { + /// Kafka-compatible bootstrap servers (`host:port[,host:port]`). Empty + /// (default) disables publishing — events stay in the structured log. + pub brokers: String, + /// The platform audit topic. + pub topic: String, +} + +impl Default for AuditConfig { + fn default() -> Self { + Self { + brokers: String::new(), + topic: "insight.audit.events".to_owned(), + } + } +} + /// Layer-2 rate limiting (DESIGN §4.4, G8): the precise, multi-replica-correct /// guards behind the gateway's coarse per-IP zone. Buckets key on what /// identifies the caller (session / OIDC state), never IP. A burst of 0 @@ -234,6 +254,8 @@ pub struct AuthenticatorConfig { pub janitor_interval_seconds: u64, /// Layer-2 rate limiting knobs (DESIGN §4.4). pub rate_limit: RateLimitConfig, + /// Audit publishing (Redpanda). + pub audit: AuditConfig, /// Back-channel logout: tolerated clock skew on the `logout_token`'s `iat` /// (future-dated tokens inside this window are accepted). pub backchannel_clock_skew_seconds: u64, @@ -313,6 +335,7 @@ impl Default for AuthenticatorConfig { csrf_origins: Vec::new(), janitor_interval_seconds: 30, rate_limit: RateLimitConfig::default(), + audit: AuditConfig::default(), backchannel_clock_skew_seconds: 60, backchannel_token_max_age_seconds: 300, admin_revoke_roles: vec!["session_admin".to_owned()], diff --git a/src/backend/services/authenticator/src/gear.rs b/src/backend/services/authenticator/src/gear.rs index dfd7100f4..2cb03fbf0 100644 --- a/src/backend/services/authenticator/src/gear.rs +++ b/src/backend/services/authenticator/src/gear.rs @@ -91,6 +91,10 @@ impl Gear for AuthenticatorGear { ctx.client_hub() .register::(authn_client.clone()); + // Audit sink (PRD nfr-auth-audit). Fails the gear on a malformed + // broker config; unconfigured = disabled (structured log only). + let audit = crate::audit::AuditEmitter::new(&cfg.audit.brokers, &cfg.audit.topic)?; + let state = Arc::new(AppState { cfg, sessions, @@ -99,6 +103,7 @@ impl Gear for AuthenticatorGear { resolver, service_registry, authn_client, + audit, }); self.state .set(state) diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index d797b253f..3c5969b52 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -22,6 +22,7 @@ #![allow(clippy::doc_markdown)] mod api; +mod audit; mod backchannel; mod config; mod cookie; diff --git a/src/backend/services/authenticator/src/refresher.rs b/src/backend/services/authenticator/src/refresher.rs index 88a183925..75b151e1d 100644 --- a/src/backend/services/authenticator/src/refresher.rs +++ b/src/backend/services/authenticator/src/refresher.rs @@ -279,6 +279,18 @@ async fn do_refresh( detail = %detail, "IdP refused the refresh grant definitively: session revoked" ); + state.audit.emit(crate::audit::AuditEvent { + action: "idp_refresh_invalid_grant", + outcome: "success", + tenant_id: record.tenant_id.clone(), + actor_person_id: record.person_id.clone(), + actor_ip: String::new(), + actor_user_agent: String::new(), + correlation_id: String::new(), + resource_type: "session", + resource_id: session_id.to_owned(), + details: serde_json::json!({ "detail": detail }), + }); } RefreshOutcome::Transient(detail) => { metrics.record("transient"); diff --git a/src/backend/services/authenticator/src/service_token.rs b/src/backend/services/authenticator/src/service_token.rs index b76812036..9ef5f3632 100644 --- a/src/backend/services/authenticator/src/service_token.rs +++ b/src/backend/services/authenticator/src/service_token.rs @@ -398,6 +398,21 @@ async fn token_handler( tenant_id = %claims.tenant_id, "service token issued" ); + state.audit.emit(crate::audit::AuditEvent { + action: "service_token_issued", + outcome: "success", + tenant_id: claims.tenant_id.clone(), + actor_person_id: String::new(), + actor_ip: String::new(), + actor_user_agent: String::new(), + correlation_id: String::new(), + resource_type: "service_token", + resource_id: verified.service.clone(), + details: serde_json::json!({ + "roles": claims.roles, + "jti": verified.jti, + }), + }); Json(TokenResponse { access_token: jwt, From f498a08dcbd6da769dc96eaa18c941fd6b1310d4 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Wed, 22 Jul 2026 17:29:08 +0800 Subject: [PATCH 10/10] fix(authenticator): rate-limit key hardening + clock-backstep guard (review: L4, L5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L4: SHA-256-hash the token-bucket key component before use, so an attacker- chosen key (the OIDC state on /auth/callback, created before state validation) is bounded to a fixed-width digest — it can't inflate Redis with long keys or smuggle control chars. L5: clamp the bucket's stored timestamp forward (ts = max(ts, now)) so a clock step-back (NTP correction) or multi-pod skew can't re-add the skipped window and over-refill. EPIC: constructorfabric/insight#1583 (step 10, #1593) Signed-off-by: Anton Zelenov --- .../services/authenticator/src/ratelimit.rs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/backend/services/authenticator/src/ratelimit.rs b/src/backend/services/authenticator/src/ratelimit.rs index 1276fd36a..2d2749176 100644 --- a/src/backend/services/authenticator/src/ratelimit.rs +++ b/src/backend/services/authenticator/src/ratelimit.rs @@ -29,6 +29,10 @@ local tokens = tonumber(data[1]) local ts = tonumber(data[2]) if tokens == nil then tokens = capacity end if ts == nil then ts = now end +-- Never let ts move backwards: on a clock step-back (NTP correction) or +-- multi-pod skew, `now < ts` would otherwise re-add the skipped window next +-- time and over-refill. Clamp to the newest timestamp seen (review L5). +if now < ts then now = ts end if now > ts then tokens = math.min(capacity, tokens + (now - ts) * refill) end @@ -66,9 +70,14 @@ impl BucketSpec { } } -/// Take one token from `asm:rl:{class}:{key}`. `Ok(true)` = allowed. +/// Take one token from `asm:rl:{class}:{key-digest}`. `Ok(true)` = allowed. /// A zero/absent spec (burst 0) disables the bucket (always allowed). /// +/// The key component is SHA-256-hashed (hex) before use, so an attacker-chosen +/// `key` (e.g. the OIDC `state` on `/auth/callback`) is bounded to a +/// fixed-width digest — it cannot inflate Redis with arbitrarily long keys or +/// smuggle control characters (review L4). +/// /// # Errors /// Fails on a Redis error — the caller decides fail-open vs fail-closed. pub async fn take( @@ -81,10 +90,12 @@ pub async fn take( if spec.burst == 0 { return Ok(true); } + let digest = ::digest(key.as_bytes()); + let key_hex = base16(&digest); let mut conn = conn.clone(); let script = redis::Script::new(TOKEN_BUCKET_LUA); let allowed: i64 = script - .key(format!("asm:rl:{class}:{key}")) + .key(format!("asm:rl:{class}:{key_hex}")) .arg(spec.burst) .arg(spec.refill_per_second()) .arg(now) @@ -95,6 +106,16 @@ pub async fn take( Ok(allowed == 1) } +/// Lowercase hex of a byte slice (avoids a hex-crate dependency). +fn base16(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + #[cfg(test)] mod tests { use super::*;