feat(07-01): add JWT mint, refresh, and blacklist primitives
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
69
bouncer/refresh.go
Normal file
69
bouncer/refresh.go
Normal file
@@ -0,0 +1,69 @@
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
next, _, err := Mint(secret, sub, issuerURL, ttl)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user