fix(cabana): enforce tokens_valid_after and is_activated on admin refresh

Fixes review finding CR-01 (quick 260927-q23): POST {prefix}/api/v1/auth/refresh
minted a new token without loading the admin, so a session kept alive by the
SPA's refresh-on-401 survived admin:reset-password, deactivation and deletion.
This broke Phase 9 truth T-09-04.

- bouncer: extract the JWT guard's subject lookup into subjectPrincipal and
  issuedBeforeCutoff (same order and messages), add ErrSubjectRejected
- bouncer: add RefreshAudienceFor, which runs the guard's subject checks
  after the token-only checks and before minting; Refresh and
  RefreshAudience are unchanged (nil hook)
- cabana: share one lazyBackendUsers provider between the backend guard and
  refresh; a cookie refresh refused for its subject expires summer_admin
- test: TestAdminRefreshRevocation (Postgres, real admin:reset-password)
This commit is contained in:
Jakub Zych
2026-09-27 18:58:15 +02:00
parent 815cb903c6
commit be4a923f36
6 changed files with 246 additions and 19 deletions

View File

@@ -221,8 +221,14 @@ func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
next, err := bouncer.RefreshAudience(s.secret, raw, bouncer.AudienceBackend, s.refreshTTL, s.bl, s.grace, s.issuer)
next, err := bouncer.RefreshAudienceFor(r.Context(), s.users, s.secret, raw, bouncer.AudienceBackend, s.refreshTTL, s.bl, s.grace, s.issuer)
if err != nil {
// A subject the guard would refuse (deactivated, deleted, or cut off
// by tokens_valid_after) ends the browser session. Other failures,
// including a provider error, leave the cookie alone.
if fromCookie && errors.Is(err, bouncer.ErrSubjectRejected) {
s.expireSessionCookie(w)
}
s.logAuth(r, "failed", 0)
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return