feat(07-01): add JWT mint, refresh, and blacklist primitives
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
@@ -65,8 +66,10 @@ func Middleware(secret string, users UserProvider) func(http.Handler) http.Handl
|
||||
}
|
||||
|
||||
type jwtGuard struct {
|
||||
secret string
|
||||
users UserProvider
|
||||
secret string
|
||||
users UserProvider
|
||||
bl BlacklistStore
|
||||
cookieNames []string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -74,19 +77,19 @@ var (
|
||||
_ UnauthorizedWriter = (*jwtGuard)(nil)
|
||||
)
|
||||
|
||||
// NewJWTGuard adapts the existing bearerToken -> Verify -> users.FindByID
|
||||
// chain (identical to Middleware's body) into a Guard + UnauthorizedWriter,
|
||||
// so Registry.Middleware("jwt") is byte-identical to bouncer.Middleware.
|
||||
func NewJWTGuard(secret string, users UserProvider) Guard {
|
||||
return &jwtGuard{secret: secret, users: users}
|
||||
// NewJWTGuard adapts bearer/cookie extraction, VerifyClaims, and users.FindByID
|
||||
// into a Guard + UnauthorizedWriter. bl may be nil. An empty cookieNames list
|
||||
// is Bearer-only; otherwise each name is tried, in order, after the Authorization header.
|
||||
func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard {
|
||||
return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames}
|
||||
}
|
||||
|
||||
func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
||||
raw, err := bearerToken(r)
|
||||
raw, err := extractToken(r, g.cookieNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub, err := Verify(raw, g.secret)
|
||||
sub, iat, _, jti, err := VerifyClaims(raw, g.secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,6 +107,18 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
||||
if user == nil {
|
||||
return nil, errors.New(msgUserNotFound)
|
||||
}
|
||||
if g.bl != nil {
|
||||
blocked, err := g.bl.IsBlacklisted(r.Context(), jti)
|
||||
if err != nil {
|
||||
return nil, errors.New("Authentication error")
|
||||
}
|
||||
if blocked {
|
||||
return nil, errors.New(msgBadSignature)
|
||||
}
|
||||
}
|
||||
if !user.TokensValidAfter.IsZero() && iat.Before(user.TokensValidAfter) {
|
||||
return nil, errors.New(msgUserNotFound)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -131,6 +146,70 @@ func Verify(tokenString, secret string) (string, error) {
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti.
|
||||
func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty")
|
||||
}
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
|
||||
claims := jwt.MapClaims{}
|
||||
_, err = parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
|
||||
}
|
||||
sub = subject(claims)
|
||||
if sub == "" {
|
||||
return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims)
|
||||
}
|
||||
iat, _ = claimTime(claims, "iat")
|
||||
exp, _ = claimTime(claims, "exp")
|
||||
jti, _ = claims["jti"].(string)
|
||||
return sub, iat, exp, jti, nil
|
||||
}
|
||||
|
||||
func extractToken(r *http.Request, cookieNames []string) (string, error) {
|
||||
raw, err := bearerToken(r)
|
||||
if err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
if len(cookieNames) == 0 {
|
||||
return "", err
|
||||
}
|
||||
for _, name := range cookieNames {
|
||||
c, cerr := r.Cookie(name)
|
||||
if cerr != nil {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(c.Value)
|
||||
if v != "" {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New(msgTokenNotProvided)
|
||||
}
|
||||
|
||||
func claimTime(claims jwt.MapClaims, key string) (time.Time, bool) {
|
||||
switch v := claims[key].(type) {
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(int64(v), 0), true
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(n, 0), true
|
||||
case int64:
|
||||
return time.Unix(v, 0), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) (string, error) {
|
||||
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if h == "" {
|
||||
|
||||
Reference in New Issue
Block a user