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

@@ -118,19 +118,9 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
if err != nil {
return nil, err
}
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
return nil, errors.New(msgUserNotFound)
}
if g.users == nil {
return nil, errors.New(msgUserNotFound)
}
user, err := g.users.FindByID(r.Context(), uint(id))
user, err := subjectPrincipal(r.Context(), g.users, sub)
if err != nil {
return nil, errors.New("Authentication error")
}
if user == nil {
return nil, errors.New(msgUserNotFound)
return nil, err
}
if g.bl != nil {
blocked, err := g.bl.IsBlacklisted(r.Context(), jti)
@@ -141,12 +131,48 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
return nil, errors.New(msgBadSignature)
}
}
if !user.TokensValidAfter.IsZero() && iat.Before(user.TokensValidAfter) {
return nil, errors.New(msgUserNotFound)
if issuedBeforeCutoff(user, iat) {
return nil, ErrSubjectRejected
}
return user, nil
}
// ErrSubjectRejected reports that a token's subject is not a loadable user
// (a non-numeric or zero sub, a nil provider, or a provider that returned no
// principal for a missing, deleted or not-activated user), or that the token
// was issued before Principal.TokensValidAfter. Its message is "User not found".
var ErrSubjectRejected = errors.New(msgUserNotFound)
// errAuthentication reports a provider failure while loading the subject.
var errAuthentication = errors.New("Authentication error")
// subjectPrincipal loads the token subject through users, shared by the JWT
// guard and RefreshAudienceFor. A provider error is errAuthentication; every
// other refusal is ErrSubjectRejected.
func subjectPrincipal(ctx context.Context, users UserProvider, sub string) (*Principal, error) {
id, err := strconv.ParseUint(sub, 10, 64)
if err != nil || id == 0 {
return nil, ErrSubjectRejected
}
if users == nil {
return nil, ErrSubjectRejected
}
user, err := users.FindByID(ctx, uint(id))
if err != nil {
return nil, errAuthentication
}
if user == nil {
return nil, ErrSubjectRejected
}
return user, nil
}
// issuedBeforeCutoff reports whether iat predates the user's
// tokens_valid_after cutoff (set by a password reset). A zero cutoff never cuts.
func issuedBeforeCutoff(user *Principal, iat time.Time) bool {
return !user.TokensValidAfter.IsZero() && iat.Before(user.TokensValidAfter)
}
func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
if g.writeFn != nil {
g.writeFn(w, err)

View File

@@ -15,7 +15,7 @@ import (
// jwt-auth: the later of the old exp and iat+refreshTTL, plus one minute, so
// a logged-out token cannot be refreshed again for the rest of its refresh window.
func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
return refreshAudience(secret, tokenString, AudienceUser, true, refreshTTL, bl, grace, issuerURL)
return refreshAudience(secret, tokenString, AudienceUser, true, refreshTTL, bl, grace, issuerURL, nil)
}
// RefreshAudience reissues a token that already carries audience. Missing aud is rejected.
@@ -23,10 +23,37 @@ func RefreshAudience(secret, tokenString, audience string, refreshTTL time.Durat
if strings.TrimSpace(audience) == "" {
return "", errors.New("bouncer: jwt audience is empty")
}
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL)
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL, nil)
}
func refreshAudience(secret, tokenString, audience string, allowMissing bool, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
// RefreshAudienceFor is RefreshAudience plus the JWT guard's subject checks
// before minting: the subject is loaded through users, and a missing,
// deleted or not-activated user, or a token issued before the user's
// TokensValidAfter cutoff, is refused. The lookup runs only after every
// token-only check (signature, audience, refresh window, blacklist, exp)
// passed, and a refused subject neither mints a token nor blacklists the old
// jti. Only subject refusals match errors.Is(err, ErrSubjectRejected); a
// provider failure returns a different error.
func RefreshAudienceFor(ctx context.Context, users UserProvider, secret, tokenString, audience string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
if strings.TrimSpace(audience) == "" {
return "", errors.New("bouncer: jwt audience is empty")
}
check := func(sub string, iat time.Time) error {
user, err := subjectPrincipal(ctx, users, sub)
if err != nil {
return err
}
if issuedBeforeCutoff(user, iat) {
return ErrSubjectRejected
}
return nil
}
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL, check)
}
// refreshAudience holds the shared refresh flow. check, when non-nil, runs
// after every token-only check and immediately before minting.
func refreshAudience(secret, tokenString, audience string, allowMissing bool, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string, check func(sub string, iat time.Time) error) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", errors.New("bouncer: jwt secret is empty")
}
@@ -68,6 +95,11 @@ func refreshAudience(secret, tokenString, audience string, allowMissing bool, re
if !expOK || ttl <= 0 {
return "", errors.New(msgRequiredClaims)
}
if check != nil {
if err := check(sub, iat); err != nil {
return "", err
}
}
next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience)
if err != nil {
return "", err