harden(auth/sessions): CSRF rotation on logout closes HIGH-2 fourth call site

Audit 2026-05-11 Fix 13 closure. The HIGH-2 closure on
dev/auth-bundle-2 documented four RotateCSRFTokenForActor call
sites — login completion (fresh by construction), Assign/Revoke
RoleToKey (wired at internal/api/handler/auth.go:498 + 546),
Logout, and an explicit operator endpoint. The 2026-05-11
adversarial review observed only 3 of the 4: Logout did NOT
rotate the actor's sibling sessions post-revoke.

Threat closed: a token captured pre-logout (browser DevTools,
malicious extension, session-storage leak) could be replayed
against the user's other-device/other-browser sessions until
those sessions hit their own idle/absolute expiry. Rotation on
logout defeats this — the captured token is dead the moment
the user clicks 'Sign out' anywhere.

What this changes:

* internal/api/handler/auth_session_oidc.go::SessionMinter
  interface gains RotateCSRFTokenForActor(ctx, actorID,
  actorType string) int. Nil-safe semantics by convention —
  the production wiring is *session.Service which already
  implements the method; rotation NEVER errors (returns int
  count, swallows per-row failures via the underlying
  Service.RotateCSRFToken) so it can't block the surrounding
  Revoke that triggered it.

* internal/api/handler/auth_session_oidc.go::Logout calls
  RotateCSRFTokenForActor after Revoke(sess.ID) succeeds. The
  auth.session_revoked audit row gains a csrf_rotated detail
  key carrying the count so SOC/SIEM can correlate logout
  events with CSRF churn on sibling sessions.

* The no-cookie + invalid-cookie 204 short-circuit paths
  skip rotation. No session row exists to rotate against;
  the caller is already unauthenticated. Rotation on those
  paths would do nothing useful and pollute the audit log.

Test coverage in internal/api/handler/auth_session_oidc_test.go:

* TestLogout_RotatesCSRFForActor — happy path. Mocks
  rotateCSRFReturnCount=2; asserts Revoke fires before
  rotation, rotation fires exactly once with caller's
  (actor_id, actor_type), audit details carry csrf_rotated=2.

* TestLogout_NoCookie_SkipsCSRFRotation — pins the 204
  short-circuit branch when there's no cookie. Rotation count
  stays at 0.

* TestLogout_InvalidCookie_SkipsCSRFRotation — pins the 204
  short-circuit branch when Validate rejects the cookie.
  Same rationale: no session row, no rotation.

The stubSession test fake gains RotateCSRFTokenForActor with
call-recording fields; the phase5StubAudit gains a details
slice append-aligned 1:1 with events so the happy-path test
can index into the latest entry and assert the count.

Spec Phase 3 (explicit operator endpoint) — intentionally
NOT shipped. The three automatic triggers (login + role-
mutation + logout) cover the HIGH-2 threat model; operators
who want a nuclear option can use the existing
RevokeAllForActor flow which forces re-login → fresh session
→ fresh CSRF. Adding a dedicated POST /api/v1/auth/sessions/
rotate-csrf admin endpoint would be defense-in-depth without
new attack-surface coverage. Documented in the audit-doc
annotation.

Verify gate:

* gofmt -l — clean
* go vet ./internal/api/handler/... — clean
* go build ./cmd/server/... ./internal/... — clean (production
  *session.Service satisfies the extended interface
  out of the box)
* go test -short -count=1 ./internal/api/handler/...
  ./internal/auth/session/... — all green; 3 new Logout
  cases + the 2 pre-existing Logout cases all pass.

Audit doc annotation at cowork/auth-bundles-audit-2026-05-10.md
flips the HIGH-2 row from 'CLOSED 2026-05-10 (3/4 call sites
wired)' to 'A-B-3 verified 2026-05-11: HIGH-2 fully closed
across all four documented call sites.'

Refs cowork/auth-bundles-fixes-2026-05-11/13-verify-logout-csrf-rotation.md.
This commit is contained in:
shankar0123
2026-05-11 12:24:41 +00:00
parent b8fac59200
commit 9a8130de32
3 changed files with 175 additions and 2 deletions
+32 -1
View File
@@ -68,11 +68,31 @@ type OIDCAuthHandshaker interface {
}
// SessionMinter is the slice of *session.Service the OIDC handler uses.
//
// Audit 2026-05-11 Fix 13 closure — adds RotateCSRFTokenForActor so the
// Logout handler can fire the HIGH-2 fourth call site. The HIGH-2 spec
// at cowork/auth-bundles-fixes-2026-05-10/06-high-1-2-revoke-and-rotate.md
// enumerated four CSRF-rotation triggers; three were wired (login mints
// fresh by construction, AssignRoleToKey + RevokeRoleFromKey rotate
// post-success), but Logout was missing. A token captured pre-logout
// (browser DevTools, malicious extension) was reusable on the actor's
// sibling sessions until those sessions hit their own idle/absolute
// expiry. Rotation on logout defeats this. Nil-safe: when the wired
// implementation isn't the production *session.Service (e.g. a future
// minimal-config deployment), the Logout handler skips the rotation
// instead of panic-ing.
type SessionMinter interface {
Create(ctx context.Context, actorID, actorType, ip, userAgent string) (*sessionsvc.CreateResult, error)
Validate(ctx context.Context, in sessionsvc.ValidateInput) (*sessiondomain.Session, error)
Revoke(ctx context.Context, sessionID string) error
RevokeAllForActor(ctx context.Context, actorID, actorType string) error
// RotateCSRFTokenForActor mints a fresh CSRF token across every
// active session for the (actorID, actorType) pair. Returns the
// count rotated. NEVER errors — rotation is defense-in-depth and
// must not block the surrounding mutation that triggered it.
// Matches the signature on *session.Service so the production
// wiring satisfies the interface without an adapter.
RotateCSRFTokenForActor(ctx context.Context, actorID, actorType string) int
}
// BackChannelLogoutVerifier validates an OpenID Connect Back-Channel
@@ -553,8 +573,19 @@ func (h *AuthSessionOIDCHandler) Logout(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusInternalServerError, "could not revoke session")
return
}
// Audit 2026-05-11 Fix 13 — HIGH-2 fourth call site. Rotate the CSRF
// token on the actor's remaining sessions so a token captured in
// this device's browser pre-logout (DevTools, malicious extension,
// session-storage leak) can't be replayed against a sibling session
// (other browser, other device) after the user logged out here.
// The just-revoked session also rotates but its CSRF lookup will
// fail at the sessions table's revoked_at IS NOT NULL filter
// anyway; rotation on the revoked row is harmless. RotateCSRFTokenForActor
// returns the count rotated and NEVER errors — rotation is defense
// in depth and must not block the logout success.
rotated := h.sessionSvc.RotateCSRFTokenForActor(r.Context(), caller.ActorID, string(caller.ActorType))
h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sess.ID,
map[string]interface{}{"session_id": sess.ID, "self_initiated": true})
map[string]interface{}{"session_id": sess.ID, "self_initiated": true, "csrf_rotated": rotated})
h.clearSessionCookies(w)
w.WriteHeader(http.StatusNoContent)
}