snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
25
internal/jwtverify/token_decoder.go
Normal file
25
internal/jwtverify/token_decoder.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
var claimsDecoder = &Decoder{}
|
||||
|
||||
type Decoder struct{}
|
||||
|
||||
func (d *Decoder) DecodeConnectClaims(data []byte) (*ConnectTokenClaims, error) {
|
||||
var claims ConnectTokenClaims
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (d *Decoder) DecodeSubscribeClaims(data []byte) (*SubscribeTokenClaims, error) {
|
||||
var claims SubscribeTokenClaims
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
45
internal/jwtverify/token_verifier.go
Normal file
45
internal/jwtverify/token_verifier.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
)
|
||||
|
||||
type Verifier interface {
|
||||
VerifyConnectToken(token string, skipVerify bool) (ConnectToken, error)
|
||||
VerifySubscribeToken(token string, skipVerify bool) (SubscribeToken, error)
|
||||
}
|
||||
|
||||
type ConnectToken struct {
|
||||
// UserID tells an ID of connecting user.
|
||||
UserID string
|
||||
// ExpireAt allows setting time in future when connection must be validated.
|
||||
// Validation can be server-side using On().Refresh callback or client-side
|
||||
// if On().Refresh not set.
|
||||
ExpireAt int64
|
||||
// Info contains additional information about connection. It will be
|
||||
// included into Join/Leave messages, into Alive information, also
|
||||
// info becomes a part of published message if it was published from
|
||||
// client directly. In some cases having additional info can be an
|
||||
// overhead – but you are simply free to not use it.
|
||||
Info []byte
|
||||
// Meta is custom data to append to a connection. Can be retrieved later
|
||||
// for connection inspection. Never sent in a client protocol and accessible
|
||||
// from a backend-side only.
|
||||
Meta json.RawMessage
|
||||
// Subs is a map of channels to subscribe server-side with options.
|
||||
Subs map[string]centrifuge.SubscribeOptions
|
||||
}
|
||||
|
||||
type SubscribeToken struct {
|
||||
// UserID tells an ID of subscribing user.
|
||||
UserID string
|
||||
// Channel client wants to subscribe. Will be compared with channel in
|
||||
// subscribe command.
|
||||
Channel string
|
||||
// Client is a deprecated claim for compatibility with Centrifugo v3.
|
||||
Client string
|
||||
// Options for subscription.
|
||||
Options centrifuge.SubscribeOptions
|
||||
}
|
||||
903
internal/jwtverify/token_verifier_jwt.go
Normal file
903
internal/jwtverify/token_verifier_jwt.go
Normal file
@@ -0,0 +1,903 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/jwks"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/subsource"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/cristalhq/jwt/v5"
|
||||
"github.com/rakutentech/jwk-go/okp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type VerifierConfig struct {
|
||||
// HMACSecretKey is a secret key used to validate connection and subscription
|
||||
// tokens generated using HMAC. Zero value means that HMAC tokens won't be allowed.
|
||||
HMACSecretKey string
|
||||
|
||||
// HMACPreviousSecretKey is the previous HMAC secret key used during key rotation.
|
||||
// When set, tokens that fail verification with HMACSecretKey will be retried with
|
||||
// this key, enabling seamless key rotation without mass disconnects.
|
||||
HMACPreviousSecretKey string
|
||||
|
||||
// HMACPreviousSecretKeyValidUntil is an optional Unix timestamp. If set, the previous
|
||||
// HMAC key will only be used for verification if the current time is before this timestamp.
|
||||
HMACPreviousSecretKeyValidUntil int64
|
||||
|
||||
// RSAPublicKey is a public key used to validate connection and subscription
|
||||
// tokens generated using RSA. Zero value means that RSA tokens won't be allowed.
|
||||
RSAPublicKey *rsa.PublicKey
|
||||
|
||||
// ECDSAPublicKey is a public key used to validate connection and subscription
|
||||
// tokens generated using ECDSA. Zero value means that ECDSA tokens won't be allowed.
|
||||
ECDSAPublicKey *ecdsa.PublicKey
|
||||
|
||||
// JWKSPublicEndpoint is a public url used to validate connection and subscription
|
||||
// tokens generated using rotating RSA public keys. Zero value means that JSON Web Key Sets
|
||||
// extension won't be used.
|
||||
JWKSPublicEndpoint string
|
||||
|
||||
// Audience when set will enable audience token check. See
|
||||
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3.
|
||||
Audience string
|
||||
|
||||
// AudienceRegex allows setting Audience in form of Go language regex pattern. Regex groups
|
||||
// may be then used in constructing JWKSPublicEndpoint.
|
||||
AudienceRegex string
|
||||
|
||||
// Issuer when set will enable a check that token issuer matches configured string.
|
||||
// See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1.
|
||||
Issuer string
|
||||
|
||||
// IssuerRegex allows setting Issuer in form of Go language regex pattern. Regex groups
|
||||
// may be then used in constructing JWKSPublicEndpoint.
|
||||
IssuerRegex string
|
||||
|
||||
// UserIDClaim allows overriding default claim used to extract user ID from token.
|
||||
// By default, Centrifugo uses "sub" and we recommend keeping the default if possible.
|
||||
UserIDClaim string
|
||||
|
||||
// InsecureSkipJWKSEndpointSafetyCheck disables the config-time safety validation of
|
||||
// JWKS endpoint URL templates. This is INSECURE and exists only as a temporary escape
|
||||
// hatch for users upgrading with existing permissive regex patterns. This option will
|
||||
// be removed in a future release.
|
||||
InsecureSkipJWKSEndpointSafetyCheck bool
|
||||
}
|
||||
|
||||
func (c VerifierConfig) Validate() error {
|
||||
if c.Audience != "" && c.AudienceRegex != "" {
|
||||
return errors.New("can not use both token_audience and token_audience_regex, configure only one of them")
|
||||
}
|
||||
if c.Issuer != "" && c.IssuerRegex != "" {
|
||||
return errors.New("can not use both token_issuer and token_issuer_regex, configure only one of them")
|
||||
}
|
||||
if c.JWKSPublicEndpoint != "" {
|
||||
if err := validateJWKSEndpointSafety(c.JWKSPublicEndpoint, c.IssuerRegex, c.AudienceRegex); err != nil {
|
||||
if c.InsecureSkipJWKSEndpointSafetyCheck {
|
||||
log.Warn().Err(err).Msg("JWKS endpoint template safety check skipped — this is INSECURE and the escape hatch will be removed in a future release, please update your regex to use an explicit list of allowed values")
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewTokenVerifierJWT(config VerifierConfig, cfgContainer *config.Container) (*VerifierJWT, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("error validating token verifier config: %w", err)
|
||||
}
|
||||
var audienceRe *regexp.Regexp
|
||||
var issuerRe *regexp.Regexp
|
||||
var err error
|
||||
if config.AudienceRegex != "" {
|
||||
audienceRe, err = regexp.Compile(config.AudienceRegex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error compiling audience regex: %w", err)
|
||||
}
|
||||
}
|
||||
if config.IssuerRegex != "" {
|
||||
issuerRe, err = regexp.Compile(config.IssuerRegex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error compiling issuer regex: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
verifier := &VerifierJWT{
|
||||
cfgContainer: cfgContainer,
|
||||
issuer: config.Issuer,
|
||||
issuerRe: issuerRe,
|
||||
audience: config.Audience,
|
||||
audienceRe: audienceRe,
|
||||
userIDClaim: config.UserIDClaim,
|
||||
}
|
||||
|
||||
if config.JWKSPublicEndpoint != "" {
|
||||
mng, err := jwks.NewManager(config.JWKSPublicEndpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating JWK manager: %w", err)
|
||||
}
|
||||
verifier.jwksManager = &jwksManager{mng}
|
||||
log.Info().Str("endpoint", strings.Join(tools.RedactedLogURLs(config.JWKSPublicEndpoint), ",")).
|
||||
Msg("JWKS manager created")
|
||||
} else {
|
||||
alg, err := newAlgorithms(config.HMACSecretKey, config.RSAPublicKey, config.ECDSAPublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error initializing token algorithms: %w", err)
|
||||
}
|
||||
verifier.algorithms = alg
|
||||
if config.HMACPreviousSecretKey != "" {
|
||||
prevAlg, err := newAlgorithms(config.HMACPreviousSecretKey, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error initializing previous HMAC token algorithms: %w", err)
|
||||
}
|
||||
verifier.previousHMACAlgorithms = prevAlg
|
||||
verifier.hmacPreviousSecretKeyValidUntil = config.HMACPreviousSecretKeyValidUntil
|
||||
}
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
type VerifierJWT struct {
|
||||
mu sync.RWMutex
|
||||
jwksManager *jwksManager
|
||||
algorithms *algorithms
|
||||
previousHMACAlgorithms *algorithms
|
||||
hmacPreviousSecretKeyValidUntil int64
|
||||
cfgContainer *config.Container
|
||||
audience string
|
||||
audienceRe *regexp.Regexp
|
||||
issuer string
|
||||
issuerRe *regexp.Regexp
|
||||
userIDClaim string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
errPublicKeyInvalid = errors.New("public key is invalid")
|
||||
errUnsupportedAlgorithm = errors.New("unsupported JWT algorithm")
|
||||
errDisabledAlgorithm = errors.New("disabled JWT algorithm")
|
||||
)
|
||||
|
||||
// BoolValue allows override boolean option.
|
||||
type BoolValue struct {
|
||||
Value bool `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// SubscribeOptionOverride to override configured behaviour of subscriptions.
|
||||
type SubscribeOptionOverride struct {
|
||||
// Presence turns on participating in channel presence.
|
||||
Presence *BoolValue `json:"presence,omitempty"`
|
||||
// JoinLeave enables sending Join and Leave messages for this client in channel.
|
||||
JoinLeave *BoolValue `json:"join_leave,omitempty"`
|
||||
// ForcePushJoinLeave forces sending join/leave for this client.
|
||||
ForcePushJoinLeave *BoolValue `json:"force_push_join_leave,omitempty"`
|
||||
// ForcePositioning on says that client will additionally sync its position inside
|
||||
// a stream to prevent message loss. Make sure you are enabling ForcePositioning in channels
|
||||
// that maintain Publication history stream. When ForcePositioning is on Centrifuge will
|
||||
// include StreamPosition information to subscribe response - for a client to be able
|
||||
// to manually track its position inside a stream.
|
||||
ForcePositioning *BoolValue `json:"force_positioning,omitempty"`
|
||||
// ForceRecovery turns on recovery option for a channel. In this case client will try to
|
||||
// recover missed messages automatically upon resubscribe to a channel after reconnect
|
||||
// to a server. This option also enables client position tracking inside a stream
|
||||
// (like ForcePositioning option) to prevent occasional message loss. Make sure you are using
|
||||
// ForceRecovery in channels that maintain Publication history stream.
|
||||
ForceRecovery *BoolValue `json:"force_recovery,omitempty"`
|
||||
}
|
||||
|
||||
// SubscribeOptions define per-subscription options.
|
||||
type SubscribeOptions struct {
|
||||
// Info defines custom channel information, zero value means no channel information.
|
||||
Info json.RawMessage `json:"info,omitempty"`
|
||||
// Base64Info is like Info but for binary.
|
||||
Base64Info string `json:"b64info,omitempty"`
|
||||
// Data to send to a client with Subscribe Push.
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
// Base64Data is like Data but for binary data.
|
||||
Base64Data string `json:"b64data,omitempty"`
|
||||
// Override channel options can contain channel options overrides.
|
||||
Override *SubscribeOptionOverride `json:"override,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectTokenClaims struct {
|
||||
ExpireAt *int64 `json:"expire_at,omitempty"`
|
||||
Info json.RawMessage `json:"info,omitempty"`
|
||||
Base64Info string `json:"b64info,omitempty"`
|
||||
Channels []string `json:"channels,omitempty"`
|
||||
Subs map[string]SubscribeOptions `json:"subs,omitempty"`
|
||||
Meta json.RawMessage `json:"meta,omitempty"`
|
||||
// Channel must never be set in connection tokens. We check this on verifying.
|
||||
Channel string `json:"channel,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type SubscribeTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
SubscribeOptions
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Client string `json:"client,omitempty"`
|
||||
ExpireAt *int64 `json:"expire_at,omitempty"`
|
||||
}
|
||||
|
||||
type jwksManager struct{ *jwks.Manager }
|
||||
|
||||
func (j *jwksManager) verify(token *jwt.Token, tokenVars map[string]any) error {
|
||||
kid := token.Header().KeyID
|
||||
|
||||
key, err := j.Manager.FetchKey(context.Background(), kid, tokenVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if key.Kty != "RSA" && key.Kty != "EC" && key.Kty != "OKP" {
|
||||
return errUnsupportedAlgorithm
|
||||
}
|
||||
|
||||
spec, err := key.ParseKeySpec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing key spec: %w", err)
|
||||
}
|
||||
|
||||
switch key.Kty {
|
||||
case "RSA":
|
||||
pubKey, ok := spec.Key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return errPublicKeyInvalid
|
||||
}
|
||||
|
||||
keySetAlgorithm := spec.Algorithm
|
||||
if keySetAlgorithm == "" { // "alg" is optional: https://datatracker.ietf.org/doc/html/rfc7517#section-4.4.
|
||||
switch token.Header().Algorithm {
|
||||
case jwt.RS256, jwt.RS384, jwt.RS512: // Only allow algorithms from RSA family.
|
||||
keySetAlgorithm = string(token.Header().Algorithm)
|
||||
default:
|
||||
return fmt.Errorf("%w: no match in algorithms", errUnsupportedAlgorithm)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := jwt.NewVerifierRS(jwt.Algorithm(keySetAlgorithm), pubKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", errUnsupportedAlgorithm, keySetAlgorithm)
|
||||
}
|
||||
|
||||
return verifier.Verify(token)
|
||||
case "EC":
|
||||
pubKey, ok := spec.Key.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return errPublicKeyInvalid
|
||||
}
|
||||
|
||||
keySetAlgorithm := spec.Algorithm
|
||||
if keySetAlgorithm == "" { // "alg" is optional: https://datatracker.ietf.org/doc/html/rfc7517#section-4.4.
|
||||
switch token.Header().Algorithm {
|
||||
case jwt.ES256, jwt.ES384, jwt.ES512: // Only allow algorithms from EC family.
|
||||
keySetAlgorithm = string(token.Header().Algorithm)
|
||||
default:
|
||||
return fmt.Errorf("%w: no match in algorithms", errUnsupportedAlgorithm)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := jwt.NewVerifierES(jwt.Algorithm(keySetAlgorithm), pubKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", errUnsupportedAlgorithm, keySetAlgorithm)
|
||||
}
|
||||
|
||||
return verifier.Verify(token)
|
||||
case "OKP":
|
||||
pubKey, ok := spec.Key.(okp.Ed25519)
|
||||
if !ok {
|
||||
return errPublicKeyInvalid
|
||||
}
|
||||
|
||||
verifier, err := jwt.NewVerifierEdDSA(pubKey.PublicKey())
|
||||
if err != nil {
|
||||
return errUnsupportedAlgorithm
|
||||
}
|
||||
|
||||
return verifier.Verify(token)
|
||||
default:
|
||||
return errUnsupportedAlgorithm
|
||||
}
|
||||
}
|
||||
|
||||
type algorithms struct {
|
||||
HS256 jwt.Verifier
|
||||
HS384 jwt.Verifier
|
||||
HS512 jwt.Verifier
|
||||
RS256 jwt.Verifier
|
||||
RS384 jwt.Verifier
|
||||
RS512 jwt.Verifier
|
||||
ES256 jwt.Verifier
|
||||
ES384 jwt.Verifier
|
||||
ES512 jwt.Verifier
|
||||
}
|
||||
|
||||
func newAlgorithms(tokenHMACSecretKey string, rsaPubKey *rsa.PublicKey, ecdsaPubKey *ecdsa.PublicKey) (*algorithms, error) {
|
||||
alg := &algorithms{}
|
||||
|
||||
var algorithms []string
|
||||
|
||||
// HMAC SHA.
|
||||
if tokenHMACSecretKey != "" {
|
||||
verifierHS256, err := jwt.NewVerifierHS(jwt.HS256, []byte(tokenHMACSecretKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifierHS384, err := jwt.NewVerifierHS(jwt.HS384, []byte(tokenHMACSecretKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifierHS512, err := jwt.NewVerifierHS(jwt.HS512, []byte(tokenHMACSecretKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alg.HS256 = verifierHS256
|
||||
alg.HS384 = verifierHS384
|
||||
alg.HS512 = verifierHS512
|
||||
algorithms = append(algorithms, []string{"HS256", "HS384", "HS512"}...)
|
||||
}
|
||||
|
||||
// RSA.
|
||||
if rsaPubKey != nil {
|
||||
if verifierRS256, err := jwt.NewVerifierRS(jwt.RS256, rsaPubKey); err != nil {
|
||||
if !errors.Is(err, jwt.ErrInvalidKey) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
alg.RS256 = verifierRS256
|
||||
algorithms = append(algorithms, "RS256")
|
||||
}
|
||||
if verifierRS384, err := jwt.NewVerifierRS(jwt.RS384, rsaPubKey); err != nil {
|
||||
if !errors.Is(err, jwt.ErrInvalidKey) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
alg.RS384 = verifierRS384
|
||||
algorithms = append(algorithms, "RS384")
|
||||
}
|
||||
if verifierRS512, err := jwt.NewVerifierRS(jwt.RS512, rsaPubKey); err != nil {
|
||||
if !errors.Is(err, jwt.ErrInvalidKey) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
alg.RS512 = verifierRS512
|
||||
algorithms = append(algorithms, "RS512")
|
||||
}
|
||||
}
|
||||
|
||||
// ECDSA.
|
||||
if ecdsaPubKey != nil {
|
||||
if verifierES256, err := jwt.NewVerifierES(jwt.ES256, ecdsaPubKey); err != nil {
|
||||
if !errors.Is(err, jwt.ErrInvalidKey) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
alg.ES256 = verifierES256
|
||||
algorithms = append(algorithms, "ES256")
|
||||
}
|
||||
if verifierES384, err := jwt.NewVerifierES(jwt.ES384, ecdsaPubKey); err != nil {
|
||||
if !errors.Is(err, jwt.ErrInvalidKey) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
alg.ES384 = verifierES384
|
||||
algorithms = append(algorithms, "ES384")
|
||||
}
|
||||
if verifierES512, err := jwt.NewVerifierES(jwt.ES512, ecdsaPubKey); err != nil {
|
||||
if !errors.Is(err, jwt.ErrInvalidKey) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
alg.ES512 = verifierES512
|
||||
algorithms = append(algorithms, "ES512")
|
||||
}
|
||||
}
|
||||
|
||||
if len(algorithms) > 0 {
|
||||
log.Info().Str("algorithms", strings.Join(algorithms, ", ")).Msg("enabled JWT verifiers")
|
||||
}
|
||||
|
||||
return alg, nil
|
||||
}
|
||||
|
||||
func (s *algorithms) verify(token *jwt.Token) error {
|
||||
var verifier jwt.Verifier
|
||||
switch token.Header().Algorithm {
|
||||
case jwt.HS256:
|
||||
verifier = s.HS256
|
||||
case jwt.HS384:
|
||||
verifier = s.HS384
|
||||
case jwt.HS512:
|
||||
verifier = s.HS512
|
||||
case jwt.RS256:
|
||||
verifier = s.RS256
|
||||
case jwt.RS384:
|
||||
verifier = s.RS384
|
||||
case jwt.RS512:
|
||||
verifier = s.RS512
|
||||
case jwt.ES256:
|
||||
verifier = s.ES256
|
||||
case jwt.ES384:
|
||||
verifier = s.ES384
|
||||
case jwt.ES512:
|
||||
verifier = s.ES512
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", errUnsupportedAlgorithm, string(token.Header().Algorithm))
|
||||
}
|
||||
if verifier == nil {
|
||||
return fmt.Errorf("%w: %s", errDisabledAlgorithm, string(token.Header().Algorithm))
|
||||
}
|
||||
return verifier.Verify(token)
|
||||
}
|
||||
|
||||
// verifySignature verifies a token's signature. The caller must hold
|
||||
// verifier.mu (at least RLock); it does not lock, to avoid recursive RLock.
|
||||
func (verifier *VerifierJWT) verifySignature(token *jwt.Token) error {
|
||||
err := verifier.algorithms.verify(token)
|
||||
if err != nil && verifier.previousHMACAlgorithms != nil {
|
||||
if validUntil := verifier.hmacPreviousSecretKeyValidUntil; validUntil > 0 && time.Now().Unix() > validUntil {
|
||||
return err
|
||||
}
|
||||
if prevErr := verifier.previousHMACAlgorithms.verify(token); prevErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// verifySignatureByJWK verifies a token against the JWKS manager. The caller
|
||||
// must hold verifier.mu (at least RLock); it does not lock, to avoid recursive
|
||||
// RLock.
|
||||
func (verifier *VerifierJWT) verifySignatureByJWK(token *jwt.Token, tokenVars map[string]any) error {
|
||||
return verifier.jwksManager.verify(token, tokenVars)
|
||||
}
|
||||
|
||||
// extractTokenVars extracts named group matches from issuer/audience regex into tokenVars.
|
||||
// This must be called before signature verification because the extracted values may be
|
||||
// needed to construct the JWKS endpoint URL. Returns an error if regex is configured but
|
||||
// the claim doesn't match.
|
||||
func (verifier *VerifierJWT) extractTokenVars(claims jwt.RegisteredClaims, tokenVars map[string]any) error {
|
||||
if verifier.issuerRe != nil {
|
||||
match := verifier.issuerRe.FindStringSubmatch(claims.Issuer)
|
||||
if len(match) == 0 {
|
||||
return errors.New("issuer not matched")
|
||||
}
|
||||
for i, name := range verifier.issuerRe.SubexpNames() {
|
||||
if i != 0 && name != "" {
|
||||
tokenVars[name] = match[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if verifier.audienceRe != nil {
|
||||
matched := false
|
||||
for _, aud := range claims.Audience {
|
||||
match := verifier.audienceRe.FindStringSubmatch(aud)
|
||||
if len(match) == 0 {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
for i, name := range verifier.audienceRe.SubexpNames() {
|
||||
if i != 0 && name != "" {
|
||||
tokenVars[name] = match[i]
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
return errors.New("audience not matched")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateClaims checks issuer and audience claims against configured values.
|
||||
// Must be called after signature verification.
|
||||
func (verifier *VerifierJWT) validateClaims(claims jwt.RegisteredClaims) error {
|
||||
if verifier.audience != "" && !claims.IsForAudience(verifier.audience) {
|
||||
return errors.New("invalid audience")
|
||||
}
|
||||
|
||||
if verifier.issuer != "" && !claims.IsIssuer(verifier.issuer) {
|
||||
return errors.New("invalid issuer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (verifier *VerifierJWT) VerifyConnectToken(t string, skipVerify bool) (ConnectToken, error) {
|
||||
token, err := jwt.ParseNoVerify([]byte(t)) // Will be verified later.
|
||||
if err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
claims, err := claimsDecoder.DecodeConnectClaims(token.Claims())
|
||||
if err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
// Hold a read lock across the whole verification so a concurrent Reload (which
|
||||
// rewrites the verifier's signing/config fields under mu.Lock) can't change
|
||||
// them mid-verification - reading them unlocked was a data race and could
|
||||
// nil-deref algorithms/jwksManager when a reload switched auth mode. The
|
||||
// signature helpers assume the caller holds this lock and don't re-acquire it
|
||||
// (recursive RLock can deadlock if a writer is waiting).
|
||||
verifier.mu.RLock()
|
||||
defer verifier.mu.RUnlock()
|
||||
|
||||
tokenVars := map[string]any{}
|
||||
|
||||
// Extract token vars before signature verification — needed to construct JWKS URL.
|
||||
if err = verifier.extractTokenVars(claims.RegisteredClaims, tokenVars); err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if !skipVerify {
|
||||
if verifier.jwksManager != nil {
|
||||
err = verifier.verifySignatureByJWK(token, tokenVars)
|
||||
} else {
|
||||
err = verifier.verifySignature(token)
|
||||
}
|
||||
if err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate claims after signature verification.
|
||||
if err = verifier.validateClaims(claims.RegisteredClaims); err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if claims.Channel != "" {
|
||||
return ConnectToken{}, fmt.Errorf(
|
||||
"%w: connection JWT can not contain channel claim, only subscription JWT can", ErrInvalidToken)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !claims.IsValidExpiresAt(now) || !claims.IsValidNotBefore(now) {
|
||||
return ConnectToken{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
subs := map[string]centrifuge.SubscribeOptions{}
|
||||
|
||||
if len(claims.Subs) > 0 {
|
||||
for ch, v := range claims.Subs {
|
||||
_, _, chOpts, found, err := verifier.cfgContainer.ChannelOptions(ch)
|
||||
if err != nil {
|
||||
return ConnectToken{}, err
|
||||
}
|
||||
if !found {
|
||||
return ConnectToken{}, centrifuge.ErrorUnknownChannel
|
||||
}
|
||||
var info []byte
|
||||
if v.Base64Info != "" {
|
||||
byteInfo, err := base64.StdEncoding.DecodeString(v.Base64Info)
|
||||
if err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
info = byteInfo
|
||||
} else {
|
||||
info = v.Info
|
||||
}
|
||||
var data []byte
|
||||
if v.Base64Data != "" {
|
||||
byteInfo, err := base64.StdEncoding.DecodeString(v.Base64Data)
|
||||
if err != nil {
|
||||
return ConnectToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
data = byteInfo
|
||||
} else {
|
||||
data = v.Data
|
||||
}
|
||||
presence := chOpts.Presence
|
||||
if v.Override != nil && v.Override.Presence != nil {
|
||||
presence = v.Override.Presence.Value
|
||||
}
|
||||
joinLeave := chOpts.JoinLeave
|
||||
if v.Override != nil && v.Override.JoinLeave != nil {
|
||||
joinLeave = v.Override.JoinLeave.Value
|
||||
}
|
||||
pushJoinLeave := chOpts.ForcePushJoinLeave
|
||||
if v.Override != nil && v.Override.ForcePushJoinLeave != nil {
|
||||
pushJoinLeave = v.Override.ForcePushJoinLeave.Value
|
||||
}
|
||||
recovery := chOpts.ForceRecovery
|
||||
if v.Override != nil && v.Override.ForceRecovery != nil {
|
||||
recovery = v.Override.ForceRecovery.Value
|
||||
}
|
||||
positioning := chOpts.ForcePositioning
|
||||
if v.Override != nil && v.Override.ForcePositioning != nil {
|
||||
positioning = v.Override.ForcePositioning.Value
|
||||
}
|
||||
recoveryMode := chOpts.GetRecoveryMode()
|
||||
subs[ch] = centrifuge.SubscribeOptions{
|
||||
ChannelInfo: info,
|
||||
EmitPresence: presence,
|
||||
EmitJoinLeave: joinLeave,
|
||||
PushJoinLeave: pushJoinLeave,
|
||||
EnableRecovery: recovery,
|
||||
EnablePositioning: positioning,
|
||||
RecoveryMode: recoveryMode,
|
||||
Data: data,
|
||||
Source: subsource.ConnectionToken,
|
||||
HistoryMetaTTL: chOpts.HistoryMetaTTL.ToDuration(),
|
||||
AllowedDeltaTypes: chOpts.AllowedDeltaTypes,
|
||||
AllowTagsFilter: chOpts.AllowTagsFilter,
|
||||
}
|
||||
}
|
||||
} else if len(claims.Channels) > 0 {
|
||||
for _, ch := range claims.Channels {
|
||||
_, _, chOpts, found, err := verifier.cfgContainer.ChannelOptions(ch)
|
||||
if err != nil {
|
||||
return ConnectToken{}, err
|
||||
}
|
||||
if !found {
|
||||
return ConnectToken{}, centrifuge.ErrorUnknownChannel
|
||||
}
|
||||
subs[ch] = centrifuge.SubscribeOptions{
|
||||
EmitPresence: chOpts.Presence,
|
||||
EmitJoinLeave: chOpts.JoinLeave,
|
||||
PushJoinLeave: chOpts.ForcePushJoinLeave,
|
||||
EnableRecovery: chOpts.ForceRecovery,
|
||||
EnablePositioning: chOpts.ForcePositioning,
|
||||
RecoveryMode: chOpts.GetRecoveryMode(),
|
||||
Source: subsource.ConnectionToken,
|
||||
HistoryMetaTTL: chOpts.HistoryMetaTTL.ToDuration(),
|
||||
AllowedDeltaTypes: chOpts.AllowedDeltaTypes,
|
||||
AllowTagsFilter: chOpts.AllowTagsFilter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var expireAt int64
|
||||
if claims.ExpireAt != nil {
|
||||
if *claims.ExpireAt > 0 {
|
||||
expireAt = *claims.ExpireAt
|
||||
}
|
||||
} else {
|
||||
if claims.ExpiresAt != nil {
|
||||
expireAt = claims.ExpiresAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
var info []byte
|
||||
if claims.Base64Info != "" {
|
||||
byteInfo, err := base64.StdEncoding.DecodeString(claims.Base64Info)
|
||||
if err != nil {
|
||||
return ConnectToken{}, err
|
||||
}
|
||||
info = byteInfo
|
||||
} else {
|
||||
info = claims.Info
|
||||
}
|
||||
|
||||
ct := ConnectToken{
|
||||
Info: info,
|
||||
Subs: subs,
|
||||
ExpireAt: expireAt,
|
||||
Meta: claims.Meta,
|
||||
}
|
||||
if verifier.userIDClaim != "" {
|
||||
value := gjson.GetBytes(token.Claims(), verifier.userIDClaim)
|
||||
ct.UserID = value.String()
|
||||
} else {
|
||||
ct.UserID = claims.RegisteredClaims.Subject
|
||||
}
|
||||
return ct, nil
|
||||
}
|
||||
|
||||
func (verifier *VerifierJWT) VerifySubscribeToken(t string, skipVerify bool) (SubscribeToken, error) {
|
||||
token, err := jwt.ParseNoVerify([]byte(t)) // Will be verified later.
|
||||
if err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
claims, err := claimsDecoder.DecodeSubscribeClaims(token.Claims())
|
||||
if err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
// See VerifyConnectToken: hold a read lock across verification so a concurrent
|
||||
// Reload can't change the signing/config fields mid-verification.
|
||||
verifier.mu.RLock()
|
||||
defer verifier.mu.RUnlock()
|
||||
|
||||
tokenVars := map[string]any{}
|
||||
|
||||
// Extract token vars before signature verification — needed to construct JWKS URL.
|
||||
if err = verifier.extractTokenVars(claims.RegisteredClaims, tokenVars); err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if !skipVerify {
|
||||
if verifier.jwksManager != nil {
|
||||
err = verifier.verifySignatureByJWK(token, tokenVars)
|
||||
} else {
|
||||
err = verifier.verifySignature(token)
|
||||
}
|
||||
if err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate claims after signature verification.
|
||||
if err = verifier.validateClaims(claims.RegisteredClaims); err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !claims.IsValidExpiresAt(now) || !claims.IsValidNotBefore(now) {
|
||||
return SubscribeToken{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
if claims.Channel == "" {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: channel claim is required for subscription JWT", ErrInvalidToken)
|
||||
}
|
||||
|
||||
_, _, chOpts, found, err := verifier.cfgContainer.ChannelOptions(claims.Channel)
|
||||
if err != nil {
|
||||
return SubscribeToken{}, err
|
||||
}
|
||||
if !found {
|
||||
return SubscribeToken{}, centrifuge.ErrorUnknownChannel
|
||||
}
|
||||
var info []byte
|
||||
if claims.Base64Info != "" {
|
||||
byteInfo, err := base64.StdEncoding.DecodeString(claims.Base64Info)
|
||||
if err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
info = byteInfo
|
||||
} else {
|
||||
info = claims.Info
|
||||
}
|
||||
var data []byte
|
||||
if claims.Base64Data != "" {
|
||||
byteInfo, err := base64.StdEncoding.DecodeString(claims.Base64Data)
|
||||
if err != nil {
|
||||
return SubscribeToken{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
|
||||
}
|
||||
data = byteInfo
|
||||
} else {
|
||||
data = claims.Data
|
||||
}
|
||||
presence := chOpts.Presence
|
||||
if claims.Override != nil && claims.Override.Presence != nil {
|
||||
presence = claims.Override.Presence.Value
|
||||
}
|
||||
joinLeave := chOpts.JoinLeave
|
||||
if claims.Override != nil && claims.Override.JoinLeave != nil {
|
||||
joinLeave = claims.Override.JoinLeave.Value
|
||||
}
|
||||
pushJoinLeave := chOpts.ForcePushJoinLeave
|
||||
if claims.Override != nil && claims.Override.ForcePushJoinLeave != nil {
|
||||
pushJoinLeave = claims.Override.ForcePushJoinLeave.Value
|
||||
}
|
||||
recovery := chOpts.ForceRecovery
|
||||
if claims.Override != nil && claims.Override.ForceRecovery != nil {
|
||||
recovery = claims.Override.ForceRecovery.Value
|
||||
}
|
||||
positioning := chOpts.ForcePositioning
|
||||
if claims.Override != nil && claims.Override.ForcePositioning != nil {
|
||||
positioning = claims.Override.ForcePositioning.Value
|
||||
}
|
||||
recoveryMode := chOpts.GetRecoveryMode()
|
||||
|
||||
var expireAt int64
|
||||
if claims.ExpireAt != nil {
|
||||
if *claims.ExpireAt > 0 {
|
||||
expireAt = *claims.ExpireAt
|
||||
}
|
||||
} else {
|
||||
if claims.ExpiresAt != nil {
|
||||
expireAt = claims.ExpiresAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
st := SubscribeToken{
|
||||
UserID: claims.RegisteredClaims.Subject,
|
||||
Channel: claims.Channel,
|
||||
Client: claims.Client,
|
||||
Options: centrifuge.SubscribeOptions{
|
||||
ExpireAt: expireAt,
|
||||
ChannelInfo: info,
|
||||
EmitPresence: presence,
|
||||
EmitJoinLeave: joinLeave,
|
||||
PushJoinLeave: pushJoinLeave,
|
||||
EnableRecovery: recovery,
|
||||
EnablePositioning: positioning,
|
||||
RecoveryMode: recoveryMode,
|
||||
AllowedDeltaTypes: chOpts.AllowedDeltaTypes,
|
||||
AllowTagsFilter: chOpts.AllowTagsFilter,
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
if verifier.userIDClaim != "" {
|
||||
value := gjson.GetBytes(token.Claims(), verifier.userIDClaim)
|
||||
st.UserID = value.String()
|
||||
} else {
|
||||
st.UserID = claims.RegisteredClaims.Subject
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (verifier *VerifierJWT) Reload(config VerifierConfig) error {
|
||||
if err := config.Validate(); err != nil {
|
||||
return fmt.Errorf("error validating token verifier config: %w", err)
|
||||
}
|
||||
|
||||
verifier.mu.Lock()
|
||||
defer verifier.mu.Unlock()
|
||||
|
||||
var audienceRe *regexp.Regexp
|
||||
var issuerRe *regexp.Regexp
|
||||
if config.AudienceRegex != "" {
|
||||
var err error
|
||||
audienceRe, err = regexp.Compile(config.AudienceRegex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling audience regex: %w", err)
|
||||
}
|
||||
}
|
||||
if config.IssuerRegex != "" {
|
||||
var err error
|
||||
issuerRe, err = regexp.Compile(config.IssuerRegex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling issuer regex: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.JWKSPublicEndpoint != "" {
|
||||
mng, err := jwks.NewManager(config.JWKSPublicEndpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating JWK manager: %w", err)
|
||||
}
|
||||
verifier.jwksManager = &jwksManager{mng}
|
||||
verifier.algorithms = nil
|
||||
verifier.previousHMACAlgorithms = nil
|
||||
verifier.hmacPreviousSecretKeyValidUntil = 0
|
||||
} else {
|
||||
alg, err := newAlgorithms(config.HMACSecretKey, config.RSAPublicKey, config.ECDSAPublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verifier.algorithms = alg
|
||||
verifier.jwksManager = nil
|
||||
if config.HMACPreviousSecretKey != "" {
|
||||
prevAlg, err := newAlgorithms(config.HMACPreviousSecretKey, nil, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error initializing previous HMAC token algorithms: %w", err)
|
||||
}
|
||||
verifier.previousHMACAlgorithms = prevAlg
|
||||
verifier.hmacPreviousSecretKeyValidUntil = config.HMACPreviousSecretKeyValidUntil
|
||||
} else {
|
||||
verifier.previousHMACAlgorithms = nil
|
||||
verifier.hmacPreviousSecretKeyValidUntil = 0
|
||||
}
|
||||
}
|
||||
|
||||
verifier.audience = config.Audience
|
||||
verifier.audienceRe = audienceRe
|
||||
verifier.issuer = config.Issuer
|
||||
verifier.issuerRe = issuerRe
|
||||
verifier.userIDClaim = config.UserIDClaim
|
||||
return nil
|
||||
}
|
||||
115
internal/jwtverify/token_verifier_jwt_jwks_scope_test.go
Normal file
115
internal/jwtverify/token_verifier_jwt_jwks_scope_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
|
||||
"github.com/cristalhq/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestJWKSCacheKeyIsScopedToTemplatedEndpoint is a regression test for a
|
||||
// cross-issuer JWT verification bypass. With templated JWKS endpoints
|
||||
// (e.g., per-tenant URLs derived from the JWT iss claim), the JWKS cache
|
||||
// must scope entries by the resolved endpoint, not only by the JWT header
|
||||
// kid. Otherwise, a key cached from tenant A's endpoint could be used to
|
||||
// verify a token forged for tenant B if both JWKS documents advertise the
|
||||
// same kid — kid is not globally unique by spec.
|
||||
//
|
||||
// Scenario: an attacker controls tenant A and mints a token claiming
|
||||
// issuer "tenant-b" but signed with tenant A's key. Tenant A's key is
|
||||
// already cached. The verifier must NOT accept the forged token.
|
||||
func TestJWKSCacheKeyIsScopedToTemplatedEndpoint(t *testing.T) {
|
||||
const kid = "shared-kid"
|
||||
|
||||
tenantAPrivateKey, tenantAPublicKey := generateTestRSAKeys(t)
|
||||
_, tenantBPublicKey := generateTestRSAKeys(t)
|
||||
|
||||
var tenantARequests, tenantBRequests int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/tenant-a/jwks.json":
|
||||
atomic.AddInt32(&tenantARequests, 1)
|
||||
writeRSAJWKS(t, w, tenantAPublicKey, kid)
|
||||
case "/tenant-b/jwks.json":
|
||||
atomic.AddInt32(&tenantBRequests, 1)
|
||||
writeRSAJWKS(t, w, tenantBPublicKey, kid)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
verifier, err := NewTokenVerifierJWT(VerifierConfig{
|
||||
JWKSPublicEndpoint: ts.URL + "/{{tenant}}/jwks.json",
|
||||
IssuerRegex: `^(?P<tenant>tenant-a|tenant-b)$`,
|
||||
}, cfgContainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
legitimateTenantAToken := getRSAIssuerConnToken(t, "tenant-a-user", "tenant-a", tenantAPrivateKey, kid)
|
||||
forgedTenantBToken := getRSAIssuerConnToken(t, "victim", "tenant-b", tenantAPrivateKey, kid)
|
||||
|
||||
// Step 1: Legitimate tenant-A token primes tenant A's key in the cache.
|
||||
ct, err := verifier.VerifyConnectToken(legitimateTenantAToken, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "tenant-a-user", ct.UserID)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&tenantARequests))
|
||||
require.Equal(t, int32(0), atomic.LoadInt32(&tenantBRequests))
|
||||
|
||||
// Step 2: Forged tenant-B token (signed by tenant A) must be rejected.
|
||||
// The verifier must consult tenant B's JWKS endpoint, fetch tenant B's
|
||||
// public key, and reject the signature.
|
||||
_, err = verifier.VerifyConnectToken(forgedTenantBToken, false)
|
||||
require.Error(t, err, "forged cross-tenant token must be rejected")
|
||||
require.ErrorIs(t, err, ErrInvalidToken)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&tenantBRequests),
|
||||
"verifier must fetch tenant B's JWKS rather than reuse cached tenant A key")
|
||||
}
|
||||
|
||||
func writeRSAJWKS(t *testing.T, w http.ResponseWriter, pubKey *rsa.PublicKey, kid string) {
|
||||
t.Helper()
|
||||
resp := map[string]any{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"alg": "RS256",
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": kid,
|
||||
"n": encodeToString(pubKey.N.Bytes()),
|
||||
"e": encodeUint64ToString(uint64(pubKey.E)),
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(resp))
|
||||
}
|
||||
|
||||
func getRSAIssuerConnToken(t *testing.T, user, issuer string, rsaPrivateKey *rsa.PrivateKey, kid string) string {
|
||||
t.Helper()
|
||||
signer, err := jwt.NewSignerRS(jwt.RS256, rsaPrivateKey)
|
||||
require.NoError(t, err)
|
||||
builder := jwt.NewBuilder(signer, jwt.WithKeyID(kid))
|
||||
claims := &ConnectTokenClaims{
|
||||
Base64Info: "e30=",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user,
|
||||
Issuer: issuer,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
token, err := builder.Build(claims)
|
||||
require.NoError(t, err)
|
||||
return token.String()
|
||||
}
|
||||
61
internal/jwtverify/token_verifier_jwt_race_test.go
Normal file
61
internal/jwtverify/token_verifier_jwt_race_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVerifierConcurrentReloadRace guards against a data race between token
|
||||
// verification and a concurrent config reload (SIGHUP). VerifyConnectToken /
|
||||
// VerifySubscribeToken used to read the verifier's signing/config fields unlocked
|
||||
// while Reload rewrites them under mu.Lock - a data race that could also nil-deref
|
||||
// algorithms/jwksManager when a reload switched auth mode. Verification now holds
|
||||
// verifier.mu.RLock for its whole body. Run with -race.
|
||||
func TestVerifierConcurrentReloadRace(t *testing.T) {
|
||||
cfgContainer, err := config.NewContainer(config.DefaultConfig())
|
||||
require.NoError(t, err)
|
||||
verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer)
|
||||
require.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
var writerWg, readersWg sync.WaitGroup
|
||||
|
||||
// Writer reloads the verifier config repeatedly, as a SIGHUP would.
|
||||
writerWg.Add(1)
|
||||
go func() {
|
||||
defer writerWg.Done()
|
||||
configs := []VerifierConfig{
|
||||
{HMACSecretKey: "secret"},
|
||||
{HMACSecretKey: "secret2", HMACPreviousSecretKey: "secret"},
|
||||
{HMACSecretKey: "secret", Audience: "aud", Issuer: "iss"},
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = verifier.Reload(configs[i%len(configs)])
|
||||
}
|
||||
}()
|
||||
|
||||
// Readers verify concurrently; results are irrelevant, only -race matters.
|
||||
for r := 0; r < 4; r++ {
|
||||
readersWg.Add(1)
|
||||
go func() {
|
||||
defer readersWg.Done()
|
||||
for i := 0; i < 3000; i++ {
|
||||
_, _ = verifier.VerifyConnectToken(jwtValid, false)
|
||||
_, _ = verifier.VerifySubscribeToken(subJWTValidCustomUserClaim, false)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
readersWg.Wait()
|
||||
close(done)
|
||||
writerWg.Wait()
|
||||
}
|
||||
1106
internal/jwtverify/token_verifier_jwt_test.go
Normal file
1106
internal/jwtverify/token_verifier_jwt_test.go
Normal file
File diff suppressed because it is too large
Load Diff
154
internal/jwtverify/validate.go
Normal file
154
internal/jwtverify/validate.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp/syntax"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extractTemplatePlaceholders returns the names of all {{name}} placeholders in s.
|
||||
func extractTemplatePlaceholders(s string) []string {
|
||||
var result []string
|
||||
for {
|
||||
start := strings.Index(s, "{{")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
rest := s[start+2:]
|
||||
end := strings.Index(rest, "}}")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
result = append(result, rest[:end])
|
||||
s = rest[end+2:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// findNamedGroupSubTrees parses a regex pattern and returns a map from each named
|
||||
// capture group to its parsed sub-expression AST. We work with AST nodes directly
|
||||
// to avoid round-tripping through String() which can introduce optimizations like
|
||||
// character classes (e.g., "test1|test2" → "test[12]") that change the structure.
|
||||
func findNamedGroupSubTrees(pattern string) (map[string]*syntax.Regexp, error) {
|
||||
re, err := syntax.Parse(pattern, syntax.Perl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := make(map[string]*syntax.Regexp)
|
||||
var walk func(re *syntax.Regexp)
|
||||
walk = func(re *syntax.Regexp) {
|
||||
if re.Op == syntax.OpCapture && re.Name != "" {
|
||||
if len(re.Sub) > 0 {
|
||||
groups[re.Name] = re.Sub[0]
|
||||
}
|
||||
}
|
||||
for _, sub := range re.Sub {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(re)
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// isLiteralTree returns true if the regexp AST can only match a finite set of fixed
|
||||
// strings — no wildcards (dot), repetitions (star, plus, quest), or open-ended
|
||||
// constructs. The following ops are accepted:
|
||||
// - OpLiteral, OpEmptyMatch — fixed content.
|
||||
// - OpConcat, OpCapture, OpAlternate — structural; safe if all children are safe.
|
||||
// - OpCharClass — matches exactly one rune from a finite set. The Go regexp parser
|
||||
// produces these when factoring common prefixes from alternations of literals
|
||||
// (e.g., "test1|test2" → Concat(Literal("test"), CharClass("12"))).
|
||||
func isLiteralTree(re *syntax.Regexp) bool {
|
||||
switch re.Op {
|
||||
case syntax.OpLiteral, syntax.OpEmptyMatch, syntax.OpCharClass:
|
||||
return true
|
||||
case syntax.OpConcat, syntax.OpCapture, syntax.OpAlternate:
|
||||
for _, sub := range re.Sub {
|
||||
if !isLiteralTree(sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// rejectCharClassSyntax checks whether a regex pattern contains character class syntax
|
||||
// that is not allowed in issuer/audience regexes when JWKS endpoint templates are used.
|
||||
// This catches both bracket syntax ([...]) and shorthand classes (\d, \w, \s, \D, \W, \S)
|
||||
// as well as Unicode property classes (\p, \P).
|
||||
func rejectCharClassSyntax(pattern string) error {
|
||||
for i := 0; i < len(pattern); i++ {
|
||||
switch pattern[i] {
|
||||
case '[':
|
||||
return fmt.Errorf("contains character class syntax which is not allowed when JWKS endpoint URL template is used")
|
||||
case '\\':
|
||||
if i+1 < len(pattern) {
|
||||
switch pattern[i+1] {
|
||||
case 'd', 'D', 'w', 'W', 's', 'S', 'p', 'P':
|
||||
return fmt.Errorf("contains character class shorthand (\\%c) which is not allowed when JWKS endpoint URL template is used", pattern[i+1])
|
||||
}
|
||||
i++ // skip escaped char
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateJWKSEndpointSafety checks that regex named groups used as JWKS endpoint
|
||||
// template placeholders are explicit alternations of fixed strings (e.g.,
|
||||
// "value1|value2|value3"). This is required regardless of URL position because
|
||||
// template values come from unverified JWT claims — a permissive pattern would let
|
||||
// an attacker substitute an arbitrary value and redirect JWKS key fetches to a server
|
||||
// they control (whether via host, subdomain on a shared cloud platform, or path on
|
||||
// a shared auth service). Runtime path.Clean protection in the JWKS manager provides
|
||||
// additional defense-in-depth against path traversal.
|
||||
func validateJWKSEndpointSafety(endpoint, issuerRegex, audienceRegex string) error {
|
||||
placeholders := extractTemplatePlaceholders(endpoint)
|
||||
if len(placeholders) == 0 {
|
||||
return nil
|
||||
}
|
||||
placeholderSet := make(map[string]struct{}, len(placeholders))
|
||||
for _, p := range placeholders {
|
||||
placeholderSet[p] = struct{}{}
|
||||
}
|
||||
for _, regexConfig := range []struct {
|
||||
name string
|
||||
pattern string
|
||||
}{
|
||||
{"issuer_regex", issuerRegex},
|
||||
{"audience_regex", audienceRegex},
|
||||
} {
|
||||
if regexConfig.pattern == "" {
|
||||
continue
|
||||
}
|
||||
// Reject character classes in the source pattern. The regexp parser
|
||||
// may produce OpCharClass from alternation optimization (e.g., "test1|test2"
|
||||
// → CharClass("12")), but user-written character classes must not be allowed.
|
||||
// This covers bracket syntax ([...]) and shorthand classes (\d, \w, \s, etc.).
|
||||
if err := rejectCharClassSyntax(regexConfig.pattern); err != nil {
|
||||
return fmt.Errorf(
|
||||
"%s %w — use an explicit list of allowed values "+
|
||||
"(e.g., value1|value2|value3)",
|
||||
regexConfig.name, err)
|
||||
}
|
||||
groups, err := findNamedGroupSubTrees(regexConfig.pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing %s for JWKS safety check: %w", regexConfig.name, err)
|
||||
}
|
||||
for groupName, subTree := range groups {
|
||||
if _, ok := placeholderSet[groupName]; !ok {
|
||||
continue
|
||||
}
|
||||
if !isLiteralTree(subTree) {
|
||||
return fmt.Errorf(
|
||||
"%s named group %q is used in JWKS endpoint URL template — "+
|
||||
"only an explicit list of allowed values is permitted "+
|
||||
"(e.g., value1|value2|value3)",
|
||||
regexConfig.name, groupName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
388
internal/jwtverify/validate_test.go
Normal file
388
internal/jwtverify/validate_test.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package jwtverify
|
||||
|
||||
import (
|
||||
"regexp/syntax"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_extractTemplatePlaceholders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
want []string
|
||||
}{
|
||||
{"no placeholders", "https://example.com/.well-known/jwks.json", nil},
|
||||
{"single", "https://{{tenant}}.example.com/jwks", []string{"tenant"}},
|
||||
{"multiple", "https://{{host}}/{{path}}/jwks", []string{"host", "path"}},
|
||||
{"empty template", "https://{{}}/.well-known/jwks.json", []string{""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractTemplatePlaceholders(tt.s)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isLiteralTree(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
// --- Should pass (finite fixed strings) ---
|
||||
{"single literal", `host\.example\.com`, true},
|
||||
{"explicit alternation", `host1\.example\.com|host2\.example\.com`, true},
|
||||
{"three alternatives", `aaa|bbb|ccc`, true},
|
||||
{"single char", `a`, true},
|
||||
{"empty pattern", ``, true},
|
||||
|
||||
// Common prefix factoring produces CharClass or nested Alternate.
|
||||
{"common prefix single char suffix", `test1|test2`, true},
|
||||
{"common prefix multi-option suffix", `ab|ac|ad`, true},
|
||||
{"single char alternation becomes CharClass", `a|b|c`, true},
|
||||
{"hex escape alternation", `foo\x41|foo\x42`, true},
|
||||
|
||||
// Prefix factoring with one branch empty (prefix-of-prefix).
|
||||
{"one is prefix of other", `fo|foo`, true},
|
||||
{"short and long", `test|testing`, true},
|
||||
{"prefix chain", `a|ab|abc`, true},
|
||||
{"alternation with empty branch", `|foo`, true},
|
||||
{"alternation with trailing empty", `foo|`, true},
|
||||
|
||||
// Escaped special chars (dots) — should be literals.
|
||||
{"escaped dots in alternation", `auth\.example\.com|auth\.other\.com`, true},
|
||||
{"escaped dots multi-segment", `auth\.us\.example\.com|auth\.eu\.example\.com|auth\.ap\.example\.com`, true},
|
||||
{"escaped dots in path-like values", `org\.acme\.production|org\.globex\.staging`, true},
|
||||
|
||||
// Nested unnamed captures.
|
||||
{"nested unnamed groups", `(foo|bar)baz|(foo|bar)qux`, true},
|
||||
|
||||
// --- Should fail (open-ended patterns) ---
|
||||
{"character class with plus", `[a-zA-Z0-9-]+`, false},
|
||||
{"dot plus", `.+`, false},
|
||||
{"dot star", `.*`, false},
|
||||
{"word class with plus", `\w+`, false},
|
||||
{"mixed literal and class", `host1\.example\.com|[a-z]+`, false},
|
||||
{"any char", `.`, false},
|
||||
{"question mark (optional)", `foo?`, false},
|
||||
{"star", `a*`, false},
|
||||
{"plus", `a+`, false},
|
||||
{"repeat", `a{3}`, false},
|
||||
{"dot in alternation", `foo|.`, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
re, err := syntax.Parse(tt.pattern, syntax.Perl)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, isLiteralTree(re), "pattern: %s, AST: %s", tt.pattern, re)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_findNamedGroupSubTrees(t *testing.T) {
|
||||
groups, err := findNamedGroupSubTrees(`^https://(?P<tenant>[a-zA-Z0-9-]+)\.example\.com$`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, 1)
|
||||
require.Contains(t, groups, "tenant")
|
||||
|
||||
groups, err = findNamedGroupSubTrees(`^https://(.+)$`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, 0) // unnamed group
|
||||
|
||||
groups, err = findNamedGroupSubTrees(`^(?P<a>[a-z]+)-(?P<b>.+)$`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, 2)
|
||||
}
|
||||
|
||||
func Test_validateJWKSEndpointSafety(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
issuerRegex string
|
||||
audienceRegex string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
// --- General cases ---
|
||||
{
|
||||
name: "no placeholders - always safe",
|
||||
endpoint: "https://example.com/.well-known/jwks.json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "placeholder but no regex - safe",
|
||||
endpoint: "https://{{tenant}}.example.com/jwks",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "named group not used as placeholder - safe even if permissive",
|
||||
endpoint: "https://fixed-host.example.com/.well-known/jwks.json",
|
||||
issuerRegex: `^https://(?P<anything>.+)\.example\.com$`,
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// --- Host position (entire host and subdomain) ---
|
||||
{
|
||||
name: "entire host with explicit alternation - safe",
|
||||
endpoint: "https://{{host}}/.well-known/jwks.json",
|
||||
issuerRegex: `^(?P<host>auth1\.example\.com|auth2\.example\.com)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "entire host with single literal - safe",
|
||||
endpoint: "https://{{host}}/.well-known/jwks.json",
|
||||
issuerRegex: `^(?P<host>auth\.example\.com)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "entire host with character class - unsafe",
|
||||
endpoint: "https://{{host}}/.well-known/jwks.json",
|
||||
issuerRegex: `^(?P<host>[a-zA-Z0-9.-]+)$`,
|
||||
wantErr: true,
|
||||
errContains: "JWKS endpoint URL template",
|
||||
},
|
||||
{
|
||||
name: "entire host with dot-plus - unsafe",
|
||||
endpoint: "https://{{host}}/.well-known/jwks.json",
|
||||
issuerRegex: `^(?P<host>.+)$`,
|
||||
wantErr: true,
|
||||
errContains: "JWKS endpoint URL template",
|
||||
},
|
||||
{
|
||||
name: "subdomain with explicit alternation - safe",
|
||||
endpoint: "https://{{tenant}}.example.com/.well-known/jwks.json",
|
||||
issuerRegex: `^https://(?P<tenant>acme|globex|initech)\.example\.com$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "subdomain with character class - unsafe",
|
||||
endpoint: "https://{{tenant}}.example.com/.well-known/jwks.json",
|
||||
issuerRegex: `^https://(?P<tenant>[a-zA-Z0-9-]+)\.example\.com$`,
|
||||
wantErr: true,
|
||||
errContains: "JWKS endpoint URL template",
|
||||
},
|
||||
{
|
||||
name: "audience regex with wildcard - unsafe",
|
||||
endpoint: "https://{{realm}}.auth.example.com/.well-known/jwks.json",
|
||||
audienceRegex: `^(?P<realm>\S+)\.auth\.example\.com$`,
|
||||
wantErr: true,
|
||||
errContains: "audience_regex",
|
||||
},
|
||||
{
|
||||
name: "audience regex with alternation - safe",
|
||||
endpoint: "https://{{realm}}.auth.example.com/.well-known/jwks.json",
|
||||
audienceRegex: `^(?P<realm>us|eu|ap)\.auth\.example\.com$`,
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// --- Path position ---
|
||||
{
|
||||
name: "path with explicit alternation - safe",
|
||||
endpoint: "https://fixed-host.com/tenants/{{tenant}}/jwks.json",
|
||||
issuerRegex: `^(?P<tenant>acme|globex|initech)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path with single literal - safe",
|
||||
endpoint: "https://fixed-host.com/tenants/{{tenant}}/jwks.json",
|
||||
issuerRegex: `^(?P<tenant>production)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path with character class - unsafe",
|
||||
endpoint: "https://fixed-host.com/tenants/{{tenant}}/jwks.json",
|
||||
issuerRegex: `^(?P<tenant>[a-zA-Z0-9_-]+)$`,
|
||||
wantErr: true,
|
||||
errContains: "JWKS endpoint URL template",
|
||||
},
|
||||
{
|
||||
name: "path with dot-plus - unsafe",
|
||||
endpoint: "https://fixed-host.com/tenants/{{tenant}}/jwks.json",
|
||||
issuerRegex: `^(?P<tenant>.+)$`,
|
||||
wantErr: true,
|
||||
errContains: "JWKS endpoint URL template",
|
||||
},
|
||||
|
||||
// --- Common prefix factoring ---
|
||||
// Go regexp parser factors common prefixes and may convert single-char
|
||||
// alternations into CharClass nodes. We work with the AST directly to
|
||||
// handle these correctly.
|
||||
{
|
||||
name: "common prefix single char suffix - safe",
|
||||
endpoint: "https://keycloak:443/{{realm}}/protocol/openid-connect/certs",
|
||||
issuerRegex: `https://example.com/auth/realms/(?P<realm>test1|test2)`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "common prefix longer suffix - safe",
|
||||
endpoint: "https://keycloak:443/{{realm}}/protocol/openid-connect/certs",
|
||||
issuerRegex: `https://example.com/auth/realms/(?P<realm>production|prod-staging)`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "one value is prefix of another - safe",
|
||||
endpoint: "https://keycloak:443/{{realm}}/protocol/openid-connect/certs",
|
||||
issuerRegex: `https://example.com/auth/realms/(?P<realm>test|testing)`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "three values with common prefix - safe",
|
||||
endpoint: "https://host/{{env}}/jwks",
|
||||
issuerRegex: `^(?P<env>dev1|dev2|dev3)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "single char alternation - safe",
|
||||
endpoint: "https://host/{{zone}}/jwks",
|
||||
issuerRegex: `^(?P<zone>a|b|c)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "prefix chain - safe",
|
||||
endpoint: "https://host/{{tier}}/jwks",
|
||||
issuerRegex: `^(?P<tier>a|ab|abc)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "escaped dots in host alternation - safe",
|
||||
endpoint: "https://{{host}}/jwks.json",
|
||||
issuerRegex: `^(?P<host>auth\.example\.com|auth\.other\.com)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "escaped dots in multi-segment host alternation - safe",
|
||||
endpoint: "https://{{host}}/protocol/openid-connect/certs",
|
||||
issuerRegex: `^https://(?P<host>auth\.us\.example\.com|auth\.eu\.example\.com|auth\.ap\.example\.com)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "escaped dots in path values - safe",
|
||||
endpoint: "https://example.com/{{realm}}/jwks.json",
|
||||
issuerRegex: `^https://example.com/(?P<realm>org\.acme\.production|org\.globex\.staging)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// --- Patterns that must remain unsafe ---
|
||||
{
|
||||
name: "optional suffix - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>foo?)$`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "repetition - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>a{3})$`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "star - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>test.*)$`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "any char in alternation - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>foo|.)$`,
|
||||
wantErr: true,
|
||||
},
|
||||
|
||||
// --- Shorthand character classes ---
|
||||
{
|
||||
name: "digit shorthand - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>\d+)$`,
|
||||
wantErr: true,
|
||||
errContains: `\d`,
|
||||
},
|
||||
{
|
||||
name: "word shorthand - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>\w+)$`,
|
||||
wantErr: true,
|
||||
errContains: `\w`,
|
||||
},
|
||||
{
|
||||
name: "space shorthand - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>\s)$`,
|
||||
wantErr: true,
|
||||
errContains: `\s`,
|
||||
},
|
||||
{
|
||||
name: "non-digit shorthand - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>\D+)$`,
|
||||
wantErr: true,
|
||||
errContains: `\D`,
|
||||
},
|
||||
{
|
||||
name: "unicode property - unsafe",
|
||||
endpoint: "https://host/{{realm}}/jwks",
|
||||
issuerRegex: `^(?P<realm>\pL+)$`,
|
||||
wantErr: true,
|
||||
errContains: `\p`,
|
||||
},
|
||||
{
|
||||
name: "escaped dot is fine - safe",
|
||||
endpoint: "https://{{host}}/jwks",
|
||||
issuerRegex: `^(?P<host>auth\.example\.com)$`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateJWKSEndpointSafety(tt.endpoint, tt.issuerRegex, tt.audienceRegex)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
require.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validateJWKSEndpointSafety_IntegrationWithValidate(t *testing.T) {
|
||||
// Character class rejected for any position.
|
||||
cfg := VerifierConfig{
|
||||
JWKSPublicEndpoint: "https://{{host}}/.well-known/jwks.json",
|
||||
IssuerRegex: `^(?P<host>[a-z0-9.-]+)$`,
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "JWKS endpoint URL template")
|
||||
|
||||
// Explicit alternation passes.
|
||||
cfg.IssuerRegex = `^(?P<host>auth\.example\.com|auth\.other\.com)$`
|
||||
err = cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Path: character class also rejected.
|
||||
cfg = VerifierConfig{
|
||||
JWKSPublicEndpoint: "https://fixed-host.com/{{tenant}}/jwks.json",
|
||||
IssuerRegex: `^(?P<tenant>[a-zA-Z0-9_-]+)$`,
|
||||
}
|
||||
err = cfg.Validate()
|
||||
require.Error(t, err)
|
||||
|
||||
// Path: explicit alternation passes.
|
||||
cfg.IssuerRegex = `^(?P<tenant>acme|globex)$`
|
||||
err = cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insecure escape hatch: unsafe config passes validation when skip flag is set.
|
||||
cfg = VerifierConfig{
|
||||
JWKSPublicEndpoint: "https://{{host}}/.well-known/jwks.json",
|
||||
IssuerRegex: `^(?P<host>[a-z0-9.-]+)$`,
|
||||
InsecureSkipJWKSEndpointSafetyCheck: true,
|
||||
}
|
||||
err = cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user