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
+49 -4
View File
@@ -21,8 +21,22 @@ import (
// signature/issuer/audience validation path (which the chassis previously had
// no happy-path test for). Used to prove multi-issuer validation (ADR-0011).
type testIssuer struct {
url string
priv jwk.Key
url string
priv jwk.Key
requireBearer string // if set, discovery+JWKS return 401 without this bearer
}
func (ti *testIssuer) authed(r *http.Request) bool {
return ti.requireBearer == "" || r.Header.Get("Authorization") == "Bearer "+ti.requireBearer
}
// bearerRT adds a static Authorization header to every request — a minimal stand-in
// for the in-cluster case where the JWKS fetch carries the pod's SA token.
type bearerRT struct{ token string }
func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) {
r.Header.Set("Authorization", "Bearer "+b.token)
return http.DefaultTransport.RoundTrip(r)
}
func newTestIssuer(t *testing.T) *testIssuer {
@@ -40,10 +54,18 @@ func newTestIssuer(t *testing.T) *testIssuer {
ti := &testIssuer{priv: priv}
mux := http.NewServeMux()
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
if !ti.authed(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(set)
})
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
if !ti.authed(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": ti.url,
"jwks_uri": ti.url + "/jwks",
@@ -141,3 +163,26 @@ func TestNewMultiJWTValidator_EmptyList_ReturnsNilNil(t *testing.T) {
require.NoError(t, err)
require.Nil(t, v)
}
// The in-cluster k8s issuer requires an AUTHENTICATED discovery/JWKS fetch
// (anonymous is 401, confirmed against k3s). IssuerConfig.HTTPClient must carry
// that credential (the server pod's SA token) + trust the cluster CA.
func TestNewMultiJWTValidator_UsesPerIssuerHTTPClientForAuthedFetch(t *testing.T) {
ctx := context.Background()
iss := newTestIssuer(t)
iss.requireBearer = "fetch-secret" // discovery + JWKS now demand this bearer
// nil client (http.DefaultClient, no credential) → discovery 401 → build fails.
_, err := NewMultiJWTValidator(ctx, []IssuerConfig{{IssuerURL: iss.url, Audience: "sa-poc"}})
require.Error(t, err, "unauthenticated fetch must fail against an auth-gated issuer")
// A client that presents the fetch credential → build succeeds, token validates.
client := &http.Client{Transport: bearerRT{token: "fetch-secret"}}
v, err := NewMultiJWTValidator(ctx, []IssuerConfig{
{IssuerURL: iss.url, Audience: "sa-poc", HTTPClient: client},
})
require.NoError(t, err)
sub, err := v.Validate(ctx, iss.mint(t, "sa-poc", "poc-pod"))
require.NoError(t, err)
require.Equal(t, "poc-pod", sub)
}