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:
+19
-4
@@ -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)
|
||||
}
|
||||
|
||||
+49
-4
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user