Files
summercms/modules/bouncer/refresh.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

122 lines
4.7 KiB
Go

package bouncer
import (
"context"
"errors"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Refresh issues a new token for a still-refreshable subject. exp is not
// required to be in the future; iat must fall inside refreshTTL. The previous
// jti is blacklisted with validUntil = now+grace. Storage expiry follows PHP
// 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, nil)
}
// RefreshAudience reissues a token that already carries audience. Missing aud is rejected.
func RefreshAudience(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")
}
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL, nil)
}
// 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")
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
claims := jwt.MapClaims{}
if _, err := parser.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) {
return []byte(secret), nil
}); err != nil {
return "", mapJWTError(err)
}
sub := subject(claims)
if sub == "" {
return "", errors.New(msgRequiredClaims)
}
auds := claimAudiences(claims)
if len(auds) == 0 {
if !allowMissing {
return "", errors.New(msgBadSignature)
}
} else if !audienceMatches(claims, audience) {
return "", errors.New(msgBadSignature)
}
iat, ok := claimTime(claims, "iat")
if !ok || time.Now().After(iat.Add(refreshTTL)) {
return "", errors.New("Token has expired and can no longer be refreshed")
}
jti, _ := claims["jti"].(string)
if bl != nil && jti != "" {
blocked, err := bl.IsBlacklisted(context.Background(), jti)
if err != nil {
return "", err
}
if blocked {
return "", errors.New("The token has been blacklisted")
}
}
exp, expOK := claimTime(claims, "exp")
ttl := exp.Sub(iat)
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
}
if bl != nil && jti != "" {
// PHP Blacklist::getMinutesUntilExpired keeps the row until the later of
// exp and iat+refreshTTL, plus one minute. Using exp alone would drop a
// logged-out token whose access exp has passed but whose refresh window
// has not, and the next Refresh would succeed.
expiresAt := iat.Add(refreshTTL).Add(time.Minute)
if until := exp.Add(time.Minute); until.After(expiresAt) {
expiresAt = until
}
if err := bl.Add(context.Background(), jti, expiresAt, time.Now().Add(grace)); err != nil {
return "", err
}
}
return next, nil
}