feat(authenticator): nginx+auth step 10 — full auth surface completion (all 9 items) - #1851
Conversation
📝 WalkthroughWalkthroughThe authenticator gains CSRF-protected session APIs, refresh rotation, back-channel logout, IdP token refreshing, Redis rate limiting and maintenance, audit publishing, authenticated gateway configuration, deployment updates, and corresponding documentation and end-to-end tests. ChangesAuthenticator auth-surface completion
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
342ecb1 to
e3bd7d8
Compare
…ace (steps 10.1–10.2)
Item 1 — POST /auth/refresh (G10 rotation model, no swap keys):
a fresh CSPRNG token mapping is written and the superseded mapping's TTL
drops to refresh_grace_ms (default 250 ms) — the expiring old mapping IS the
grace window — while the session's expires_at advances to min(now +
session_ttl, absolute cap) across the record, its key TTL, and the per-user
index score, all in one pipeline. The stable session_id and the linked JWT
are untouched. A stale token inside the grace window resolves to the same
session and is answered with the current credential (no re-rotation); past
grace or past either cap → 401 + cleared cookie. Response is
{expires_at, refresh_at} with refresh_at = expires_at − 90 s ± uniform(60 s)
(big-jitter decision, G8), re-jittered per call and shared with GET /auth/me;
cookie Max-Age is the actual remaining session life.
Item 2 — session management:
GET /auth/sessions lists the caller's live sessions from the per-user ZSET
(created_at, expires_at, user_agent, ip, current flag; attribution captured
at login from User-Agent + first X-Forwarded-For hop, length-capped).
DELETE /auth/sessions/{id} revokes one owned session (absent and not-owned
are both 404 — no existence oracle); DELETE /auth/sessions revokes everything
for the current user. Every revoke runs the standard pipeline: token mappings
+ session + linked JWT + index entries in one MULTI/EXEC.
The admin/service variant DELETE /auth/admin/users/{person_id}/sessions is a
.authenticated() operation: the host authn pipeline (cf-gears-oidc-authn-plugin,
newly linked) verifies the ES256 gateway JWT against the authenticator's own
issuer, and the handler requires one of admin_revoke_roles (default
["session_admin"]) before delegating to the SDK contract
(AuthenticatorClientV1::revoke_user_sessions) — the lever the future
permissions service pulls (DD-AUTH-07).
Config wiring: committed host config flips to auth_disabled: false with a
fail-closed .invalid placeholder issuer; dev compose bind-mounts a full-auth
override (authn-tls issuer + self-signed CA) and grants the dev testclient
the session_admin role; the Helm configmap renders the plugin block off
tlsDiscovery (real issuer + in-pod CA when enabled, dark otherwise).
Also repairs the e2e harness: identity-stub readiness probed the old
/v1/persons path, and two stale ignored e2e asserts predated the
space-delimited roles claim and the UUIDv5 service sub. run-e2e.sh now also
runs the new refresh + sessions loops; all four e2e loops pass locally.
EPIC: constructorfabric#1583 (step 10, constructorfabric#1593)
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
Second line behind SameSite=Strict (PRD 5.11 / DESIGN 4.2, salvaged spec): middleware over the route table checks POST/PUT/PATCH/DELETE under /auth/*. X-CSRF-Token is compared in constant time (fixed-size SHA-256 digests) against the per-session token minted at login; with no header, the Origin allowlist (csrf_origins) is the fallback; empty allowlist (the default) is fail closed — token required. A presented-but-wrong token is never rescued by the Origin fallback. Requests without a resolvable session pass through (the handler answers 401 — nothing to forge); a Redis failure answers 503, never a bypass. The back-channel logout endpoint is exempt: IdP server-to-server, its credential is the signed logout_token. GET /auth/csrf issues the session's token; /auth/me echoes it so one call primes both the refresh timer and the CSRF header at page load. Config: csrf_origins in the committed config (empty = fail closed), the dev compose override (Vite + gateway origins keep browser flows working until the SPA header lands everywhere), and a new chart value csrfOrigins. The SPA side (store csrf_token from /auth/me, send X-CSRF-Token on logout) lands in insight-front (feat/auth-csrf-header). e2e now asserts 403-without / pass-with the header on refresh and log-out-everywhere; all loops green. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
M2 (QA/security review): make /auth/refresh rotation a compare-and-swap on the session's current_token (atomic Lua) instead of an unconditional pipeline. Two concurrent refreshes of the same cookie (multi-tab, within the refresh burst) could both pass the grace check and both rotate, leaving the first new token mapping written with the full session TTL and never demoted to grace — a live parallel credential that revoke never touches. The CAS lets only one rotate; the loser re-loads and answers the grace path with the winner's current credential. No orphan mapping. M5: document the fail-closed CSRF deploy coordination in the chart values — roll the header-sending insight-front first, or set csrfOrigins, or logout/ refresh 403 during the transition. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
POST /auth/oidc/back-channel-logout validates the logout_token per OIDC BCL
1.0: signature via the configured IdP's JWKS (fetched fresh per call — cold
path, picks up key rotation), iss against the one trusted issuer, aud
against our client_id, iat freshness inside a skew/max-age window
(backchannel_clock_skew_seconds 60 / backchannel_token_max_age_seconds 300),
the mandatory back-channel events member, sub-or-sid presence, and no nonce
(a nonce marks a replayed id_token). Rejections are 400 with a coarse
reason; JWKS unavailability is 503 (retryable by the IdP), never a bypass.
Replay guard: asm:logout_jti:{iss}:{jti} SET NX EX with TTL = iat + max_age
+ skew − now; a replayed delivery answers 200 idempotently without another
revoke. Success answers 200 no-store (BCL §2.7).
Target resolution: (iss, sid) via the existing asm:sid_index; a sub-only
token takes the documented fallback — a new asm:sub_index:{iss}:{sub} SET
(maintained in the create/revoke pipelines) resolves the user's sessions,
and EVERYTHING for that user is revoked through the standard pipeline with
the operator-facing warn line (blast radius visible, not silent). The sub
index replaces the PRD's 'resolve via Identity Service' sketch: Identity's
lookup is email-keyed (a logout_token carries no email), and an index keeps
the logout path free of a cross-service dependency; the spec text is updated
in the step-10 docs pass.
e2e via fakeidp /_control/backchannel: two live devices die on one signed
logout_token; garbage tokens are 400. All six e2e loops pass locally.
EPIC: constructorfabric#1583 (step 10, constructorfabric#1593)
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
RunnableCapability::start now spawns the leader-elected refresher: one
leader (Redis lock asm:leader:idp_refresher, SET NX PX + same-holder renew,
TTL = 3 ticks) polls asm:idp_refresh_due (ZRANGEBYSCORE 0 now, bounded) every
idp.refresher_tick_seconds (default 5) and spawns per-session refresh tasks
behind a Semaphore(idp.refresh_concurrency) — politeness toward the customer
IdP, not our capacity. Each task holds a per-session rotation lock (SET NX PX
30 s): refresh tokens are one-time-use at most IdPs, and two workers racing a
rotation would burn the grant and falsely kill the session.
Outcomes (fail open on transport, fail closed on verdict):
- success → store the rotated refresh token + new access expiry, reset the
failure counter, re-schedule margin-before-expiry with write-time jitter
(idp.refresh_due_jitter_seconds, G5 anti-herding);
- invalid_grant (definitive: revoked/expired/disabled) → revoke the owning
session through the standard pipeline (audited); the user's other sessions
hold their own grants and die at their own next refresh, so IdP-side
deactivation converges within about one access-token lifetime;
- transient (network, 5xx, 429) → exponential backoff min(15<<n, 300) s with
jitter, retry, NEVER revoke.
Login-side policy fixes: a session with no refresh token is no longer
scheduled (nothing to refresh), and no_refresh_token_policy=strict now
actually caps the session at the IdP access-token lifetime (login_only keeps
the absolute cap). Due-time jitter at login now comes from config.
Metrics (OTel global meter, exported by the toolkit host pipeline):
idp_refresh_total{result}, idp_refresh_consecutive_failures gauge (alert
before the mass logout), idp_refresh_invalid_grant_total.
e2e via the fakeidp control hooks at a fast lifecycle (token TTL 15 s,
margin 10 s, tick 1 s): /_control/outage 5xx logs nobody out;
/_control/revoke kills the victim's session on the next scheduled refresh
while another user's session survives. All seven e2e loops pass locally.
EPIC: constructorfabric#1583 (step 10, constructorfabric#1593)
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
Per-key TTLs remove session records and token mappings, but the per-user ZSET indexes and the refresh schedule keep dead members until trimmed. One leader (asm:leader:janitor, the same SET NX PX + same-holder-renew election as the refresher) runs a pass every janitor_interval_seconds (default 30 s): SCAN asm:user_sessions:* (bounded batches, never KEYS) + ZREMRANGEBYSCORE 0 now per index, plus dropping refresh-schedule entries overdue by more than 10 min (live sessions are rescheduled every attempt — a long-overdue entry has no owner). Metrics: auth_janitor_removed_total counter and auth_janitor_backlog_size gauge (expired-but-untrimmed members seen by the last pass — rises when no pod is running passes, per DESIGN 4.3). EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…H2, M1, M3, M4, L2, L3) H1: give the OIDC HTTP client a 10s total / 5s connect timeout (reqwest has none by default). A hung IdP connection otherwise outlives the refresher's 30s per-session lock — a second worker re-runs the grant with the same one-time-use refresh token, the IdP burns it → false logout — and holds a semaphore permit forever, wedging the whole refresher. H2: store_idp_refresh and bump_refresh_failures are now atomic Lua guarded on the session still existing. A revoke landing during the IdP round trip could otherwise HSET the deleted session key back into existence as a TTL-less hash holding the freshly-rotated, live IdP refresh token — a permanent, janitor-invisible secret for a logged-out user. store returns false on the revoked-mid-flight race; the caller unschedules. M3: the IdP rotates the grant before we store it, so a store failure loses the new token and the next attempt re-sends the spent one → false logout. Retry the store (3x, 200ms) before giving up. M4: the janitor no longer blind-ZREMRANGEBYSCOREs overdue refresh-due entries — it removes an entry only when its session hash is actually gone. Blind purge would silently kill IdP refresh for live-but-behind sessions after a Redis restore or while the refresher is disabled/wedged. M1: back-channel logout releases the (iss,jti) replay guard if the revoke that followed the claim fails, so the IdP's retry actually revokes instead of getting an idempotent 200 with nothing done. L2: try_lead is now a single compare-and-pexpire Lua (no GET-then-PEXPIRE race that briefly allowed two leaders). L3: next_due_at floored at now + margin/2 so an IdP with ≤margin access-token lifetimes can't be refreshed every tick. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…gin-state cap (step 10.6) The gateway's per-IP limit_req zone stays the coarse flood guard (G8); this is the precise, multi-replica-correct layer in Redis (DESIGN 4.4 / §9.3): - an atomic Lua token bucket keyed by what identifies the caller — the STABLE session id on /auth/refresh (rotation doesn't reset it; default 5-burst, 6/min) and the OIDC state on /auth/callback (default 5-burst, 10/min). Never IP: corporate NAT makes per-IP keys wrong at this layer. - a global live login-state cap on /auth/login (default 1000): pre-auth there is no per-caller key, so the guarded resource is the store itself. A new asm:login_state_live ZSET (score = expiry, maintained in the put/take pipelines, trimmed by the janitor) counts live entries and excess logins get 429 before any state is written — the slow-trickle Redis-exhaustion attack the edge cannot see. Everything is tunable under rate_limit.* (burst 0 disables a bucket). Trips answer 429 problem+json with a quota violation + retry hint. The limiter fails OPEN on a Redis error (the coarse layer still guards; a Redis blip must not become a 429 storm) — auth itself keeps failing closed. e2e: one session's refresh bucket trips past the burst while a second session is unaffected; hammering one bogus callback state flips 400 → 429. All eight e2e loops pass locally. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…p 10.8) New audit emitter (DESIGN 3.2 'Audit Emitter', PRD nfr-auth-audit): every auth-relevant action publishes to insight.audit.events with the platform envelope — schema tag insight.audit.event.v1, event_id (UUIDv7), RFC3339-ms timestamp, correlation_id (the gateway's X-Correlation-Id when the request carried one, else the event id), tenant_id, actor person/ip/user-agent, service=authenticator, category=auth, action, outcome, resource, details — field names mirroring the Audit Service's ClickHouse events schema. Covered actions: login success/failure (unknown person), session_refresh, logout, session_revoke (single / all / admin-by-user with the acting subject), back_channel_logout (with the sub-only-fallback marker), idp_refresh_invalid_grant kills, and service_token_issued. Publishing never touches auth latency or availability: emit() drops the event into a bounded channel; a background task owns the rdkafka FutureProducer (Kafka-compatible rdkafka API only — the backend PRD's Redpanda-to-Kafka migration constraint; vendored librdkafka via cmake-build, cmake added to the builder image). Queue-full or delivery failure drops the event and bumps auth_audit_dropped_total; with no audit.brokers configured the emitter is disabled and events remain in the structured log (the existing target:audit lines stay as the operator-facing trace). Wiring: audit.brokers/audit.topic config (empty = disabled), compose sets redpanda:9092, the subchart gains audit.* values, and the umbrella folds the global redpanda.brokers into the authenticator config Secret. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…review: L4, L5) L4: SHA-256-hash the token-bucket key component before use, so an attacker- chosen key (the OIDC state on /auth/callback, created before state validation) is bounded to a fixed-width digest — it can't inflate Redis with long keys or smuggle control chars. L5: clamp the bucket's stored timestamp forward (ts = max(ts, now)) so a clock step-back (NTP correction) or multi-pod skew can't re-add the skipped window and over-refill. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…ps 10.9–10.10) KEY-ROTATION.md: operator runbook for rotating the ES256 signing keys — generate (named-curve P-256), swap into the Secret as current.pem with the old key demoted to previous.pem, roll the pods, wait at least jwt_ttl (300 s) + downstream JWKS cache (3600 s) ≈ 65 min, then drop previous.pem. Includes the exp-clamp note (tokens carry ≤ jwt_ttl, never more — the floor is conservative in the right direction), the JWKS Cache-Control interaction (the overlap floor moves with the longest cache in the fleet), and the emergency-rotation trade-off. PRD: tick the requirement/contract/use-case checkboxes delivered through step 10 (bootstrap and the exchange-p95 NFR stay open), and align stale text with the implemented contract: single signed tenant_id (EPIC constructorfabric#1583 one-tenant decision; supersedes the tenants array + X-Tenant-ID selector sketch), ES256 decided, service-token sub as the per-service UUIDv5 with sid = service:<name>, and the back-channel sub-only fallback resolving via the asm:sub_index written at login (Identity cannot resolve a bare sub, and the logout path must not depend on another service). DESIGN: document the step-10 Redis keys (asm:sub_index, asm:login_state_live, asm:rl:* buckets, asm:leader:* / asm:refresh_lock:* locks) in 3.7, the step-10 config additions in 3.9, and the sub_index in the back-channel sequence. Component checkboxes stay open pending a DECOMPOSITION artifact (ticking them trips ref-missing-from-kind repo-wide). cfs toc regenerated; cfs validate green for both artifacts (repo-wide count unchanged from main); check-language clean on the runbook. EPIC: constructorfabric#1583 (step 10, constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…consumer yet) The Audit Service that should drain insight.audit.events → ClickHouse (cpt-insightspec-component-be-audit-service) is spec'd but not built, so nothing consumes the topic the step-10 emitter produces to. Redpanda won't OOM (disk-backed, low volume) but events would sit at the cluster-default retention indefinitely. The emitter now creates the topic with retention.ms = audit.retention_ms (default 86_400_000 = 1 day) so its on-disk log is bounded — events are deliberately aged out after 24h (accepted data loss for now; the structured target:"audit" logs shipped to Loki are the interim trail). Best-effort and spawned: never blocks or fails auth boot; TopicAlreadyExists is a no-op and we do NOT alter an existing topic's config (no clobbering infra-managed settings); admin errors are logged and swallowed. disables the bound. DESIGN Audit Emitter note + config row updated (cfs green). EPIC: constructorfabric#1583 (constructorfabric#1593) Signed-off-by: Anton Zelenov <antonz@constructor.tech>
…(review)
CI fix: the `Rust — authenticator` job failed building the vendored
librdkafka (rdkafka cmake-build, audit events step 10.8) with
`fatal error: curl/curl.h: No such file` — librdkafka's OAUTHBEARER path
includes curl.h even with WITH_CURL=0. Add cmake + libcurl4-openssl-dev to
the ci.yml rust-matrix dep step and the authenticator Dockerfile builder.
The authenticator is the workspace's first rdkafka user, so these deps
weren't present. (Coverage gate failed only because this build failed.)
Review (cyberantonz): a bare `DESIGN §4.4` in a code comment isn't findable.
Add a module-doc pointer stating that PRD/DESIGN §refs resolve to
docs/components/backend/authenticator/{PRD,DESIGN}.md, and expand the flagged
comment to the repo-relative doc path.
EPIC: constructorfabric#1583 (constructorfabric#1593)
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
9e9a516 to
8f7b9a1
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (7)
src/backend/services/authenticator/src/refresher.rs (2)
107-163: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated leader-elected tick-loop boilerplate across the two new background workers. Both
refresher.rs::runandjanitor.rs::runindependently implement the same cancel/tick-select → lease-sizing →try_lead→ skip-if-not-leader shape; the only difference is the per-tick work performed once leadership is held.
src/backend/services/authenticator/src/refresher.rs#L107-L163: extract the cancel/tick/lease/try_leadscaffolding into a shared helper (e.g.,leader_elected_loop(state, cancel, tick, leader_key, work_fn)) and pass the due-session draining logic as the work closure.src/backend/services/authenticator/src/janitor.rs#L28-L83: reuse the same shared helper, passing thejanitor_passinvocation as its work closure.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/src/refresher.rs` around lines 107 - 163, Extract the duplicated cancellation, ticking, lease sizing, and try_lead control flow from refresher.rs lines 107-163 into a shared leader_elected_loop helper, passing the refresher due-session draining logic as its work closure; update janitor.rs lines 28-83 to reuse the same helper with janitor_pass as its work closure, preserving each worker’s existing per-tick behavior.
121-161: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winPermit acquisition inside the batch loop can stall cancellation and lease renewal.
acquire_owned().await(line 152) blocks the leader's main loop before it returns totokio::select!(cancellation check) or re-runstry_lead(lease renewal). With a large due-batch (up toBATCH_LIMIT= 512) and modestrefresh_concurrency, this loop can run well past the lease TTL (tick * 3), delaying shutdown responsiveness and letting the lease lapse mid-batch (a second instance can then start draining the same batch — harmless here only becauselock_session_refreshde-dupes per session, but it's wasted leader-election churn).♻️ Spawn tasks without blocking on the permit in the leader loop
for session_id in due { - let Ok(permit) = semaphore.clone().acquire_owned().await else { - return; // semaphore closed — only on shutdown - }; + let semaphore = semaphore.clone(); let state = state.clone(); let metrics = metrics.clone(); tokio::spawn(async move { - let _permit = permit; + let Ok(_permit) = semaphore.acquire_owned().await else { + return; + }; refresh_one(&state, &metrics, &session_id).await; }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/src/refresher.rs` around lines 121 - 161, Update the batch spawning flow around the leader loop and refresh_one so permit acquisition does not await inside the leader task. Spawn each refresh task immediately, acquire the semaphore within that task before calling refresh_one, and preserve shutdown handling and permit lifetime there so the leader loop can promptly return to cancellation checks and lease renewal.src/backend/services/authenticator/src/oidc.rs (2)
296-340: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider consolidating repeated discovery-document fetching.
idp_jwks()(lines 315-330) fetches/.well-known/openid-configurationitself to readjwks_uri, duplicating the same endpoint fetch already done inmetadata()and inrp_logout_url()below (each with its own localDiscostruct). Also, neither the discovery GET nor the JWKS GET call.error_for_status()before.json(), so a non-2xx response surfaces as an opaque decode error instead of a clear HTTP-status error.♻️ Add explicit status checks
let disco: Disco = self .http .get(format!( "{}/.well-known/openid-configuration", self.issuer_url )) .send() .await .context("fetch IdP discovery")? + .error_for_status() + .context("IdP discovery returned an error status")? .json() .await .context("decode IdP discovery")?; self.http .get(&disco.jwks_uri) .send() .await .context("fetch IdP JWKS")? + .error_for_status() + .context("IdP JWKS endpoint returned an error status")? .json() .await .context("decode IdP JWKS")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/src/oidc.rs` around lines 296 - 340, Consolidate discovery-document retrieval into a shared helper used by metadata(), rp_logout_url(), and idp_jwks(), removing their repeated local Disco definitions and endpoint requests. In that helper, call error_for_status() after the discovery GET before decoding JSON. Also apply error_for_status() to the JWKS GET in idp_jwks() so non-2xx responses surface as HTTP errors.
250-262: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winCache discovery metadata for the background refresh path.
refresh_grantfetches OIDC discovery on every call, andrefresher.rsinvokes it for each due refresh under a worker semaphore. Add a short TTL/metadata-cache with periodic refresh, or rely on cache-aware HTTP client behavior for discovery metadata to reduce IdP discovery traffic without keeping metadata cached indefinitely.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/src/oidc.rs` around lines 250 - 262, Update refresh_grant and its metadata retrieval path to reuse OIDC discovery metadata through a short-lived TTL cache or cache-aware HTTP behavior, refreshing it periodically rather than fetching on every refresh. Ensure concurrent background refreshes share the cached metadata and retain the existing transient error handling when discovery fails.src/backend/services/authenticator/tests/e2e_refresh.rs (1)
22-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract shared e2e test helpers into a common module. Five new e2e test files each reimplement identical
env/client/rewrite_host/cookie_from/login(and, in three files,get_csrf) helpers — one root cause: no shared test-support module for this suite.
src/backend/services/authenticator/tests/e2e_refresh.rs#L22-L97: moveenv,client,rewrite_host,cookie_from,login,get_csrfinto a sharedtests/common/mod.rs(or#[path]-included file) and import from there.src/backend/services/authenticator/tests/e2e_backchannel.rs#L21-L79: replace the local copies ofenv,client,rewrite_host,cookie_from,loginwith the shared module.src/backend/services/authenticator/tests/e2e_ratelimit.rs#L19-L92: replace the local copies ofenv,client,rewrite_host,cookie_from,login,get_csrfwith the shared module.src/backend/services/authenticator/tests/e2e_refresher.rs#L23-L81: replace the local copies ofenv,client,rewrite_host,cookie_from,loginwith the shared module.src/backend/services/authenticator/tests/e2e_sessions.rs#L22-L103: replace the local copies ofenv,client,rewrite_host,cookie_from,get_csrfwith the shared module, keeping this file's extraUSER_AGENTheader as a parameter/override on the sharedlogin.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/tests/e2e_refresh.rs` around lines 22 - 97, Create a shared tests/common/mod.rs containing env, client, rewrite_host, cookie_from, login, and get_csrf, then import and use those helpers instead of local copies. Update src/backend/services/authenticator/tests/e2e_refresh.rs#L22-97, e2e_backchannel.rs#L21-79, e2e_ratelimit.rs#L19-92, and e2e_refresher.rs#L23-81 accordingly; update e2e_sessions.rs#L22-103 to share all applicable helpers while preserving its extra USER_AGENT behavior through a login parameter or override.src/backend/services/authenticator/src/gear.rs (1)
142-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winStale comment now contradicts the code immediately below it.
This comment says the refresher/janitor "land in step 10 and will spawn here the same way" — but this very PR (step 10) already spawns both a few lines down. Update or remove it to avoid confusing future readers.
✏️ Proposed comment update
- // `start` must return promptly — the host awaits it before starting the - // next gear (including the api-gateway HTTP server). We bind the - // service-token listener here (surfacing a bad bind at boot) and spawn - // its server, holding `cancel` for graceful shutdown. The IdP refresher - // (G5) and janitor land in step 10 and will spawn here the same way. + // `start` must return promptly — the host awaits it before starting the + // next gear (including the api-gateway HTTP server). We bind the + // service-token listener here (surfacing a bad bind at boot), then spawn + // it plus the IdP refresher (G5) and index janitor, holding `cancel` for + // graceful shutdown of all three.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/src/gear.rs` around lines 142 - 143, Update the stale comment immediately before the step-10 spawning logic to remove the claim that the IdP refresher and janitor will spawn there in the future, since they already do so below. Keep only wording that accurately describes the current shutdown and spawning behavior.src/backend/services/authenticator/src/audit.rs (1)
85-101: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueBound the topic-creation admin call with an explicit timeout.
AdminOptions::new()does not setrequest_timeout, so this best-effort create falls back to the rdkafkasocket.timeout.msconfiguration instead of documenting a timeout boundary here. Since this is not required for auth availability, tie it to a small constant or the existing configured timeout.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/backend/services/authenticator/src/audit.rs` around lines 85 - 101, Update ensure_topic_retention to configure an explicit request timeout on the AdminOptions used by admin.create_topics, using a small constant or the existing configured timeout rather than relying on socket.timeout.ms. Preserve the current best-effort behavior and error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/backend/services/authenticator/src/audit.rs`:
- Around line 209-227: Update the serde_json::to_vec failure branch in the audit
delivery task to increment dropped_in_task and emit a tracing warning containing
the serialization error and relevant event context, matching the existing
producer delivery-failure drop path so serialization losses are counted and
visible.
In `@src/backend/services/authenticator/src/refresher.rs`:
- Around line 234-263: Extend the retry handling around store_idp_refresh to use
a longer, backed-off retry window suitable for Redis failovers instead of only
three attempts with fixed 200ms delays. When retries are exhausted in the final
Err branch, increment a dedicated idp_refresh_store_exhausted_total metric in
addition to the existing error log, keeping the Ok(true) and Ok(false) outcomes
unchanged.
---
Nitpick comments:
In `@src/backend/services/authenticator/src/audit.rs`:
- Around line 85-101: Update ensure_topic_retention to configure an explicit
request timeout on the AdminOptions used by admin.create_topics, using a small
constant or the existing configured timeout rather than relying on
socket.timeout.ms. Preserve the current best-effort behavior and error handling.
In `@src/backend/services/authenticator/src/gear.rs`:
- Around line 142-143: Update the stale comment immediately before the step-10
spawning logic to remove the claim that the IdP refresher and janitor will spawn
there in the future, since they already do so below. Keep only wording that
accurately describes the current shutdown and spawning behavior.
In `@src/backend/services/authenticator/src/oidc.rs`:
- Around line 296-340: Consolidate discovery-document retrieval into a shared
helper used by metadata(), rp_logout_url(), and idp_jwks(), removing their
repeated local Disco definitions and endpoint requests. In that helper, call
error_for_status() after the discovery GET before decoding JSON. Also apply
error_for_status() to the JWKS GET in idp_jwks() so non-2xx responses surface as
HTTP errors.
- Around line 250-262: Update refresh_grant and its metadata retrieval path to
reuse OIDC discovery metadata through a short-lived TTL cache or cache-aware
HTTP behavior, refreshing it periodically rather than fetching on every refresh.
Ensure concurrent background refreshes share the cached metadata and retain the
existing transient error handling when discovery fails.
In `@src/backend/services/authenticator/src/refresher.rs`:
- Around line 107-163: Extract the duplicated cancellation, ticking, lease
sizing, and try_lead control flow from refresher.rs lines 107-163 into a shared
leader_elected_loop helper, passing the refresher due-session draining logic as
its work closure; update janitor.rs lines 28-83 to reuse the same helper with
janitor_pass as its work closure, preserving each worker’s existing per-tick
behavior.
- Around line 121-161: Update the batch spawning flow around the leader loop and
refresh_one so permit acquisition does not await inside the leader task. Spawn
each refresh task immediately, acquire the semaphore within that task before
calling refresh_one, and preserve shutdown handling and permit lifetime there so
the leader loop can promptly return to cancellation checks and lease renewal.
In `@src/backend/services/authenticator/tests/e2e_refresh.rs`:
- Around line 22-97: Create a shared tests/common/mod.rs containing env, client,
rewrite_host, cookie_from, login, and get_csrf, then import and use those
helpers instead of local copies. Update
src/backend/services/authenticator/tests/e2e_refresh.rs#L22-97,
e2e_backchannel.rs#L21-79, e2e_ratelimit.rs#L19-92, and e2e_refresher.rs#L23-81
accordingly; update e2e_sessions.rs#L22-103 to share all applicable helpers
while preserving its extra USER_AGENT behavior through a login parameter or
override.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 181b0d9b-89ed-44e6-8c5c-fea377866d98
⛔ Files ignored due to path filters (1)
src/backend/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (36)
.github/workflows/ci.ymlcharts/insight/templates/secrets.yamldeploy/compose/authenticator-fullauth.yamldocker-compose.ymldocs/components/backend/authenticator/DESIGN.mddocs/components/backend/authenticator/KEY-ROTATION.mddocs/components/backend/authenticator/PRD.mdsrc/backend/services/authenticator/Cargo.tomlsrc/backend/services/authenticator/Dockerfilesrc/backend/services/authenticator/config/insight.yamlsrc/backend/services/authenticator/helm/templates/configmap.yamlsrc/backend/services/authenticator/helm/templates/deployment.yamlsrc/backend/services/authenticator/helm/values.yamlsrc/backend/services/authenticator/src/api/error.rssrc/backend/services/authenticator/src/api/handlers.rssrc/backend/services/authenticator/src/api/mod.rssrc/backend/services/authenticator/src/audit.rssrc/backend/services/authenticator/src/backchannel.rssrc/backend/services/authenticator/src/config.rssrc/backend/services/authenticator/src/csrf.rssrc/backend/services/authenticator/src/gear.rssrc/backend/services/authenticator/src/janitor.rssrc/backend/services/authenticator/src/main.rssrc/backend/services/authenticator/src/oidc.rssrc/backend/services/authenticator/src/ratelimit.rssrc/backend/services/authenticator/src/refresher.rssrc/backend/services/authenticator/src/service_token.rssrc/backend/services/authenticator/src/session.rssrc/backend/services/authenticator/tests/e2e_backchannel.rssrc/backend/services/authenticator/tests/e2e_login_loop.rssrc/backend/services/authenticator/tests/e2e_ratelimit.rssrc/backend/services/authenticator/tests/e2e_refresh.rssrc/backend/services/authenticator/tests/e2e_refresher.rssrc/backend/services/authenticator/tests/e2e_service_token.rssrc/backend/services/authenticator/tests/e2e_sessions.rssrc/backend/services/authenticator/tests/run-e2e.sh
| 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)"); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Serialization failures silently bypass the dropped-events metric.
Every other drop path here (emit's queue-full, the producer's delivery failure) increments dropped/dropped_in_task and logs a warning. The serde_json::to_vec failure branch does neither, so a malformed event vanishes without a trace and without moving auth_audit_dropped_total — undercounting the very metric meant to surface audit data loss.
🩹 Proposed fix
let Ok(payload) = serde_json::to_vec(&env) else {
- continue;
+ dropped_in_task.add(1, &[]);
+ tracing::warn!(action = env.action, "audit event serialization failed (dropped)");
+ continue;
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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)"); | |
| } | |
| } | |
| 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 { | |
| dropped_in_task.add(1, &[]); | |
| tracing::warn!(action = env.action, "audit event serialization failed (dropped)"); | |
| 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)"); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/backend/services/authenticator/src/audit.rs` around lines 209 - 227,
Update the serde_json::to_vec failure branch in the audit delivery task to
increment dropped_in_task and emit a tracing warning containing the
serialization error and relevant event context, matching the existing producer
delivery-failure drop path so serialization losses are counted and visible.
| 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; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Store-retry window may be too short to protect against realistic Redis blips.
The comment correctly identifies the false-logout risk if store_idp_refresh fails after the IdP has already rotated the grant, but the mitigation is only ~400ms of retry budget (3 attempts, 200ms apart). A Redis failover/blip commonly lasts longer than that, and on exhaustion there's no dedicated metric — only a log line — to distinguish "rotated token lost, next refresh will falsely log the user out" from an ordinary transient IdP failure tracked by idp_refresh_consecutive_failures.
Consider a longer/backed-off retry budget and a dedicated counter (e.g., idp_refresh_store_exhausted_total) so this specific failure mode is separately alertable before it manifests as user-visible logouts.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/backend/services/authenticator/src/refresher.rs` around lines 234 - 263,
Extend the retry handling around store_idp_refresh to use a longer, backed-off
retry window suitable for Redis failovers instead of only three attempts with
fixed 200ms delays. When retries are exhausted in the final Err branch,
increment a dedicated idp_refresh_store_exhausted_total metric in addition to
the existing error log, keeping the Ok(true) and Ok(false) outcomes unchanged.
|
The two CodeRabbit findings that landed here after merge (audit.rs serialization-drop metric; refresher.rs store-retry window) are addressed in follow-up PR #1882. |
…re-retry (post-merge review) (#1882) Two CodeRabbit findings from #1851 (posted after the rate-limit reset, once the PR had merged): - audit.rs: the serde_json::to_vec failure branch dropped the event silently (bare `continue`) — unlike every other drop path it didn't bump auth_audit_dropped_total or log. Now it counts + warns. - refresher.rs: the post-grant store-retry was only 3×200ms (~400ms), too short for a realistic Redis failover/blip — a store failure after the IdP rotated the grant would then false-logout the session. Widened to exponential backoff (200ms→3.2s, ~6s over 6 attempts), still well under the 30s per-session lock TTL. Ref: #1583, #1851 Signed-off-by: Anton Zelenov <antonz@constructor.tech>
Authenticator CSRF middleware (constructorfabric#1851) fails closed on state-changing /auth/* without a token; fetch it via GET /auth/csrf like a real client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Anton Zelenov <antonz@constructor.tech>
Completes step 10 (auth surface completion, #1593) of the nginx+auth EPIC — all 9 items of
NGINX_BFF_10.mdin theauthenticatorservice. Single PR (supersedes the earlier stacked #1848/#1849/#1850/#1851, now closed): the items all touch the same files, so they're one cohesive change with one commit per item.Pairs with insight-front#212 (SPA sends
X-CSRF-Token).Items
POST /auth/refresh— rotation with grace (G10). New CSPRNG token mapping; old mapping demoted torefresh_grace_ms(250 ms) — the expiring old mapping is the grace window (no swap keys).expires_at = min(now + session_ttl, absolute cap)across record, key TTL, and per-user ZSET score; stablesession_id+ linked JWT untouched. Stale-in-grace → same session, no re-rotation; past grace/cap → 401 + clear cookie. Response{expires_at, refresh_at},refresh_at = expires_at − 90 s ± 60 s(G8), shared with/auth/me.GET /auth/sessions;DELETE /auth/sessions/{id}(no-existence-oracle 404);DELETE /auth/sessions; adminDELETE /auth/admin/users/{person_id}/sessions(.authenticated(), role-gated byadmin_revoke_roles, via the SDK). Every revoke = tokens + session + linked JWT + indexes in one pipeline.logout_token(IdP JWKS,iss/aud,iatwindow,events, sub-or-sid, no nonce);(iss,jti)replay guard; resolves by sid index or the sub-only fallback via the newasm:sub_index.invalid_grant→ revoke owning session, transient → backoff never revoke;no_refresh_token_policy. Metrics:idp_refresh_total{result}, consecutive-failure gauge,invalid_grantcounter./auth/*: constant-timeX-CSRF-Tokenvs the session record,Origin-allowlist fallback (csrf_origins, empty = fail closed), back-channel exempt.GET /auth/csrf;/auth/meechoes it./auth/refresh) / OIDC state (/auth/callback), plus a global live login-state cap; fails open on a Redis error (auth stays fail-closed).insight.audit.events(Redpanda) with the platform envelope, via a non-blocking bounded channel + rdkafka; empty brokers = disabled (log only).cfsgreen).Security + QA review fixes (folded into the relevant commits)
Two independent reviews (security: SHIP; QA: BLOCK-until-fixed) → all must-fixes applied: H1 OIDC HTTP timeout (hung IdP conn otherwise outlives the per-session lock → burns the one-time grant → false logout + wedges the refresher), H2 existence-guarded Lua for
store_idp_refresh/bump(revoke-mid-grant otherwise resurrects a TTL-less hash holding a live refresh token), M1 back-channel releases the replay guard on revoke failure, M2 rotation compare-and-swap (no orphan credential from multi-tab double-rotate), M3 post-grant store retry, M4 janitor trims a refresh-due orphan only if its session is gone, M5 CSRF fail-closed deploy note, L2try_leadCAS, L3next_duefloor, L4 hashed rate-limit keys, L5 clock-backstep clamp.Testing
Unit tests + clippy clean;
services/authenticator/tests/run-e2e.shruns 8 e2e loops (login, refresh, sessions, back-channel, rate-limit, refresher w/ fast lifecycle, service-token) against fakeidp + Redis — all green on the final tree.Deploy / follow-up notes
csrfOrigins, or logout/refresh 403 during the transition (documented in the chart values)./auth/refresh(no timer). Until that lands, sessions hard-expire at the 10-min TTL. Backend side is done here; the FE driver is tracked separately.EPIC: #1583 · closes #1593
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Security
Documentation