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/deploy/compose/authenticator-fullauth.yaml b/deploy/compose/authenticator-fullauth.yaml new file mode 100644 index 000000000..ce4fdd900 --- /dev/null +++ b/deploy/compose/authenticator-fullauth.yaml @@ -0,0 +1,148 @@ +# 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: "/" + # 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"] + 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..033a0a8fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -318,9 +318,18 @@ 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 + # 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..2a30297ee 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", @@ -303,8 +304,10 @@ dependencies = [ "futures", "jsonwebtoken", "openidconnect", + "opentelemetry", "p256 0.14.0", "rand 0.8.6", + "rdkafka", "redis", "reqwest 0.12.28", "serde", @@ -3341,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" @@ -3675,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" @@ -4620,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 99281c0ee..1cfa0d5cc 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 } @@ -51,6 +52,10 @@ futures = { workspace = true } 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/config/insight.yaml b/src/backend/services/authenticator/config/insight.yaml index 6d1602e61..d92a5702b 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" @@ -88,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"] @@ -121,4 +159,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..96a4d5bef 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" @@ -74,6 +123,16 @@ 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 }} + 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/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/helm/values.yaml b/src/backend/services/authenticator/helm/values.yaml index 38d6620e3..f00cb079c 100644 --- a/src/backend/services/authenticator/helm/values.yaml +++ b/src/backend/services/authenticator/helm/values.yaml @@ -32,6 +32,25 @@ 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. +# +# 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: [] + +# 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/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..77edd774c 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,8 @@ 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::audit::AuditEvent; use crate::cookie; use crate::identity::PersonResolution; use crate::jwt::GatewayClaims; @@ -44,6 +45,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 +82,7 @@ pub async fn login( return_to, }, 300, + now, ) .await { @@ -99,6 +117,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 { @@ -114,6 +133,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, @@ -171,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() @@ -181,8 +231,21 @@ 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 { - Ok(token) => { + let client = ClientInfo::from_headers(&headers); + match mint_and_store_session(&state, &idp, &resolution, &client).await { + 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, @@ -198,17 +261,96 @@ 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 } + } +} + +/// 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( state: &AppState, idp: &crate::oidc::AuthenticatedIdp, resolution: &PersonResolution, -) -> anyhow::Result { + client: &ClientInfo, +) -> anyhow::Result<(String, String)> { 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(); @@ -234,11 +376,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 @@ -258,8 +402,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(), }; @@ -267,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, @@ -276,7 +420,7 @@ async fn mint_and_store_session( }) .await?; - Ok(token) + Ok((session_id, token)) } // ── /internal/authz ───────────────────────────────────────────────────────── @@ -427,12 +571,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, @@ -441,15 +580,152 @@ 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): +/// 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, + headers: axum::http::HeaderMap, +) -> 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); + } + + // 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. + 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); + let rotated = match state + .sessions + .rotate_session( + &session_id, + &record, + &token, + &new_token, + new_expires_at, + state.cfg.refresh_grace_ms, + ) + .await + { + 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)"); + 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) +} + +/// `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. -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) @@ -457,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) @@ -476,6 +760,409 @@ 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, + headers: axum::http::HeaderMap, + 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" + ); + 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 { + 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, + headers: axum::http::HeaderMap, +) -> 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" + ); + 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() +} + +/// 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, + headers: axum::http::HeaderMap, + 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)" + ); + 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(), + } +} + +// ── /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. +// 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, +) -> 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" + ); + 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) => { + // 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) + } + } +} + +/// 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`: @@ -492,6 +1179,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 +1261,58 @@ 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") + .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 +1364,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..d7c691841 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -27,6 +27,12 @@ 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, + /// Audit publisher (Redpanda; no-op when unconfigured). + pub audit: crate::audit::AuditEmitter, } /// Register the authenticator routes onto the host router. The `Extension` @@ -37,7 +43,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) } @@ -47,6 +61,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) } @@ -76,6 +91,16 @@ 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") @@ -86,6 +111,29 @@ 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); + + 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") @@ -96,6 +144,61 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .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") + .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 +} + /// The gateway-facing `/internal/*` surface (the `auth_request` target). fn register_internal_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router { OperationBuilder::get("/internal/authz") 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/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 3e8f07977..bd7baed70 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, } } } @@ -136,6 +143,61 @@ 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 +/// 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)] @@ -188,6 +250,24 @@ 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, + /// 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, + /// 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 + /// service on grant changes, DD-AUTH-07). + pub admin_revoke_roles: Vec, // ── Dependencies ───────────────────────────────────────────────────── /// Redis connection URL (`redis://host:port`). @@ -253,6 +333,12 @@ impl Default for AuthenticatorConfig { ], default_return_to: "/".to_owned(), 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()], redis_url: String::new(), signing_keys_path: String::new(), identity_url: String::new(), 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/gear.rs b/src/backend/services/authenticator/src/gear.rs index e259efe39..2cb03fbf0 100644 --- a/src/backend/services/authenticator/src/gear.rs +++ b/src/backend/services/authenticator/src/gear.rs @@ -84,8 +84,16 @@ 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()); + + // 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, @@ -94,6 +102,8 @@ impl Gear for AuthenticatorGear { oidc, resolver, service_registry, + authn_client, + audit, }); self.state .set(state) @@ -132,8 +142,14 @@ 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) + // 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 09ec546b1..3c5969b52 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -22,24 +22,32 @@ #![allow(clippy::doc_markdown)] mod api; +mod audit; +mod backchannel; mod config; mod cookie; +mod csrf; mod gear; mod identity; +mod janitor; mod jwt; mod local_client; mod oidc; +mod ratelimit; +mod refresher; 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/oidc.rs b/src/backend/services/authenticator/src/oidc.rs index 41c2415fc..c0303c42e 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)] @@ -70,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 { @@ -222,6 +247,97 @@ 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 { + &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/ratelimit.rs b/src/backend/services/authenticator/src/ratelimit.rs new file mode 100644 index 000000000..2d2749176 --- /dev/null +++ b/src/backend/services/authenticator/src/ratelimit.rs @@ -0,0 +1,138 @@ +//! 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 +-- 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 +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-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( + conn: &ConnectionManager, + class: &str, + key: &str, + spec: BucketSpec, + now: u64, +) -> anyhow::Result { + 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_hex}")) + .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) +} + +/// 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::*; + + #[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/refresher.rs b/src/backend/services/authenticator/src/refresher.rs new file mode 100644 index 000000000..75b151e1d --- /dev/null +++ b/src/backend/services/authenticator/src/refresher.rs @@ -0,0 +1,380 @@ +//! 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)"); + } +} + +// 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, + 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, + ); + // 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"); + // 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" + ); + 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"); + // 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(()) +} + +/// 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), + }; + 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. +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/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, diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 997f343cc..c28e136f3 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; @@ -166,9 +166,18 @@ 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}") } +/// 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}") } @@ -220,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); @@ -229,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 @@ -244,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")?; @@ -319,15 +363,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 { @@ -419,6 +466,438 @@ 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). 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. `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 { + // 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 expires_at = i64::try_from(new_expires_at).unwrap_or(i64::MAX); + 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 (CAS)")?; + 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 { + // 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 led: i64 = redis::Script::new(LEAD_LUA) + .key(key) + .arg(holder) + .arg(ttl_ms.max(1)) + .invoke_async(&mut conn) + .await + .context("acquire/renew leader lock")?; + Ok(led == 1) + } + + /// 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 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. + pub async fn store_idp_refresh( + &self, + session_id: &str, + new_refresh_token: Option<&str>, + access_expires_at: Option, + next_due: u64, + ) -> 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 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 (guarded)")?; + Ok(stored == 1) + } + + /// Bump the per-session transient-failure counter; returns the new count + /// (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> { + 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 = redis::Script::new(BUMP_LUA) + .key(session_key(session_id)) + .invoke_async(&mut conn) + .await + .context("bump refresh failures (guarded)")?; + Ok((failures >= 0).then(|| 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(()) + } + + /// 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; + } + } + + // 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- + // 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: Vec = conn + .zrangebyscore(REFRESH_DUE_KEY, 0, now_i) + .await + .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)) + } + + // ── 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()) + } + + /// 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 + /// 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). + /// + /// # 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. @@ -441,6 +920,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/e2e_login_loop.rs b/src/backend/services/authenticator/tests/e2e_login_loop.rs index c3672e6db..e3911f37d 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"); @@ -192,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_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/e2e_refresh.rs b/src/backend/services/authenticator/tests/e2e_refresh.rs new file mode 100644 index 000000000..3bc4e5195 --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_refresh.rs @@ -0,0 +1,237 @@ +//! 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") +} + +/// 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, + 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"); + + 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(); + 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}")) + .header("X-CSRF-Token", &csrf) + .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}")) + .header("X-CSRF-Token", &csrf) + .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_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/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..4a29c34e0 --- /dev/null +++ b/src/backend/services/authenticator/tests/e2e_sessions.rs @@ -0,0 +1,243 @@ +//! 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 +} + +/// 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 + .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; + 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. + // 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}")) + .header("X-CSRF-Token", &csrf_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}")) + .header("X-CSRF-Token", &csrf_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. + // 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(); + 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..1f499200f 100644 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -63,8 +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" @@ -72,7 +76,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 \ @@ -83,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+=($!) @@ -97,6 +104,27 @@ 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 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 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 + 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