Skip to content

feat(authenticator): /auth/refresh rotation + session management + CSRF (nginx+auth step 10, PR 1/4) - #1848

Closed
cyberantonz wants to merge 3 commits into
constructorfabric:mainfrom
cyberantonz:feat/auth-step10-session-surface
Closed

feat(authenticator): /auth/refresh rotation + session management + CSRF (nginx+auth step 10, PR 1/4)#1848
cyberantonz wants to merge 3 commits into
constructorfabric:mainfrom
cyberantonz:feat/auth-step10-session-surface

Conversation

@cyberantonz

Copy link
Copy Markdown
Contributor

Step 10 (auth surface completion) of the nginx+auth EPIC, PR 1 of 4. Delivers items 10.1 (POST /auth/refresh), 10.2 (session management), and 10.5 (CSRF) of NGINX_BFF_10.md.

This is the base of a 4-PR stack; the others build on it:

  1. this PR — session surface (refresh + sessions + CSRF)
  2. idp-lifecycle (back-channel logout + IdP refresher + janitor)
  3. ratelimit + audit
  4. docs (key-rotation runbook + PRD/DESIGN updates)

GitHub can't base a cross-fork PR on another fork branch, so all four target main; review this one first (the later PRs' diffs include these commits until this merges).

What's here

  • 10.1 POST /auth/refresh — rotation with grace (G10). New CSPRNG token mapping; the superseded mapping's TTL drops to refresh_grace_ms (250 ms) — the expiring old mapping is the grace window (no swap keys). expires_at = min(now + session_ttl, absolute cap) across the record, key TTL, and per-user ZSET score. Stable session_id + linked JWT untouched. Stale-in-grace resolves to the same session (no re-rotation); past grace or either cap → 401 + clear cookie. Response {expires_at, refresh_at}, refresh_at = expires_at − 90 s ± 60 s (big-jitter, G8), re-jittered per call and shared with /auth/me.
  • 10.2 session management. GET /auth/sessions (created_at, expires_at, user_agent, ip, current — attribution captured at login); DELETE /auth/sessions/{id} (no-existence-oracle 404); DELETE /auth/sessions; admin DELETE /auth/admin/users/{person_id}/sessions — an .authenticated() op (host authn pipeline verifies the gateway JWT against the authenticator's own issuer), role-gated by admin_revoke_roles (default session_admin), delegating to the SDK AuthenticatorClientV1::revoke_user_sessions. Every revoke: token mappings + session + linked JWT + indexes in one pipeline.
  • 10.5 CSRF. Middleware on state-changing /auth/*: constant-time X-CSRF-Token vs the session record, Origin-allowlist fallback (csrf_origins, empty = fail closed), back-channel exempt. GET /auth/csrf; /auth/me echoes the token. SPA side is the insight-front PR.

Review-fix commit

The last commit applies M2 (rotation is now a compare-and-swap Lua on current_token — concurrent multi-tab refreshes can't both rotate and orphan a full-TTL credential) and M5 (CSRF fail-closed deploy-coordination note) from the security + QA review of the stack.

Testing

Unit tests + clippy clean. Local e2e harness (services/authenticator/tests/run-e2e.sh, 8 loops against fakeidp + Redis) is green on the full stack; the refresh loop asserts rotation, sid-stability, grace resolution, and past-grace 401.

⚠️ Deploy coordination: CSRF is fail-closed. Roll the header-sending insight-front (linked PR) first, or set csrfOrigins, or logout/refresh will 403 during the transition (documented in the chart values).

EPIC: #1583 · closes the step-10 items in #1593 (partial — see the stack)

🤖 Generated with Claude Code

…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>
@cyberantonz
cyberantonz requested a review from a team as a code owner July 22, 2026 09:32
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@cyberantonz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 482ac3c3-7f2d-488a-8056-ad27f1d94060

📥 Commits

Reviewing files that changed from the base of the PR and between 6c355be and a05b6ee.

⛔ Files ignored due to path filters (1)
  • src/backend/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • deploy/compose/authenticator-fullauth.yaml
  • docker-compose.yml
  • src/backend/services/authenticator/Cargo.toml
  • src/backend/services/authenticator/config/insight.yaml
  • src/backend/services/authenticator/helm/templates/configmap.yaml
  • src/backend/services/authenticator/helm/templates/deployment.yaml
  • src/backend/services/authenticator/helm/values.yaml
  • src/backend/services/authenticator/src/api/error.rs
  • src/backend/services/authenticator/src/api/handlers.rs
  • src/backend/services/authenticator/src/api/mod.rs
  • src/backend/services/authenticator/src/config.rs
  • src/backend/services/authenticator/src/csrf.rs
  • src/backend/services/authenticator/src/gear.rs
  • src/backend/services/authenticator/src/main.rs
  • src/backend/services/authenticator/src/session.rs
  • src/backend/services/authenticator/tests/e2e_login_loop.rs
  • src/backend/services/authenticator/tests/e2e_refresh.rs
  • src/backend/services/authenticator/tests/e2e_service_token.rs
  • src/backend/services/authenticator/tests/e2e_sessions.rs
  • src/backend/services/authenticator/tests/run-e2e.sh
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cyberantonz

Copy link
Copy Markdown
Contributor Author

Superseded — the four stacked step-10 PRs are collapsed into a single PR (#1851). Same commits, one review, one merge. Closing this subset.

@cyberantonz
cyberantonz deleted the feat/auth-step10-session-surface branch July 23, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants