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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user