Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
180 lines
4.5 KiB
Go
180 lines
4.5 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package jwksx
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"time"
|
|
|
|
"github.com/ory/herodot"
|
|
|
|
"github.com/hashicorp/go-retryablehttp"
|
|
|
|
"github.com/ory/x/fetcher"
|
|
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/ory/x/otelx"
|
|
|
|
"github.com/dgraph-io/ristretto/v2"
|
|
"github.com/lestrrat-go/jwx/jwk"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
var ErrUnableToFindKeyID = errors.New("specified JWK kid can not be found in the JWK sets")
|
|
|
|
type (
|
|
fetcherNextOptions struct {
|
|
forceKID string
|
|
cacheTTL time.Duration
|
|
useCache bool
|
|
httpClient *retryablehttp.Client
|
|
schemes []string
|
|
}
|
|
// FetcherNext is a JWK fetcher that can be used to fetch JWKs from multiple locations.
|
|
FetcherNext struct {
|
|
cache *ristretto.Cache[[]byte, jwk.Set]
|
|
}
|
|
// FetcherNextOption is a functional option for the FetcherNext.
|
|
FetcherNextOption func(*fetcherNextOptions)
|
|
)
|
|
|
|
// NewFetcherNext returns a new FetcherNext instance.
|
|
func NewFetcherNext(cache *ristretto.Cache[[]byte, jwk.Set]) *FetcherNext {
|
|
return &FetcherNext{
|
|
cache: cache,
|
|
}
|
|
}
|
|
|
|
// WithForceKID forces the key ID to be used. Required when multiple JWK sets are configured.
|
|
func WithForceKID(kid string) FetcherNextOption {
|
|
return func(o *fetcherNextOptions) {
|
|
o.forceKID = kid
|
|
}
|
|
}
|
|
|
|
// WithCacheTTL sets the cache TTL. If not set, the TTL is unlimited.
|
|
func WithCacheTTL(ttl time.Duration) FetcherNextOption {
|
|
return func(o *fetcherNextOptions) {
|
|
o.cacheTTL = ttl
|
|
}
|
|
}
|
|
|
|
// WithCacheEnabled enables the cache.
|
|
func WithCacheEnabled() FetcherNextOption {
|
|
return func(o *fetcherNextOptions) {
|
|
o.useCache = true
|
|
}
|
|
}
|
|
|
|
func WithAllowedSchemes(schemes ...string) FetcherNextOption {
|
|
return func(o *fetcherNextOptions) {
|
|
o.schemes = schemes
|
|
}
|
|
}
|
|
|
|
// WithHTTPClient will use the given HTTP client to fetch the JSON Web Keys.
|
|
func WithHTTPClient(c *retryablehttp.Client) FetcherNextOption {
|
|
return func(o *fetcherNextOptions) {
|
|
o.httpClient = c
|
|
}
|
|
}
|
|
|
|
func (f *FetcherNext) ResolveKey(ctx context.Context, locations string, modifiers ...FetcherNextOption) (jwk.Key, error) {
|
|
return f.ResolveKeyFromLocations(ctx, []string{locations}, modifiers...)
|
|
}
|
|
|
|
func (f *FetcherNext) ResolveKeyFromLocations(ctx context.Context, locations []string, modifiers ...FetcherNextOption) (jwk.Key, error) {
|
|
opts := new(fetcherNextOptions)
|
|
for _, m := range modifiers {
|
|
m(opts)
|
|
}
|
|
|
|
if len(locations) > 1 && opts.forceKID == "" {
|
|
return nil, errors.Errorf("a key ID must be specified when multiple JWK sets are configured")
|
|
}
|
|
|
|
set := jwk.NewSet()
|
|
eg := new(errgroup.Group)
|
|
for k := range locations {
|
|
location := locations[k]
|
|
eg.Go(func() error {
|
|
remoteSet, err := f.fetch(ctx, location, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
iterator := remoteSet.Iterate(ctx)
|
|
for iterator.Next(ctx) {
|
|
// Pair().Value is always of type jwk.Key when generated by Iterate.
|
|
set.Add(iterator.Pair().Value.(jwk.Key))
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if opts.forceKID != "" {
|
|
key, found := set.LookupKeyID(opts.forceKID)
|
|
if !found {
|
|
return nil, errors.WithStack(ErrUnableToFindKeyID)
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// No KID was forced? Use the first key we can find.
|
|
key, found := set.Get(0)
|
|
if !found {
|
|
return nil, errors.WithStack(ErrUnableToFindKeyID)
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// fetch fetches the JWK set from the given location and if enabled, may use the cache to look up the JWK set.
|
|
func (f *FetcherNext) fetch(ctx context.Context, location string, opts *fetcherNextOptions) (_ jwk.Set, err error) {
|
|
tracer := trace.SpanFromContext(ctx).TracerProvider().Tracer("")
|
|
ctx, span := tracer.Start(ctx, "jwksx.FetcherNext.fetch", trace.WithAttributes(attribute.String("location", location)))
|
|
defer otelx.End(span, &err)
|
|
|
|
cacheKey := sha256.Sum256([]byte(location))
|
|
if opts.useCache {
|
|
if result, found := f.cache.Get(cacheKey[:]); found {
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
var fopts []fetcher.Modifier
|
|
if opts.httpClient != nil {
|
|
fopts = append(fopts, fetcher.WithClient(opts.httpClient))
|
|
}
|
|
if len(opts.schemes) > 0 {
|
|
fopts = append(fopts, fetcher.WithAllowedSchemes(opts.schemes...))
|
|
}
|
|
|
|
result, err := fetcher.NewFetcher(fopts...).FetchContext(ctx, location)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
set, err := jwk.ParseReader(result)
|
|
if err != nil {
|
|
return nil, errors.WithStack(herodot.ErrBadRequest().WithReason("failed to parse JWK set").WithWrap(err))
|
|
}
|
|
|
|
if opts.useCache {
|
|
f.cache.SetWithTTL(cacheKey[:], set, 1, opts.cacheTTL)
|
|
}
|
|
|
|
return set, nil
|
|
}
|