Files
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

236 lines
5.9 KiB
Go

package jwks
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"time"
"github.com/rakutentech/jwk-go/jwk"
"github.com/rs/zerolog/log"
"github.com/valyala/fasttemplate"
"golang.org/x/sync/singleflight"
)
const (
_defaultRetries = 2
_defaultTimeout = 1 * time.Second
_defaultMaxIdleConnPerHost = 255
_defaultTTL = 1 * time.Hour
)
// JWK represents an unparsed JSON Web Key (JWK) in its wire format.
type JWK = jwk.JWK
var (
// ErrInvalidURL returned when input url has invalid format.
ErrInvalidURL = errors.New("jwks: invalid url value or format")
// ErrInvalidNumRetries returned when number of retries is zero.
ErrInvalidNumRetries = errors.New("jwks: invalid number of retries")
// ErrKeyIDNotProvided returned when input kid is not present.
ErrKeyIDNotProvided = errors.New("jwks: kid is not provided")
// ErrPublicKeyNotFound returned when no public key is found.
ErrPublicKeyNotFound = errors.New("jwks: public key not found")
errUnexpectedStatusCode = errors.New("jwks: unexpected status code")
errUnmarshal = errors.New("jwks: unmarshal error")
errConvert = errors.New("jwks: convert error")
)
// Manager fetches and returns JWK from public source.
type Manager struct {
url *fasttemplate.Template
cache Cache
client *http.Client
useCache bool
retries uint
group singleflight.Group
}
func defaultHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: _defaultMaxIdleConnPerHost,
},
Timeout: _defaultTimeout,
}
}
// NewManager returns a new instance of Manager.
func NewManager(rawURL string, opts ...Option) (*Manager, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, ErrInvalidURL
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("endpoint must have http:// or https:// scheme, got: %s", rawURL)
}
urlTemplate := fasttemplate.New(rawURL, "{{", "}}")
mng := &Manager{
url: urlTemplate,
cache: NewTTLCache(_defaultTTL),
client: defaultHTTPClient(),
useCache: true,
retries: _defaultRetries,
}
for _, opt := range opts {
opt(mng)
}
if mng.retries == 0 {
return nil, ErrInvalidNumRetries
}
return mng, nil
}
// FetchKey fetches JWKS from public source or cache.
//
// The cache and singleflight keys are scoped to the resolved JWKS endpoint URL,
// not only the JWT header kid. This prevents a key cached from one trust domain
// (e.g., tenant A's templated JWKS URL) from satisfying a verification request
// for a different trust domain (tenant B) that happens to advertise the same kid.
// JWT kid values are not globally unique by spec — common operational labels like
// "default" or rotation IDs may collide across issuers.
func (m *Manager) FetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) {
if kid == "" {
return nil, ErrKeyIDNotProvided
}
jwkURL, err := m.resolveURL(tokenVars)
if err != nil {
return nil, err
}
cacheKey := cacheKey(jwkURL, kid)
// If useCache is true, first try to get key from cache.
if m.useCache {
key, err := m.cache.Get(cacheKey)
if err == nil {
return key, nil
}
}
// Otherwise fetch from public JWKS.
v, err, _ := m.group.Do(cacheKey, func() (any, error) {
return m.fetchKey(ctx, jwkURL, kid)
})
if err != nil {
return nil, err
}
return v.(*JWK), nil
}
// cacheKey builds a cache/singleflight key namespaced by the resolved JWKS URL.
// The NUL byte is a delimiter that cannot appear in a valid URL or JWT kid, so
// the encoding is unambiguous.
func cacheKey(resolvedURL, kid string) string {
return resolvedURL + "\x00" + kid
}
func (m *Manager) loadData(req *http.Request) ([]byte, error) {
resp, err := m.client.Do(req) //nolint:gosec // URL is from server configuration, not user input.
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d", errUnexpectedStatusCode, resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// resolveURL applies tokenVars to the URL template and rejects results with
// path traversal sequences. Runs on every FetchKey call (including cache hits)
// so that cache lookups are scoped to the validated, fully-resolved URL.
func (m *Manager) resolveURL(tokenVars map[string]any) (string, error) {
jwkURL := m.url.ExecuteString(tokenVars)
u, err := url.Parse(jwkURL)
if err != nil {
return "", fmt.Errorf("error parsing JWKS URL: %w", err)
}
if u.Path != "" {
if cleanPath := path.Clean(u.Path); cleanPath != u.Path {
log.Info().Str("path", u.Path).Str("clean_path", cleanPath).
Msg("JWKS URL path contains traversal sequences, request rejected")
return "", fmt.Errorf("JWKS URL path contains traversal sequences: %q", u.Path)
}
}
return jwkURL, nil
}
func (m *Manager) fetchKey(ctx context.Context, jwkURL, kid string) (*JWK, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwkURL, nil)
if err != nil {
return nil, err
}
var set jwk.KeySpecSet
var data []byte
var lastError error
retries := m.retries
for {
if retries == 0 {
return nil, lastError
}
retries--
var err error
data, err = m.loadData(req)
if err != nil {
lastError = err
continue
}
break
}
if err := json.Unmarshal(data, &set); err != nil {
return nil, fmt.Errorf("%w: %v", errUnmarshal, err)
}
if len(set.Keys) == 0 {
return nil, ErrPublicKeyNotFound
}
var res *JWK
// Save new set into cache.
for _, spec := range set.Keys {
key, err := spec.ToJWK()
if err != nil {
return nil, fmt.Errorf("%w: %v", errConvert, err)
}
if key.Use != "sig" {
// Not interested in other types of Use in Centrifugo.
continue
}
if m.useCache {
_ = m.cache.Add(cacheKey(jwkURL, key.Kid), key)
}
if key.Kid == kid {
res = key
}
}
if res == nil {
return nil, ErrPublicKeyNotFound
}
return res, nil
}