Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
494 changes: 494 additions & 0 deletions pkg/auth/jwks/jwks.go

Large diffs are not rendered by default.

580 changes: 580 additions & 0 deletions pkg/auth/jwks/jwks_test.go

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions pkg/auth/jwks/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package jwks

import (
"net/http"
"time"
)

// Option is a functional option for NewFetcher.
type Option func(*Fetcher)

// WithHTTPClient supplies a pre-built *http.Client to use as-is for every
// JWKS fetch (and exposed via HTTPClient). When set, the Fetcher skips
// building its own client from the flag options below — but still wraps the
// client's Transport with the body-cap transport unless the body limit is
// disabled, since jwx's cache has no cap of its own.
func WithHTTPClient(client *http.Client) Option {
return func(f *Fetcher) {
f.httpClient = client
}
}

// WithInsecureAllowHTTP permits plain-HTTP JWKS URLs. Development and testing
// only — never set in production.
func WithInsecureAllowHTTP(allow bool) Option {
return func(f *Fetcher) {
f.insecureAllowHTTP = allow
}
}

// WithAllowPrivateIPs permits JWKS endpoints resolving to private or loopback
// addresses. Use only when the issuer is hosted inside the same cluster and
// has no public endpoint.
func WithAllowPrivateIPs(allow bool) Option {
return func(f *Fetcher) {
f.allowPrivateIPs = allow
}
}

// WithCABundle sets the path to a PEM CA certificate bundle. When set, ONLY
// certificates from that bundle are trusted (system roots are not included) —
// pinned-only trust, mirroring networking's HttpClientBuilder.WithCABundle.
func WithCABundle(path string) Option {
return func(f *Fetcher) {
f.caBundlePath = path
f.caBundleUsesSystemRoots = false
}
}

// WithSystemRootsPlusCABundle sets the path to a PEM CA certificate bundle and
// preserves trust in the system root pool — additive trust, mirroring
// networking's HttpClientBuilder.WithSystemRootsPlusCABundle. Use this when
// the upstream may use either a publicly trusted certificate or a private CA.
func WithSystemRootsPlusCABundle(path string) Option {
return func(f *Fetcher) {
f.caBundlePath = path
f.caBundleUsesSystemRoots = true
}
}

// WithAuthTokenFile sets the path to a file containing a bearer token sent as
// Authorization on every JWKS fetch.
func WithAuthTokenFile(path string) Option {
return func(f *Fetcher) {
f.authTokenFile = path
}
}

// WithTimeout sets the HTTP client timeout for JWKS fetches. Zero keeps
// networking's default.
func WithTimeout(timeout time.Duration) Option {
return func(f *Fetcher) {
f.timeout = timeout
}
}

// WithDisableKeepAlives disables HTTP keep-alive on the transport. When true,
// each request uses a fresh connection, ensuring the per-dial SSRF check fires
// on every request rather than being bypassed by a reused connection.
func WithDisableKeepAlives(disable bool) Option {
return func(f *Fetcher) {
f.disableKeepAlives = disable
}
}

// WithSameHostRedirects restricts redirect hops to the host of the original
// request, guarding against a discovery/JWKS redirect landing on a different,
// unvetted host.
func WithSameHostRedirects(enable bool) Option {
return func(f *Fetcher) {
f.sameHostRedirects = enable
}
}

// WithWorkers caps the background worker pool of the Fetcher's own cache.
// Zero keeps httprc's default of five workers.
func WithWorkers(workers int) Option {
return func(f *Fetcher) {
f.workers = workers
}
}

// WithBodyLimit caps every JWKS response body at limit bytes. Zero disables
// the cap — not recommended for endpoints derived from untrusted discovery
// documents. Defaults to DefaultBodyLimit.
func WithBodyLimit(limit int64) Option {
return func(f *Fetcher) {
f.bodyLimit = limit
}
}

// WithMaxKeys caps the number of keys accepted from a JWKS. Zero disables the
// cap. Defaults to DefaultMaxKeys.
func WithMaxKeys(limit int) Option {
return func(f *Fetcher) {
f.maxKeys = limit
}
}

// WithRefreshInterval pins the fixed interval at which the cache re-fetches
// its JWKS in the background. It is passed to Register via
// jwk.WithConstantInterval, which makes the resource ignore the response's
// Cache-Control/Expires headers entirely rather than merely bounding them —
// deliberately: absent this override, httprc derives the interval from those
// headers, clamped to [15m, 30 days], so a hostile or misconfigured issuer
// could otherwise extend our own key-retention window up to a month simply by
// setting a long max-age. Zero (the default) keeps header-derived scheduling.
func WithRefreshInterval(interval time.Duration) Option {
return func(f *Fetcher) {
f.refreshInterval = interval
}
}

