feat(auth): per-issuer HTTPClient for authenticated JWKS fetch (infra ADR-0011)

The k3s OIDC discovery + JWKS endpoints require an AUTHENTICATED request
(anonymous -> 401; anonymous-auth is off on the cluster) and are served over the
cluster CA. A bare http.DefaultClient (correct for public Authentik) cannot
reach them, so multi-issuer alone (v0.4.0) could not actually validate k8s SA
tokens in-cluster.

Add IssuerConfig.HTTPClient: when set, it fetches THAT issuer's discovery + JWKS
(threaded into discoverJWKSURI and jwk.Cache.Register via jwk.WithHTTPClient).
The k8s-issuer consumer supplies a client that trusts the cluster CA
(/var/run/secrets/kubernetes.io/serviceaccount/ca.crt) and carries the server
pod's SA bearer. nil keeps http.DefaultClient (Authentik/public, unchanged).

Test: an auth-gated in-process issuer — unauthenticated fetch fails, a client
carrying the credential builds + validates a token. Empirically grounded in the
k3s 401 finding during the ADR-0011 in-cluster PoC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 23:25:04 +02:00
co-authored by Claude Opus 4.8
parent 75eb8b831c
commit 4e16913248
2 changed files with 68 additions and 8 deletions
+19 -4
View File
@@ -39,6 +39,13 @@ var ErrUnavailable = errors.New("jwt validation temporarily unavailable")
type IssuerConfig struct {
IssuerURL string
Audience string // "" = skip audience validation for this issuer
// HTTPClient, if non-nil, is used to fetch THIS issuer's OIDC discovery
// document and JWKS. Supply one to reach an issuer that needs a custom CA
// and/or credential — notably the in-cluster k8s OIDC endpoint, which serves
// its discovery/JWKS over the cluster CA AND requires an authenticated
// (ServiceAccount-bearer) request (anonymous access is 401). nil uses
// http.DefaultClient — correct for public issuers like Authentik.
HTTPClient *http.Client
}
// issuerEntry is a resolved IssuerConfig: OIDC discovery has run and jwks_uri is
@@ -82,11 +89,19 @@ func NewMultiJWTValidator(ctx context.Context, issuers []IssuerConfig) (*JWTVali
if ic.IssuerURL == "" {
continue
}
jwksURI, err := discoverJWKSURI(ctx, ic.IssuerURL)
client := ic.HTTPClient
if client == nil {
client = http.DefaultClient
}
jwksURI, err := discoverJWKSURI(ctx, client, ic.IssuerURL)
if err != nil {
return nil, fmt.Errorf("issuer %s: %w", ic.IssuerURL, err)
}
if err := cache.Register(jwksURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
regOpts := []jwk.RegisterOption{jwk.WithMinRefreshInterval(time.Hour)}
if ic.HTTPClient != nil {
regOpts = append(regOpts, jwk.WithHTTPClient(ic.HTTPClient))
}
if err := cache.Register(jwksURI, regOpts...); err != nil {
return nil, fmt.Errorf("register jwks cache (%s): %w", ic.IssuerURL, err)
}
if _, err := cache.Refresh(ctx, jwksURI); err != nil {
@@ -102,13 +117,13 @@ func NewMultiJWTValidator(ctx context.Context, issuers []IssuerConfig) (*JWTVali
// discoverJWKSURI fetches the OIDC discovery document from issuerURL and returns
// its jwks_uri.
func discoverJWKSURI(ctx context.Context, issuerURL string) (string, error) {
func discoverJWKSURI(ctx context.Context, client *http.Client, issuerURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
issuerURL+"/.well-known/openid-configuration", nil)
if err != nil {
return "", fmt.Errorf("build oidc discovery request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetch oidc discovery: %w", err)
}