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 }