diff --git a/auth/jwt.go b/auth/jwt.go index f7a9724..5e0255b 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -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) } diff --git a/auth/jwt_test.go b/auth/jwt_test.go index 7bb1256..5603f40 100644 --- a/auth/jwt_test.go +++ b/auth/jwt_test.go @@ -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) +}