// WithFetchFailureBackoff bounds how often EnsureRegistered retries a JWKS
// fetch that has never once succeeded. Defaults to DefaultFetchFailureBackoff.
func WithFetchFailureBackoff(backoff time.Duration) Option {
return func(f *Fetcher) {
f.fetchFailureBackoff = backoff
}
}

// WithMinKidRefreshInterval bounds how often Lookup forces a refresh for an
// unknown key ID. Defaults to DefaultMinKidRefreshInterval.
func WithMinKidRefreshInterval(interval time.Duration) Option {
return func(f *Fetcher) {
f.minKidRefreshInterval = interval
}
}

// WithRegistrationTimeout bounds the initial registration's ready-wait.
// Defaults to DefaultRegistrationTimeout.
func WithRegistrationTimeout(timeout time.Duration) Option {
return func(f *Fetcher) {
f.registrationTimeout = timeout
}
}
38 changes: 38 additions & 0 deletions pkg/auth/jwks/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package jwks

import "net/http"

// limitedBodyTransport wraps an http.RoundTripper to cap every response body
// at max bytes, via http.MaxBytesReader rather than io.LimitReader: the
// latter truncates silently, which would let a caller parse a cut-off JWKS
// document as if it were complete, where the former surfaces a
// *http.MaxBytesError instead. Its error text ("http: request body too
// large") is written for the request-body case MaxBytesReader was designed
// for, so it reads oddly for a capped response — an accepted rough edge
// rather than justifying a custom ReadCloser.
//
// The nil first argument (http.ResponseWriter) is safe: MaxBytesReader only
// reaches it through a type assertion (`l.w.(requestTooLarger)`) used to tell
// a real server connection to close early, which — on a nil interface value
// — safely evaluates to false rather than panicking (net/http/request.go).
type limitedBodyTransport struct {
base http.RoundTripper
max int64
}

// RoundTrip delegates to base and then caps the returned body. The cap
// applies to every response, including a non-2xx one whose body the caller
// doesn't intend to parse — draining it is only ever io.Discard-ed, not
// unbounded, so this errs on the safe side rather than special-casing status
// codes.
func (t *limitedBodyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base.RoundTrip(req)
if err != nil {
return nil, err
}
resp.Body = http.MaxBytesReader(nil, resp.Body, t.max)
return resp, nil
}
64 changes: 64 additions & 0 deletions pkg/auth/jwks/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package jwks

import (
"errors"
"fmt"
"net"
"net/url"

"github.com/stacklok/toolhive/pkg/networking"
)

// ValidateJWKSURL checks that jwksURL parses, has a host, uses HTTPS unless
// insecureAllowHTTP permits plain HTTP — and only exactly the "http" scheme,
// not any other non-https scheme such as "file" or "ftp" — and, when the
// host is an IP literal, is not a private or loopback address unless
// allowPrivateIPs permits that. Both flags come from the specific Fetcher
// (or issuer) being fetched, never from a validator-wide or self-issuer
// setting. This prevents SSRF attacks where a compromised discovery document —
// or a hand-configured jwks_url — points to internal services.
//
// This is the single implementation shared by the runtime choke point in
// Fetcher.registerOrRefresh (on every fetch) and pkg/authserver/config.go's
// config-time check (validateJWKSEndpointURL): the two must not drift out of
// sync, or a laxer runtime check would silently defeat the config-time guard.
//
// Deliberately env-immune: neither flag is widened by
// INSECURE_DISABLE_URL_VALIDATION or any other environment variable, so an
// unrelated env var can never silently disable this SSRF-relevant check.
func ValidateJWKSURL(jwksURL string, insecureAllowHTTP, allowPrivateIPs bool) error {
u, err := url.Parse(jwksURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}

if u.Host == "" {
return errors.New("host is required")
}

// Unlike issuer_url, a jwks_url carrying userinfo would actually work —
// net/http turns it into a Basic auth header on every JWKS fetch — which
// is precisely why it is rejected rather than tolerated: it would put a
// live credential in the RunConfig, in this function's error strings, and
// in any log that quotes the URL. A JWKS endpoint is public by
// definition (it serves verification keys), so there is no legitimate
// reason to authenticate to one.
if u.User != nil {
return errors.New("must not contain userinfo (credentials in the URL)")
}

if u.Scheme != "https" && (u.Scheme != "http" || !insecureAllowHTTP) {
return fmt.Errorf("must use HTTPS, got %q", u.Scheme)
}

host := u.Hostname()
ip := net.ParseIP(host)
if ip != nil && !allowPrivateIPs && networking.IsPrivateIP(ip) {
return errors.New("must not point to a private or loopback address")
}

return nil
}
Loading
Loading