From fbc55257c06483a5db56b85400f37d080c36ae9d Mon Sep 17 00:00:00 2001 From: "Benjamin A. Petersen" Date: Tue, 7 Jul 2026 13:41:46 -0400 Subject: [PATCH 1/6] :sparkles: KEP-6060: opt-in webhook-side API server authentication (Alpha) Implements the controller-runtime portion of KEP-6060 (API Server Authentication to Admission Webhooks): an opt-in, webhook-side library that verifies the bound, APIGroup-scoped service-account JWT presented by kube-apiserver / aggregated API servers to admission webhooks. Phase 1 (admission integration seam): - Add admission.Authenticator interface and optional Webhook.Authenticator field, invoked in ServeHTTP after AdmissionReview decode and before the user handler. Nil authenticator preserves existing behavior exactly. Auth failures short-circuit and fail closed (401 unauthenticated / 403 unauthorized). Phase 2 (offline JWT verifier): - Add pkg/webhook/admission/webhookauth: offline verification via go-jose/v4 with OIDC discovery + JWKS caching and fail-closed refresh, an explicit RS256 algorithm allow-list (rejects "none"/HS* per RFC 8725, audience-bound per RFC 9700), and issuer/exp/nbf/iat, binding, and allowedAPIGroup claim checks. No TokenReview round-trips (KEP mandate). - Typed unauthenticated/unauthorized errors with no token material leakage. - Adapter (NewAuthenticator) maps the Verifier onto admission.Authenticator. Backward compatible: webhooks that do not adopt verification are unaffected and silently ignore the Authorization header. NOTE: upstream KEP-6060 wire format is not yet settled (k/k#140113 is WIP); KEP-specific claim keys/field names are isolated as provisional constants pending upstream api-review reconciliation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 + pkg/webhook/admission/authenticator_test.go | 223 ++++++++ pkg/webhook/admission/http.go | 25 + pkg/webhook/admission/response.go | 26 + pkg/webhook/admission/webhook.go | 13 + .../admission/webhookauth/authenticator.go | 79 +++ .../webhookauth/authenticator_test.go | 137 +++++ pkg/webhook/admission/webhookauth/doc.go | 33 ++ pkg/webhook/admission/webhookauth/errors.go | 53 ++ pkg/webhook/admission/webhookauth/verifier.go | 504 ++++++++++++++++++ .../admission/webhookauth/verifier_test.go | 292 ++++++++++ 12 files changed, 1388 insertions(+) create mode 100644 pkg/webhook/admission/authenticator_test.go create mode 100644 pkg/webhook/admission/webhookauth/authenticator.go create mode 100644 pkg/webhook/admission/webhookauth/authenticator_test.go create mode 100644 pkg/webhook/admission/webhookauth/doc.go create mode 100644 pkg/webhook/admission/webhookauth/errors.go create mode 100644 pkg/webhook/admission/webhookauth/verifier.go create mode 100644 pkg/webhook/admission/webhookauth/verifier_test.go diff --git a/go.mod b/go.mod index 8fa6ddcfe0..3dc272aff8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/fsnotify/fsnotify v1.9.0 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 74fd04820d..a84aa50c85 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/pkg/webhook/admission/authenticator_test.go b/pkg/webhook/admission/authenticator_test.go new file mode 100644 index 0000000000..448d255b80 --- /dev/null +++ b/pkg/webhook/admission/authenticator_test.go @@ -0,0 +1,223 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var _ = Describe("Admission webhook authenticators", func() { + const ( + gvkJSONv1 = `"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1"` + gvkJSONv1beta1 = `"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1beta1"` + ) + + It("preserves existing behavior when no authenticator is configured", func() { + body := fmt.Sprintf(`{%s,"request":{}}`, gvkJSONv1) + newWebhook := func() *Webhook { + return &Webhook{ + Handler: &fakeHandler{}, + } + } + + reqWithoutAuth := newAdmissionReviewHTTPRequest(body) + reqWithAuth := newAdmissionReviewHTTPRequest(body) + reqWithAuth.Header.Set("Authorization", "Bearer ignored") + + withoutAuthRecorder := httptest.NewRecorder() + withAuthRecorder := httptest.NewRecorder() + + newWebhook().ServeHTTP(withoutAuthRecorder, reqWithoutAuth) + newWebhook().ServeHTTP(withAuthRecorder, reqWithAuth) + + Expect(withAuthRecorder.Body.String()).To(Equal(withoutAuthRecorder.Body.String())) + }) + + It("runs the authenticator after decode and before the handler", func() { + events := []string{} + handler := &fakeHandler{ + fn: func(ctx context.Context, req Request) Response { + events = append(events, "handler") + return Allowed("handled") + }, + } + webhook := &Webhook{ + Handler: handler, + Authenticator: authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + events = append(events, "authenticator") + Expect(httpReq.Header.Get("Authorization")).To(Equal("Bearer token")) + Expect(req.Resource.Group).To(Equal("apps")) + return Allowed("") + }), + } + + req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"resource":{"group":"apps","version":"v1","resource":"deployments"}}}`, gvkJSONv1)) + req.Header.Set("Authorization", "Bearer token") + respRecorder := httptest.NewRecorder() + + webhook.ServeHTTP(respRecorder, req) + + Expect(events).To(Equal([]string{"authenticator", "handler"})) + Expect(handler.invoked).To(BeTrue()) + }) + + DescribeTable("short-circuits denied authentication responses with the request AdmissionReview version", + func(authResponse Response, expectedCode int32, expectedReason metav1.StatusReason, expectedMessage string) { + handler := &fakeHandler{} + webhook := &Webhook{ + Handler: handler, + Authenticator: authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + Expect(req.Resource.Group).To(Equal("apps")) + return authResponse + }), + } + req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"uid":"auth-uid","resource":{"group":"apps","version":"v1","resource":"deployments"}}}`, gvkJSONv1beta1)) + respRecorder := httptest.NewRecorder() + + webhook.ServeHTTP(respRecorder, req) + + Expect(handler.invoked).To(BeFalse()) + var review admissionv1.AdmissionReview + Expect(json.Unmarshal(respRecorder.Body.Bytes(), &review)).To(Succeed()) + Expect(review.APIVersion).To(Equal("admission.k8s.io/v1beta1")) + Expect(review.Kind).To(Equal("AdmissionReview")) + Expect(review.Response).NotTo(BeNil()) + Expect(string(review.Response.UID)).To(Equal("auth-uid")) + Expect(review.Response.Allowed).To(BeFalse()) + Expect(review.Response.Result).NotTo(BeNil()) + Expect(review.Response.Result.Code).To(Equal(expectedCode)) + Expect(review.Response.Result.Reason).To(Equal(expectedReason)) + Expect(review.Response.Result.Message).To(Equal(expectedMessage)) + }, + Entry("unauthenticated", Unauthenticated("missing bearer token"), int32(http.StatusUnauthorized), metav1.StatusReasonUnauthorized, "missing bearer token"), + Entry("unauthorized", Unauthorized("api group is not allowed"), int32(http.StatusForbidden), metav1.StatusReasonForbidden, "api group is not allowed"), + ) + + It("keeps typed, multi-handler, and standalone webhooks compatible", func() { + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + constructors := map[string]func(Authenticator) http.Handler{ + "WithValidator": func(authenticator Authenticator) http.Handler { + webhook := WithValidator[*corev1.Pod](scheme, podValidator{}) + webhook.Authenticator = authenticator + return webhook + }, + "WithDefaulter": func(authenticator Authenticator) http.Handler { + webhook := WithDefaulter[*corev1.Pod](scheme, podDefaulter{}) + webhook.Authenticator = authenticator + return webhook + }, + "WithCustomDefaulter": func(authenticator Authenticator) http.Handler { + webhook := WithCustomDefaulter(scheme, &corev1.Pod{}, customPodDefaulter{}) + webhook.Authenticator = authenticator + return webhook + }, + "MultiValidatingHandler": func(authenticator Authenticator) http.Handler { + return &Webhook{ + Handler: MultiValidatingHandler(&fakeHandler{}), + Authenticator: authenticator, + } + }, + "MultiMutatingHandler": func(authenticator Authenticator) http.Handler { + return &Webhook{ + Handler: MultiMutatingHandler(&fakeHandler{}), + Authenticator: authenticator, + } + }, + "StandaloneWebhook": func(authenticator Authenticator) http.Handler { + handler, err := StandaloneWebhook(&Webhook{ + Handler: &fakeHandler{}, + Authenticator: authenticator, + }, StandaloneOptions{}) + Expect(err).NotTo(HaveOccurred()) + return handler + }, + } + + for name, constructor := range constructors { + By(name) + called := false + handler := constructor(authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + called = true + return Unauthorized("blocked") + })) + req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"uid":"compat-uid"}}`, gvkJSONv1)) + respRecorder := httptest.NewRecorder() + + handler.ServeHTTP(respRecorder, req) + + Expect(called).To(BeTrue()) + var review admissionv1.AdmissionReview + Expect(json.Unmarshal(respRecorder.Body.Bytes(), &review)).To(Succeed()) + Expect(review.Response).NotTo(BeNil()) + Expect(review.Response.Allowed).To(BeFalse()) + Expect(review.Response.Result.Code).To(Equal(int32(http.StatusForbidden))) + } + }) +}) + +type authenticatorFunc func(context.Context, *http.Request, Request) Response + +func (f authenticatorFunc) Authenticate(ctx context.Context, httpReq *http.Request, req Request) Response { + return f(ctx, httpReq, req) +} + +func newAdmissionReviewHTTPRequest(body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/admission", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +type podValidator struct{} + +func (podValidator) ValidateCreate(context.Context, *corev1.Pod) (Warnings, error) { + return nil, nil +} + +func (podValidator) ValidateUpdate(context.Context, *corev1.Pod, *corev1.Pod) (Warnings, error) { + return nil, nil +} + +func (podValidator) ValidateDelete(context.Context, *corev1.Pod) (Warnings, error) { + return nil, nil +} + +type podDefaulter struct{} + +func (podDefaulter) Default(context.Context, *corev1.Pod) error { + return nil +} + +type customPodDefaulter struct{} + +func (customPodDefaulter) Default(context.Context, runtime.Object) error { + return nil +} diff --git a/pkg/webhook/admission/http.go b/pkg/webhook/admission/http.go index f049fb66e6..3c8ae0552c 100644 --- a/pkg/webhook/admission/http.go +++ b/pkg/webhook/admission/http.go @@ -25,6 +25,7 @@ import ( v1 "k8s.io/api/admission/v1" "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -116,9 +117,33 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { } wh.getLogger(&req).V(5).Info("received request") + if wh.Authenticator != nil { + response := wh.Authenticator.Authenticate(ctx, r, req) + if !response.Allowed { + if err := completeDeniedAuthenticationResponse(&response, req); err != nil { + wh.getLogger(&req).Error(err, "unable to encode authentication response") + response = Errored(http.StatusInternalServerError, errUnableToEncodeResponse) + response.UID = req.UID + } + wh.writeResponseTyped(w, response, actualAdmRevGVK) + return + } + } + wh.writeResponseTyped(w, wh.Handle(ctx, req), actualAdmRevGVK) } +func completeDeniedAuthenticationResponse(response *Response, req Request) error { + if response.Result == nil { + response.Result = &metav1.Status{} + } + if response.Result.Code == 0 { + response.Result.Code = http.StatusForbidden + response.Result.Reason = metav1.StatusReasonForbidden + } + return response.Complete(req) +} + // writeResponse writes response to w generically, i.e. without encoding GVK information. func (wh *Webhook) writeResponse(w io.Writer, response Response) { wh.writeAdmissionResponse(w, v1.AdmissionReview{Response: &response.AdmissionResponse}) diff --git a/pkg/webhook/admission/response.go b/pkg/webhook/admission/response.go index ec1c88c989..ba10cc5715 100644 --- a/pkg/webhook/admission/response.go +++ b/pkg/webhook/admission/response.go @@ -59,6 +59,32 @@ func Errored(code int32, err error) Response { } } +// Unauthenticated constructs a response indicating that the caller could not be authenticated. +func Unauthenticated(message string) Response { + return authenticationResponse(http.StatusUnauthorized, metav1.StatusReasonUnauthorized, message) +} + +// Unauthorized constructs a response indicating that the authenticated caller is not authorized. +func Unauthorized(message string) Response { + return authenticationResponse(http.StatusForbidden, metav1.StatusReasonForbidden, message) +} + +func authenticationResponse(code int, reason metav1.StatusReason, message string) Response { + resp := Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: int32(code), + Reason: reason, + }, + }, + } + if len(message) > 0 { + resp.Result.Message = message + } + return resp +} + // ValidationResponse returns a response for admitting a request. func ValidationResponse(allowed bool, message string) Response { code := http.StatusForbidden diff --git a/pkg/webhook/admission/webhook.go b/pkg/webhook/admission/webhook.go index cba6da2cb0..5c816d6a76 100644 --- a/pkg/webhook/admission/webhook.go +++ b/pkg/webhook/admission/webhook.go @@ -104,6 +104,15 @@ type Handler interface { Handle(context.Context, Request) Response } +// Authenticator verifies the HTTP caller for an already-decoded AdmissionRequest. +// A nil Authenticator preserves the default admission webhook behavior. +type Authenticator interface { + // Authenticate returns an allowed response when the caller is authenticated and + // authorized for req. Any non-allowed response rejects the request before Handler + // is called. + Authenticate(context.Context, *http.Request, Request) Response +} + // HandlerFunc implements Handler interface using a single function. type HandlerFunc func(context.Context, Request) Response @@ -138,6 +147,10 @@ type Webhook struct { // outside the context of requests. LogConstructor func(base logr.Logger, req *Request) logr.Logger + // Authenticator verifies the HTTP caller after the AdmissionReview is decoded + // and before Handler is called. Nil preserves existing behavior. + Authenticator Authenticator + setupLogOnce sync.Once log logr.Logger } diff --git a/pkg/webhook/admission/webhookauth/authenticator.go b/pkg/webhook/admission/webhookauth/authenticator.go new file mode 100644 index 0000000000..f55c74f5be --- /dev/null +++ b/pkg/webhook/admission/webhookauth/authenticator.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhookauth + +import ( + "context" + "errors" + "net/http" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const authorizationSchemeBearer = "bearer" + +type authenticator struct { + verifier Verifier +} + +var _ admission.Authenticator = authenticator{} + +func NewAuthenticator(verifier Verifier) admission.Authenticator { + return authenticator{verifier: verifier} +} + +func (a authenticator) Authenticate(ctx context.Context, httpReq *http.Request, req admission.Request) admission.Response { + token, ok := bearerToken(httpReq) + if !ok { + return admission.Unauthenticated("missing or malformed bearer token") + } + if a.verifier == nil { + return admission.Unauthenticated("verifier is not configured") + } + if _, err := a.verifier.Verify(ctx, token, req); err != nil { + return responseForVerifierError(err) + } + return admission.Allowed("") +} + +func bearerToken(req *http.Request) (string, bool) { + if req == nil { + return "", false + } + values := req.Header.Values("Authorization") + if len(values) != 1 { + return "", false + } + fields := strings.Fields(values[0]) + if len(fields) != 2 || !strings.EqualFold(fields[0], authorizationSchemeBearer) || fields[1] == "" { + return "", false + } + return fields[1], true +} + +func responseForVerifierError(err error) admission.Response { + message := "authentication failed" + var verifierErr *Error + if errors.As(err, &verifierErr) && verifierErr.Message != "" { + message = verifierErr.Message + } + if IsUnauthorized(err) { + return admission.Unauthorized(message) + } + return admission.Unauthenticated(message) +} diff --git a/pkg/webhook/admission/webhookauth/authenticator_test.go b/pkg/webhook/admission/webhookauth/authenticator_test.go new file mode 100644 index 0000000000..e63c59c5eb --- /dev/null +++ b/pkg/webhook/admission/webhookauth/authenticator_test.go @@ -0,0 +1,137 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhookauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type fakeVerifier struct { + called bool + token string + err error +} + +func (f *fakeVerifier) Verify(_ context.Context, token string, _ admission.Request) (*Result, error) { + f.called = true + f.token = token + if f.err != nil { + return nil, f.err + } + return &Result{}, nil +} + +func TestAuthenticatorAuthorizationHeaderParsing(t *testing.T) { + tests := []struct { + name string + headers []string + wantCalled bool + wantToken string + }{ + {name: "missing"}, + {name: "wrong scheme", headers: []string{"Basic abc"}}, + {name: "empty bearer", headers: []string{"Bearer"}}, + {name: "extra fields", headers: []string{"Bearer abc def"}}, + {name: "multiple values", headers: []string{"Bearer abc", "Bearer def"}}, + {name: "valid bearer", headers: []string{"Bearer abc.def.ghi"}, wantCalled: true, wantToken: "abc.def.ghi"}, + {name: "case-insensitive scheme", headers: []string{"bearer abc.def.ghi"}, wantCalled: true, wantToken: "abc.def.ghi"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier := &fakeVerifier{} + authenticator := NewAuthenticator(verifier) + req := httptest.NewRequest(http.MethodPost, "/", nil) + for _, header := range tt.headers { + req.Header.Add("Authorization", header) + } + + resp := authenticator.Authenticate(context.Background(), req, admission.Request{}) + + if verifier.called != tt.wantCalled { + t.Fatalf("verifier called = %t, want %t", verifier.called, tt.wantCalled) + } + if verifier.token != tt.wantToken { + t.Fatalf("verifier token = %q, want %q", verifier.token, tt.wantToken) + } + if tt.wantCalled && !resp.Allowed { + t.Fatalf("Authenticate() denied unexpectedly: %#v", resp.Result) + } + if !tt.wantCalled && (resp.Allowed || resp.Result.Code != http.StatusUnauthorized) { + t.Fatalf("Authenticate() = %#v, want 401 denial", resp) + } + }) + } +} + +func TestAuthenticatorMapsVerifierErrors(t *testing.T) { + tests := []struct { + name string + err error + code int32 + reason metav1.StatusReason + }{ + {name: "unauthenticated", err: unauthenticated("bad token"), code: http.StatusUnauthorized, reason: metav1.StatusReasonUnauthorized}, + {name: "unauthorized", err: unauthorized("wrong group"), code: http.StatusForbidden, reason: metav1.StatusReasonForbidden}, + {name: "unknown", err: errors.New("boom"), code: http.StatusUnauthorized, reason: metav1.StatusReasonUnauthorized}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier := &fakeVerifier{err: tt.err} + authenticator := NewAuthenticator(verifier) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Authorization", "Bearer secret-token") + + resp := authenticator.Authenticate(context.Background(), req, admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{UID: "uid"}, + }) + + if resp.Allowed || resp.Result.Code != tt.code || resp.Result.Reason != tt.reason { + t.Fatalf("Authenticate() = %#v, want %d %s denial", resp, tt.code, tt.reason) + } + if strings.Contains(resp.Result.Message, "secret-token") { + t.Fatalf("response leaked token material: %q", resp.Result.Message) + } + }) + } +} + +func TestVerifierAllowsCoreAPIGroup(t *testing.T) { + f := newFixture(t) + token := f.token(t, tokenOptions{allowedAPIGroup: []string{""}}) + + result, err := f.verifier.Verify(context.Background(), token, admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + }, + }) + + if err != nil { + t.Fatalf("Verify() unexpected error: %v", err) + } + if result.AllowedAPIGroup != "" { + t.Fatalf("AllowedAPIGroup = %q, want core API group", result.AllowedAPIGroup) + } +} diff --git a/pkg/webhook/admission/webhookauth/doc.go b/pkg/webhook/admission/webhookauth/doc.go new file mode 100644 index 0000000000..41b7a99552 --- /dev/null +++ b/pkg/webhook/admission/webhookauth/doc.go @@ -0,0 +1,33 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package webhookauth verifies KEP-6060 admission webhook authentication JWTs. +// +// Verification is offline: JWS signatures are checked against cached +// OIDC-discovered JWKS, then issuer, audience, exp, nbf, iat, webhook binding, +// and allowed API group claims are enforced. TokenReview is never used by +// default. Unknown kid or cache expiry triggers JWKS refresh; refresh failure +// fails closed. +// +// The default signing algorithm allow-list is RS256. Callers may configure +// other asymmetric algorithms, but "none" and symmetric HS* algorithms are +// rejected to follow RFC 8725 JWT BCP guidance against unsecured JWTs and +// algorithm confusion. RFC 9700 OAuth2 Security BCP likewise motivates strict +// audience-bound bearer-token validation. +// +// KEP-6060's wire format is provisional. All private claim names used here are +// isolated in verifier.go and must be reconciled after upstream API review. +package webhookauth diff --git a/pkg/webhook/admission/webhookauth/errors.go b/pkg/webhook/admission/webhookauth/errors.go new file mode 100644 index 0000000000..338ef54d23 --- /dev/null +++ b/pkg/webhook/admission/webhookauth/errors.go @@ -0,0 +1,53 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhookauth + +import "errors" + +type ErrorKind string + +const ( + ErrorKindUnauthenticated ErrorKind = "UNAUTHENTICATED" + ErrorKindUnauthorized ErrorKind = "UNAUTHORIZED" +) + +type Error struct { + Kind ErrorKind + Message string +} + +func (e *Error) Error() string { + return string(e.Kind) + ": " + e.Message +} + +func unauthenticated(message string) error { + return &Error{Kind: ErrorKindUnauthenticated, Message: message} +} + +func unauthorized(message string) error { + return &Error{Kind: ErrorKindUnauthorized, Message: message} +} + +func IsUnauthenticated(err error) bool { + var verifierErr *Error + return errors.As(err, &verifierErr) && verifierErr.Kind == ErrorKindUnauthenticated +} + +func IsUnauthorized(err error) bool { + var verifierErr *Error + return errors.As(err, &verifierErr) && verifierErr.Kind == ErrorKindUnauthorized +} diff --git a/pkg/webhook/admission/webhookauth/verifier.go b/pkg/webhook/admission/webhookauth/verifier.go new file mode 100644 index 0000000000..9a4e0a3107 --- /dev/null +++ b/pkg/webhook/admission/webhookauth/verifier.go @@ -0,0 +1,504 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhookauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// Provisional KEP-6060 wire constants. Upstream API review has not settled the +// private claim keys, binding field names, feature gate name, or audience +// derivation format; reconcile this single block with final Kubernetes API. +const ( + kubernetesPrivateClaimKey = "kubernetes.io" + validatingWebhookConfigurationClaimKey = "validatingWebhookConfiguration" + mutatingWebhookConfigurationClaimKey = "mutatingWebhookConfiguration" + attestationClaimsFieldKey = "attestationClaims" + attestationsFieldKey = "attestations" + AllowedAPIGroupClaim = "webhook-authentication.k8s.io/allowedAPIGroup" + allowedAPIGroupWildcard = "*" + webhookConfigurationBindingNameFieldKey = "name" + webhookConfigurationBindingUIDFieldKey = "uid" + wellKnownOpenIDConfigurationPathSegment = ".well-known/openid-configuration" +) + +const ( + defaultJWKSCacheTTL = 5 * time.Minute + defaultClockSkew = time.Minute +) + +type WebhookType string + +const ( + ValidatingWebhook WebhookType = "validating" + MutatingWebhook WebhookType = "mutating" +) + +type WebhookBinding struct { + Type WebhookType + Name string + UID string +} + +type Clock interface{ Now() time.Time } +type ClockFunc func() time.Time + +func (f ClockFunc) Now() time.Time { return f() } + +type KeySet interface { + Keys(context.Context, string) ([]jose.JSONWebKey, error) +} +type RefreshingKeySet interface { + KeySet + Refresh(context.Context) error +} + +type Options struct { + IssuerURL string + Audience string + Binding WebhookBinding + KeySet KeySet + HTTPClient *http.Client + Clock Clock + AllowedAlgorithms []jose.SignatureAlgorithm + ClockSkew time.Duration + JWKSCacheTTL time.Duration +} + +type Verifier interface { + Verify(context.Context, string, admission.Request) (*Result, error) +} + +type Result struct { + Subject string + Audience []string + AllowedAPIGroup string + Binding WebhookBinding + Expiration time.Time +} + +type verifier struct { + issuerURL string + audience string + binding WebhookBinding + keySet KeySet + refreshingKeySet RefreshingKeySet + clock Clock + allowedAlgs []jose.SignatureAlgorithm + clockSkew time.Duration +} + +func NewVerifier(opts Options) (Verifier, error) { + issuer, audience := strings.TrimSpace(opts.IssuerURL), strings.TrimSpace(opts.Audience) + if issuer == "" { + return nil, fmt.Errorf("issuer URL is required") + } + if audience == "" || audience == allowedAPIGroupWildcard { + return nil, fmt.Errorf("audience must be non-empty and non-wildcard") + } + if opts.Binding.Type != ValidatingWebhook && opts.Binding.Type != MutatingWebhook { + return nil, fmt.Errorf("binding type must be validating or mutating") + } + if strings.TrimSpace(opts.Binding.Name) == "" { + return nil, fmt.Errorf("binding name is required") + } + keySet := opts.KeySet + if keySet == nil { + remote, err := NewRemoteKeySet(RemoteKeySetOptions{IssuerURL: issuer, Client: opts.HTTPClient, CacheTTL: opts.JWKSCacheTTL}) + if err != nil { + return nil, err + } + keySet = remote + } + refreshing, _ := keySet.(RefreshingKeySet) + clock := opts.Clock + if clock == nil { + clock = ClockFunc(time.Now) + } + clockSkew := opts.ClockSkew + if clockSkew == 0 { + clockSkew = defaultClockSkew + } + if clockSkew < 0 || clockSkew > 5*time.Minute { + return nil, fmt.Errorf("clock skew must be between 0 and 5 minutes") + } + algs := opts.AllowedAlgorithms + if len(algs) == 0 { + algs = []jose.SignatureAlgorithm{jose.RS256} + } + for _, alg := range algs { + if alg == "" || alg == jose.SignatureAlgorithm("none") || strings.HasPrefix(string(alg), "HS") { + return nil, fmt.Errorf("unsupported signing algorithm %q", alg) + } + } + return &verifier{issuerURL: issuer, audience: audience, binding: opts.Binding, keySet: keySet, refreshingKeySet: refreshing, clock: clock, allowedAlgs: append([]jose.SignatureAlgorithm(nil), algs...), clockSkew: clockSkew}, nil +} + +func (v *verifier) Verify(ctx context.Context, raw string, req admission.Request) (*Result, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, unauthenticated("missing token") + } + if strings.Count(raw, ".") != 2 { + return nil, unauthenticated("malformed JWT") + } + parsed, err := jwt.ParseSigned(raw, v.allowedAlgs) + if err != nil { + return nil, unauthenticated("malformed or disallowed JWT") + } + if len(parsed.Headers) != 1 { + return nil, unauthenticated("JWT must contain exactly one signature") + } + claims, private, err := v.verifySignatureAndDecodeClaims(ctx, parsed) + if err != nil { + return nil, err + } + if err := claims.ValidateWithLeeway(jwt.Expected{Issuer: v.issuerURL, AnyAudience: jwt.Audience{v.audience}, Time: v.clock.Now()}, v.clockSkew); err != nil { + if errors.Is(err, jwt.ErrInvalidAudience) { + return nil, unauthorized("JWT audience is not authorized") + } + return nil, unauthenticated("JWT standard claims are invalid") + } + if claims.Expiry == nil { + return nil, unauthenticated("JWT expiration claim is required") + } + if claims.NotBefore == nil { + return nil, unauthenticated("JWT not-before claim is required") + } + if claims.IssuedAt == nil { + return nil, unauthenticated("JWT issued-at claim is required") + } + binding, err := extractAndVerifyBinding(private, v.binding) + if err != nil { + return nil, err + } + group, err := extractAndVerifyAllowedAPIGroup(private, req.Resource.Group) + if err != nil { + return nil, err + } + return &Result{Subject: claims.Subject, Audience: []string(claims.Audience), AllowedAPIGroup: group, Binding: binding, Expiration: claims.Expiry.Time()}, nil +} + +func (v *verifier) verifySignatureAndDecodeClaims(ctx context.Context, parsed *jwt.JSONWebToken) (jwt.Claims, map[string]json.RawMessage, error) { + kid := parsed.Headers[0].KeyID + keys, err := v.keySet.Keys(ctx, kid) + if err != nil { + return jwt.Claims{}, nil, unauthenticated("unable to obtain verification keys") + } + claims, private, ok := tryVerifyWithKeys(parsed, keys, parsed.Headers[0].Algorithm) + if ok { + return claims, private, nil + } + if v.refreshingKeySet != nil { + if err := v.refreshingKeySet.Refresh(ctx); err != nil { + return jwt.Claims{}, nil, unauthenticated("unable to refresh verification keys") + } + keys, err = v.refreshingKeySet.Keys(ctx, kid) + if err != nil { + return jwt.Claims{}, nil, unauthenticated("unable to obtain refreshed verification keys") + } + claims, private, ok = tryVerifyWithKeys(parsed, keys, parsed.Headers[0].Algorithm) + if ok { + return claims, private, nil + } + } + return jwt.Claims{}, nil, unauthenticated("JWT signature verification failed") +} + +func tryVerifyWithKeys(parsed *jwt.JSONWebToken, keys []jose.JSONWebKey, alg string) (jwt.Claims, map[string]json.RawMessage, bool) { + for _, key := range keys { + if key.Use != "" && key.Use != "sig" { + continue + } + if key.Algorithm != "" && key.Algorithm != alg { + continue + } + if !key.Valid() { + continue + } + var c jwt.Claims + p := map[string]json.RawMessage{} + if err := parsed.Claims(key.Key, &c, &p); err == nil { + return c, p, true + } + } + return jwt.Claims{}, nil, false +} + +func extractAndVerifyBinding(claims map[string]json.RawMessage, expected WebhookBinding) (WebhookBinding, error) { + k, err := nestedObject(claims, kubernetesPrivateClaimKey) + if err != nil { + return WebhookBinding{}, err + } + v, hv, err := bindingClaim(k, validatingWebhookConfigurationClaimKey) + if err != nil { + return WebhookBinding{}, err + } + m, hm, err := bindingClaim(k, mutatingWebhookConfigurationClaimKey) + if err != nil { + return WebhookBinding{}, err + } + if hv == hm { + return WebhookBinding{}, unauthorized("JWT must contain exactly one webhook configuration binding") + } + actual := WebhookBinding{} + if hv { + actual = WebhookBinding{Type: ValidatingWebhook, Name: v.Name, UID: v.UID} + } else { + actual = WebhookBinding{Type: MutatingWebhook, Name: m.Name, UID: m.UID} + } + if actual.Type != expected.Type || actual.Name != expected.Name { + return WebhookBinding{}, unauthorized("JWT webhook binding does not match expected webhook") + } + if expected.UID != "" && actual.UID != expected.UID { + return WebhookBinding{}, unauthorized("JWT webhook binding UID does not match expected webhook") + } + return actual, nil +} + +type webhookBindingClaim struct{ Name, UID string } + +func bindingClaim(claims map[string]json.RawMessage, key string) (webhookBindingClaim, bool, error) { + raw, ok := claims[key] + if !ok { + return webhookBindingClaim{}, false, nil + } + var claim map[string]json.RawMessage + if err := json.Unmarshal(raw, &claim); err != nil { + return webhookBindingClaim{}, false, unauthorized("JWT webhook binding claim is malformed") + } + name, err := stringField(claim, webhookConfigurationBindingNameFieldKey) + if err != nil || name == "" { + return webhookBindingClaim{}, false, unauthorized("JWT webhook binding name is required") + } + uid, err := optionalStringField(claim, webhookConfigurationBindingUIDFieldKey) + if err != nil { + return webhookBindingClaim{}, false, unauthorized("JWT webhook binding UID is malformed") + } + return webhookBindingClaim{Name: name, UID: uid}, true, nil +} + +func extractAndVerifyAllowedAPIGroup(claims map[string]json.RawMessage, resourceGroup string) (string, error) { + k, err := nestedObject(claims, kubernetesPrivateClaimKey) + if err != nil { + return "", err + } + attestations, err := attestationClaims(k) + if err != nil { + return "", err + } + vals, ok := attestations[AllowedAPIGroupClaim] + if !ok || len(vals) != 1 { + return "", unauthorized("JWT allowed API group claim must contain exactly one value") + } + if vals[0] != allowedAPIGroupWildcard && vals[0] != resourceGroup { + return "", unauthorized("JWT allowed API group claim does not match request resource") + } + return vals[0], nil +} + +func nestedObject(claims map[string]json.RawMessage, key string) (map[string]json.RawMessage, error) { + raw, ok := claims[key] + if !ok { + return nil, unauthorized("JWT Kubernetes private claims are required") + } + out := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, unauthorized("JWT Kubernetes private claims are malformed") + } + return out, nil +} + +func attestationClaims(k map[string]json.RawMessage) (map[string][]string, error) { + raw, ok := k[attestationClaimsFieldKey] + if !ok { + raw, ok = k[attestationsFieldKey] + } + if !ok { + return nil, unauthorized("JWT attestation claims are required") + } + out := map[string][]string{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, unauthorized("JWT attestation claims are malformed") + } + return out, nil +} + +func stringField(claims map[string]json.RawMessage, key string) (string, error) { + raw, ok := claims[key] + if !ok { + return "", errors.New("missing string field") + } + var out string + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + return out, nil +} + +func optionalStringField(claims map[string]json.RawMessage, key string) (string, error) { + raw, ok := claims[key] + if !ok { + return "", nil + } + var out string + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + return out, nil +} + +type RemoteKeySetOptions struct { + IssuerURL string + Client *http.Client + CacheTTL time.Duration +} + +type RemoteKeySet struct { + issuerURL string + client *http.Client + cacheTTL time.Duration + mu sync.RWMutex + keys jose.JSONWebKeySet + expiresAt time.Time +} + +func NewRemoteKeySet(opts RemoteKeySetOptions) (*RemoteKeySet, error) { + issuer := strings.TrimRight(strings.TrimSpace(opts.IssuerURL), "/") + if issuer == "" { + return nil, fmt.Errorf("issuer URL is required") + } + if _, err := url.ParseRequestURI(issuer); err != nil { + return nil, fmt.Errorf("issuer URL is invalid: %w", err) + } + client := opts.Client + if client == nil { + client = http.DefaultClient + } + ttl := opts.CacheTTL + if ttl == 0 { + ttl = defaultJWKSCacheTTL + } + if ttl < 0 { + return nil, fmt.Errorf("JWKS cache TTL must not be negative") + } + return &RemoteKeySet{issuerURL: issuer, client: client, cacheTTL: ttl}, nil +} + +func (s *RemoteKeySet) Keys(ctx context.Context, kid string) ([]jose.JSONWebKey, error) { + if err := s.ensureFresh(ctx); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + if kid != "" { + return append([]jose.JSONWebKey(nil), s.keys.Key(kid)...), nil + } + return append([]jose.JSONWebKey(nil), s.keys.Keys...), nil +} + +func (s *RemoteKeySet) Refresh(ctx context.Context) error { + d, err := s.fetchDiscovery(ctx) + if err != nil { + return err + } + keys, err := s.fetchJWKS(ctx, d.JWKSURI) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.keys = keys + s.expiresAt = time.Now().Add(s.cacheTTL) + return nil +} + +func (s *RemoteKeySet) ensureFresh(ctx context.Context) error { + s.mu.RLock() + fresh := len(s.keys.Keys) > 0 && time.Now().Before(s.expiresAt) + s.mu.RUnlock() + if fresh { + return nil + } + return s.Refresh(ctx) +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` +} + +func (s *RemoteKeySet) fetchDiscovery(ctx context.Context) (discoveryDocument, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.issuerURL+"/"+wellKnownOpenIDConfigurationPathSegment, nil) + if err != nil { + return discoveryDocument{}, err + } + resp, err := s.client.Do(req) + if err != nil { + return discoveryDocument{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return discoveryDocument{}, fmt.Errorf("OIDC discovery returned status %d", resp.StatusCode) + } + var doc discoveryDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return discoveryDocument{}, err + } + if doc.Issuer != s.issuerURL { + return discoveryDocument{}, fmt.Errorf("OIDC discovery issuer mismatch") + } + if doc.JWKSURI == "" { + return discoveryDocument{}, fmt.Errorf("OIDC discovery jwks_uri is required") + } + return doc, nil +} + +func (s *RemoteKeySet) fetchJWKS(ctx context.Context, uri string) (jose.JSONWebKeySet, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return jose.JSONWebKeySet{}, err + } + resp, err := s.client.Do(req) + if err != nil { + return jose.JSONWebKeySet{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return jose.JSONWebKeySet{}, fmt.Errorf("JWKS returned status %d", resp.StatusCode) + } + var keys jose.JSONWebKeySet + if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil { + return jose.JSONWebKeySet{}, err + } + if len(keys.Keys) == 0 { + return jose.JSONWebKeySet{}, fmt.Errorf("JWKS contains no keys") + } + return keys, nil +} diff --git a/pkg/webhook/admission/webhookauth/verifier_test.go b/pkg/webhook/admission/webhookauth/verifier_test.go new file mode 100644 index 0000000000..7097946c18 --- /dev/null +++ b/pkg/webhook/admission/webhookauth/verifier_test.go @@ -0,0 +1,292 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhookauth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const ( + testIssuer = "https://issuer.example.test" + testAudience = "webhook.example.test/validating/widgets" + testSubject = "system:serviceaccount:kube-system:kube-apiserver" + testWebhook = "widgets.example.test" + testUID = "12345" + testKid = "kid-1" +) + +type staticKeySet struct{ keys jose.JSONWebKeySet } + +func (s staticKeySet) Keys(_ context.Context, keyID string) ([]jose.JSONWebKey, error) { + if keyID == "" { + return s.keys.Keys, nil + } + return s.keys.Key(keyID), nil +} + +func TestVerifierValidToken(t *testing.T) { + f := newFixture(t) + result, err := f.verify(t, f.token(t, tokenOptions{})) + if err != nil { + t.Fatalf("Verify() unexpected error: %v", err) + } + if result.Subject != testSubject || result.AllowedAPIGroup != "apps" { + t.Fatalf("result = %#v", result) + } +} + +func TestVerifierUnauthenticatedFailures(t *testing.T) { + f := newFixture(t) + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + tests := []struct{ name, token string }{ + {name: "bad signature", token: f.tokenWithKey(t, otherKey, testKid, jose.RS256, tokenOptions{})}, + {name: "none alg", token: f.noneToken(t)}, + {name: "disallowed alg", token: f.tokenWithKey(t, []byte(strings.Repeat("s", 32)), testKid, jose.HS256, tokenOptions{})}, + {name: "malformed", token: "not-a-jwt"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := f.verify(t, tt.token) + if !IsUnauthenticated(err) { + t.Fatalf("Verify() error = %v, want unauthenticated", err) + } + if strings.Contains(err.Error(), tt.token) { + t.Fatalf("error leaked token material: %v", err) + } + }) + } +} + +func TestVerifierStandardClaimFailures(t *testing.T) { + f := newFixture(t) + tests := []struct { + name string + opts tokenOptions + wantUnauthenticated bool + }{ + {name: "wrong issuer", opts: tokenOptions{issuer: "https://other.example.test"}, wantUnauthenticated: true}, + {name: "expired", opts: tokenOptions{expiry: f.now.Add(-2 * time.Minute)}, wantUnauthenticated: true}, + {name: "not yet valid", opts: tokenOptions{notBefore: f.now.Add(2 * time.Minute)}, wantUnauthenticated: true}, + {name: "wrong audience", opts: tokenOptions{audience: []string{"other-audience"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := f.verify(t, f.token(t, tt.opts)) + if tt.wantUnauthenticated { + if !IsUnauthenticated(err) { + t.Fatalf("Verify() error = %v, want unauthenticated", err) + } + return + } + if !IsUnauthorized(err) { + t.Fatalf("Verify() error = %v, want unauthorized", err) + } + }) + } +} + +func TestVerifierWebhookBindingClaims(t *testing.T) { + f := newFixture(t) + tests := []struct { + name string + opts tokenOptions + }{ + {name: "missing binding", opts: tokenOptions{binding: map[string]any{}}}, + {name: "multiple binding claims", opts: tokenOptions{binding: map[string]any{validatingWebhookConfigurationClaimKey: binding(testWebhook, testUID), mutatingWebhookConfigurationClaimKey: binding(testWebhook, testUID)}}}, + {name: "wrong binding type", opts: tokenOptions{binding: map[string]any{mutatingWebhookConfigurationClaimKey: binding(testWebhook, testUID)}}}, + {name: "wrong binding name", opts: tokenOptions{binding: map[string]any{validatingWebhookConfigurationClaimKey: binding("other", testUID)}}}, + {name: "wrong binding uid", opts: tokenOptions{binding: map[string]any{validatingWebhookConfigurationClaimKey: binding(testWebhook, "other")}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := f.verify(t, f.token(t, tt.opts)); !IsUnauthorized(err) { + t.Fatalf("Verify() error = %v, want unauthorized", err) + } + }) + } +} + +func TestVerifierAllowedAPIGroupClaims(t *testing.T) { + f := newFixture(t) + tests := []struct { + name string + opts tokenOptions + want string + }{ + {name: "missing allowed API group", opts: tokenOptions{allowedAPIGroup: []string{}}}, + {name: "multi valued allowed API group", opts: tokenOptions{allowedAPIGroup: []string{"apps", "batch"}}}, + {name: "wrong allowed API group", opts: tokenOptions{allowedAPIGroup: []string{"batch"}}}, + {name: "wildcard allowed API group", opts: tokenOptions{allowedAPIGroup: []string{allowedAPIGroupWildcard}}, want: allowedAPIGroupWildcard}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := f.verify(t, f.token(t, tt.opts)) + if tt.want != "" { + if err != nil || result.AllowedAPIGroup != tt.want { + t.Fatalf("Verify() = %#v, %v; want allowed API group %q", result, err, tt.want) + } + return + } + if !IsUnauthorized(err) { + t.Fatalf("Verify() error = %v, want unauthorized", err) + } + }) + } +} + +type fixture struct { + now time.Time + key *rsa.PrivateKey + verifier Verifier +} + +type tokenOptions struct { + issuer string + audience []string + expiry time.Time + notBefore time.Time + issuedAt time.Time + binding map[string]any + allowedAPIGroup []string +} + +func newFixture(t *testing.T) fixture { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 7, 17, 0, 0, 0, time.UTC) + verifier, err := NewVerifier(Options{ + IssuerURL: testIssuer, + Audience: testAudience, + Binding: WebhookBinding{Type: ValidatingWebhook, Name: testWebhook, UID: testUID}, + KeySet: staticKeySet{keys: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: testKid, Use: "sig", Algorithm: string(jose.RS256)}}}}, + Clock: ClockFunc(func() time.Time { return now }), + }) + if err != nil { + t.Fatal(err) + } + return fixture{now: now, key: key, verifier: verifier} +} + +func (f fixture) verify(t *testing.T, token string) (*Result, error) { + t.Helper() + return f.verifier.Verify(context.Background(), token, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: metav1.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, + }}) +} + +func (f fixture) token(t *testing.T, opts tokenOptions) string { + t.Helper() + return f.tokenWithKey(t, f.key, testKid, jose.RS256, opts) +} + +func (f fixture) tokenWithKey(t *testing.T, key any, kid string, alg jose.SignatureAlgorithm, opts tokenOptions) string { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: key}, (&jose.SignerOptions{}).WithHeader(jose.HeaderKey("kid"), kid)) + if err != nil { + t.Fatal(err) + } + issuer := opts.issuer + if issuer == "" { + issuer = testIssuer + } + audience := opts.audience + if audience == nil { + audience = []string{testAudience} + } + expiry := opts.expiry + if expiry.IsZero() { + expiry = f.now.Add(time.Hour) + } + notBefore := opts.notBefore + if notBefore.IsZero() { + notBefore = f.now.Add(-time.Minute) + } + issuedAt := opts.issuedAt + if issuedAt.IsZero() { + issuedAt = f.now.Add(-time.Minute) + } + claims := jwt.Claims{Issuer: issuer, Subject: testSubject, Audience: jwt.Audience(audience), Expiry: jwt.NewNumericDate(expiry), NotBefore: jwt.NewNumericDate(notBefore), IssuedAt: jwt.NewNumericDate(issuedAt)} + token, err := jwt.Signed(signer).Claims(claims).Claims(map[string]any{kubernetesPrivateClaimKey: f.privateKubernetesClaims(opts)}).Serialize() + if err != nil { + t.Fatal(err) + } + return token +} + +func (f fixture) privateKubernetesClaims(opts tokenOptions) map[string]any { + bindingClaims := opts.binding + if bindingClaims == nil { + bindingClaims = map[string]any{validatingWebhookConfigurationClaimKey: binding(testWebhook, testUID)} + } + out := map[string]any{} + for key, value := range bindingClaims { + out[key] = value + } + allowedAPIGroup := opts.allowedAPIGroup + if allowedAPIGroup == nil { + allowedAPIGroup = []string{"apps"} + } + out[attestationClaimsFieldKey] = map[string][]string{AllowedAPIGroupClaim: allowedAPIGroup} + return out +} + +func binding(name, uid string) map[string]string { + return map[string]string{webhookConfigurationBindingNameFieldKey: name, webhookConfigurationBindingUIDFieldKey: uid} +} + +func (f fixture) noneToken(t *testing.T) string { + t.Helper() + claims := jwt.Claims{Issuer: testIssuer, Subject: testSubject, Audience: jwt.Audience{testAudience}, Expiry: jwt.NewNumericDate(f.now.Add(time.Hour)), NotBefore: jwt.NewNumericDate(f.now.Add(-time.Minute)), IssuedAt: jwt.NewNumericDate(f.now.Add(-time.Minute))} + claimsBytes, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + var claimsMap map[string]any + if err := json.Unmarshal(claimsBytes, &claimsMap); err != nil { + t.Fatal(err) + } + claimsMap[kubernetesPrivateClaimKey] = f.privateKubernetesClaims(tokenOptions{}) + headerBytes, err := json.Marshal(map[string]string{"alg": "none"}) + if err != nil { + t.Fatal(err) + } + payloadBytes, err := json.Marshal(claimsMap) + if err != nil { + t.Fatal(err) + } + enc := base64.RawURLEncoding + return enc.EncodeToString(headerBytes) + "." + enc.EncodeToString(payloadBytes) + "." +} From dded544f9597c6a239e82e26f54df8bd6c2eb597 Mon Sep 17 00:00:00 2001 From: "Benjamin A. Petersen" Date: Tue, 7 Jul 2026 16:00:56 -0400 Subject: [PATCH 2/6] :recycle: KEP-6060: consume k8s.io/webhook-auth instead of re-implementing it Replace the controller-runtime-local JWT verifier with a thin adapter over the k8s.io/webhook-auth library, so controller-runtime demonstrates a simple opt-in to KEP-6060 API server authentication rather than duplicating verification logic. pkg/webhook/admission/webhookauth is now a ~3-file adapter: - NewAuthenticator(v *verify.Verifier) wraps the library verifier onto the admission.Authenticator seam, reusing controller-runtime's single AdmissionReview decode (admissionhttp.VerifyAdmissionReview) and the library's BearerToken helper. It fails closed and logs only verify.Reason(err). - Deleted the local verifier / RemoteKeySet / OIDC re-implementation and its typed errors, dropping the direct github.com/go-jose/go-jose/v4 dependency. The constructor intentionally takes a caller-built *verify.Verifier so no JOSE/OIDC dependency enters controller-runtime. The library's zero-config oidckeyset.NewInClusterVerifier is documented as a future optional helper (pending SIG-Auth review) rather than wired here. The Phase 1 admission.Authenticator seam (Webhook.Authenticator, invoked after decode and before the handler) is unchanged; nil preserves existing behavior. NOTE: depends on k8s.io/webhook-auth via a local replace; the module is not yet published, so this branch is a preview pending upstream publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Benjamin A. Petersen --- go.mod | 9 +- go.sum | 10 +- .../admission/webhookauth/authenticator.go | 88 +-- .../webhookauth/authenticator_test.go | 290 +++++++--- pkg/webhook/admission/webhookauth/doc.go | 32 +- pkg/webhook/admission/webhookauth/errors.go | 53 -- pkg/webhook/admission/webhookauth/verifier.go | 504 ------------------ .../admission/webhookauth/verifier_test.go | 292 ---------- 8 files changed, 285 insertions(+), 993 deletions(-) delete mode 100644 pkg/webhook/admission/webhookauth/errors.go delete mode 100644 pkg/webhook/admission/webhookauth/verifier.go delete mode 100644 pkg/webhook/admission/webhookauth/verifier_test.go diff --git a/go.mod b/go.mod index 3dc272aff8..e67d67e8ca 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.26.0 require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/fsnotify/fsnotify v1.9.0 - github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/go-cmp v0.7.0 @@ -27,11 +26,13 @@ require ( k8s.io/apiserver v0.37.0-alpha.1 k8s.io/client-go v0.37.0-alpha.1 k8s.io/klog/v2 v2.140.0 - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + k8s.io/utils v0.0.0-20260626114624-be93311217bd sigs.k8s.io/structured-merge-diff/v6 v6.4.0 sigs.k8s.io/yaml v1.6.0 ) +require k8s.io/webhook-auth v0.0.0-00010101000000-000000000000 + require ( cel.dev/expr v0.25.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -102,9 +103,11 @@ require ( google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/component-base v0.37.0-alpha.1 // indirect - k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288 // indirect + k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974 // indirect k8s.io/streaming v0.37.0-alpha.1 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect ) + +replace k8s.io/webhook-auth => /Users/benjaminpetersen/github.com/benjaminapetersen_forks/kubernetes/staging/src/k8s.io/webhook-auth diff --git a/go.sum b/go.sum index a84aa50c85..04a9692a3e 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,6 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -251,12 +249,12 @@ k8s.io/component-base v0.37.0-alpha.1 h1:C+wu77XGtTrSvU/v4CMCmg8Uq3BmYsgDSbAy8PD k8s.io/component-base v0.37.0-alpha.1/go.mod h1:syzFTe+v8SyroLQ5JItALe3snAEmX9VTPCrCEk2K0gk= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288 h1:A7Lby6ekC6nv+6oO38huCMFBRP0Os+tIeq1GkwxOQes= -k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974 h1:JVogoTvOj6gutlx8bUwGh0e8o8L4X8nDbTLyONmoVvk= +k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/streaming v0.37.0-alpha.1 h1:8/IDL5B3WI2l7cnO3na104t9oCuQYEXZ6H3095GpfLo= k8s.io/streaming v0.37.0-alpha.1/go.mod h1:QN1+yCAfxcSvo0908Z3rFKkA5Xlr3KgUZAGwKuR6qQk= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/pkg/webhook/admission/webhookauth/authenticator.go b/pkg/webhook/admission/webhookauth/authenticator.go index f55c74f5be..72f7743804 100644 --- a/pkg/webhook/admission/webhookauth/authenticator.go +++ b/pkg/webhook/admission/webhookauth/authenticator.go @@ -18,62 +18,68 @@ package webhookauth import ( "context" - "errors" "net/http" - "strings" + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/webhook-auth/verify" + "k8s.io/webhook-auth/verify/admissionhttp" + + logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) -const authorizationSchemeBearer = "bearer" - -type authenticator struct { - verifier Verifier -} +// authenticator adapts a k8s.io/webhook-auth *verify.Verifier onto the +// controller-runtime admission.Authenticator seam. +type authenticator struct{ v *verify.Verifier } var _ admission.Authenticator = authenticator{} -func NewAuthenticator(verifier Verifier) admission.Authenticator { - return authenticator{verifier: verifier} +// NewAuthenticator returns an admission.Authenticator that enforces KEP-6060 +// API server authentication using the supplied k8s.io/webhook-auth verifier. +// +// The caller owns verifier construction, which keeps controller-runtime free of +// any JOSE/OIDC dependency: build v with +// +// verify.NewVerifier(keySet, issuer, audiences) +// +// supplying your own verify.KeySet (the single piece a real deployment provides). +// +// Opt-in / default-off: assign the returned value to Webhook.Authenticator to +// turn verification on; leaving it nil preserves existing behavior exactly. +// +// Zero-config alternative (deliberately not wired here): the library also offers +// k8s.io/webhook-auth/verify/oidckeyset.NewInClusterVerifier, which discovers the +// cluster issuer from the pod's projected service-account token and builds an +// OIDC-discovery/JWKS KeySet with no explicit configuration. We do NOT call it +// from this package because it would pull the go-oidc dependency into +// controller-runtime's module graph. If a zero-config helper is desired, we could +// add a thin optional constructor (for example NewInClusterAuthenticator) in a +// separate, optional package so consumers opt into the extra dependency. Pending +// review with SIG-Auth. +func NewAuthenticator(v *verify.Verifier) admission.Authenticator { + return authenticator{v: v} } -func (a authenticator) Authenticate(ctx context.Context, httpReq *http.Request, req admission.Request) admission.Response { - token, ok := bearerToken(httpReq) +// Authenticate verifies the API server's bearer token against the KEP-6060 +// contract and fails closed on any error. It reuses the AdmissionRequest that +// controller-runtime already decoded, so the review is never decoded twice. +// +// The library's failure model is deliberately opaque: every rejection is a single +// generic error. We therefore always deny with a 401 and log only the +// non-sensitive verify.Reason string; callers must not branch on the reason. +func (a authenticator) Authenticate(ctx context.Context, r *http.Request, req admission.Request) admission.Response { + token, ok := admissionhttp.BearerToken(r) if !ok { return admission.Unauthenticated("missing or malformed bearer token") } - if a.verifier == nil { + if a.v == nil { return admission.Unauthenticated("verifier is not configured") } - if _, err := a.verifier.Verify(ctx, token, req); err != nil { - return responseForVerifierError(err) + // Reuse controller-runtime's single decode; never re-read r.Body. + ar := &admissionv1.AdmissionReview{Request: &req.AdmissionRequest} + if err := admissionhttp.VerifyAdmissionReview(ctx, a.v, ar, token); err != nil { + logf.FromContext(ctx).Info("webhook-auth: denied unauthenticated request", "reason", verify.Reason(err)) + return admission.Unauthenticated("unauthenticated") } return admission.Allowed("") } - -func bearerToken(req *http.Request) (string, bool) { - if req == nil { - return "", false - } - values := req.Header.Values("Authorization") - if len(values) != 1 { - return "", false - } - fields := strings.Fields(values[0]) - if len(fields) != 2 || !strings.EqualFold(fields[0], authorizationSchemeBearer) || fields[1] == "" { - return "", false - } - return fields[1], true -} - -func responseForVerifierError(err error) admission.Response { - message := "authentication failed" - var verifierErr *Error - if errors.As(err, &verifierErr) && verifierErr.Message != "" { - message = verifierErr.Message - } - if IsUnauthorized(err) { - return admission.Unauthorized(message) - } - return admission.Unauthenticated(message) -} diff --git a/pkg/webhook/admission/webhookauth/authenticator_test.go b/pkg/webhook/admission/webhookauth/authenticator_test.go index e63c59c5eb..e26d1e7f2c 100644 --- a/pkg/webhook/admission/webhookauth/authenticator_test.go +++ b/pkg/webhook/admission/webhookauth/authenticator_test.go @@ -14,124 +14,252 @@ See the License for the specific language governing permissions and limitations under the License. */ -package webhookauth +package webhookauth_test import ( + "bytes" "context" - "errors" + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + "time" admissionv1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/webhook-auth/verify" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/webhookauth" +) + +const ( + testIssuer = "https://issuer.example.com" + testAudience = "webhook.example.com" ) -type fakeVerifier struct { - called bool - token string - err error +// fakeKeySet treats the raw token string as the already-signature-verified JSON +// claims payload and returns it verbatim. This is the stdlib-only fake from the +// k8s.io/webhook-auth examples: "signing" is just JSON marshaling, so the test +// carries no JOSE/OIDC dependency. +type fakeKeySet struct{} + +func (fakeKeySet) VerifySignature(_ context.Context, rawToken string) ([]byte, error) { + return []byte(rawToken), nil } -func (f *fakeVerifier) Verify(_ context.Context, token string, _ admission.Request) (*Result, error) { - f.called = true - f.token = token - if f.err != nil { - return nil, f.err +// mkToken mints a token string (== JSON claims payload) matching the KEP-6060 +// contract shape used in the library's admissionhttp example_test.go. +func mkToken(aud []string, exp time.Time, group string) string { + claims := map[string]interface{}{ + "iss": testIssuer, + "aud": aud, + "exp": exp.Unix(), + "kubernetes.io": map[string]interface{}{ + "validatingWebhookConfiguration": map[string]string{ + "name": "my-webhook", + "uid": "webhook-uid", + }, + "attestationClaims": map[string][]string{ + verify.AllowedAPIGroupClaimKey: {group}, + }, + }, } - return &Result{}, nil + payload, _ := json.Marshal(claims) + return string(payload) } -func TestAuthenticatorAuthorizationHeaderParsing(t *testing.T) { +func newVerifier(t *testing.T) *verify.Verifier { + t.Helper() + v, err := verify.NewVerifier(fakeKeySet{}, testIssuer, []string{testAudience}) + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + return v +} + +func reqForGroup(group string) admission.Request { + return admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + UID: "req-uid", + Resource: metav1.GroupVersionResource{Group: group, Version: "v1", Resource: "deployments"}, + Name: "my-deploy", + }, + } +} + +func TestAuthenticate(t *testing.T) { + future := time.Now().Add(5 * time.Minute) + past := time.Now().Add(-5 * time.Minute) + tests := []struct { - name string - headers []string - wantCalled bool - wantToken string + name string + setAuthHdr bool + authHeader string + token string + reqGroup string + wantAllowed bool }{ - {name: "missing"}, - {name: "wrong scheme", headers: []string{"Basic abc"}}, - {name: "empty bearer", headers: []string{"Bearer"}}, - {name: "extra fields", headers: []string{"Bearer abc def"}}, - {name: "multiple values", headers: []string{"Bearer abc", "Bearer def"}}, - {name: "valid bearer", headers: []string{"Bearer abc.def.ghi"}, wantCalled: true, wantToken: "abc.def.ghi"}, - {name: "case-insensitive scheme", headers: []string{"bearer abc.def.ghi"}, wantCalled: true, wantToken: "abc.def.ghi"}, + { + name: "valid token, matching group and audience, future exp", + setAuthHdr: true, + token: mkToken([]string{testAudience}, future, "apps"), + reqGroup: "apps", + wantAllowed: true, + }, + { + name: "missing token", + setAuthHdr: false, + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "wrong scheme", + setAuthHdr: true, + authHeader: "Basic abc", + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "expired token", + setAuthHdr: true, + token: mkToken([]string{testAudience}, past, "apps"), + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "wrong audience", + setAuthHdr: true, + token: mkToken([]string{"someone.else"}, future, "apps"), + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "group mismatch", + setAuthHdr: true, + token: mkToken([]string{testAudience}, future, "apps"), + reqGroup: "batch", + wantAllowed: false, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verifier := &fakeVerifier{} - authenticator := NewAuthenticator(verifier) - req := httptest.NewRequest(http.MethodPost, "/", nil) - for _, header := range tt.headers { - req.Header.Add("Authorization", header) - } - resp := authenticator.Authenticate(context.Background(), req, admission.Request{}) + auth := webhookauth.NewAuthenticator(newVerifier(t)) - if verifier.called != tt.wantCalled { - t.Fatalf("verifier called = %t, want %t", verifier.called, tt.wantCalled) - } - if verifier.token != tt.wantToken { - t.Fatalf("verifier token = %q, want %q", verifier.token, tt.wantToken) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + httpReq := httptest.NewRequest(http.MethodPost, "/", nil) + if tc.setAuthHdr { + header := tc.authHeader + if header == "" { + header = "Bearer " + tc.token + } + httpReq.Header.Set("Authorization", header) } - if tt.wantCalled && !resp.Allowed { - t.Fatalf("Authenticate() denied unexpectedly: %#v", resp.Result) + + resp := auth.Authenticate(context.Background(), httpReq, reqForGroup(tc.reqGroup)) + if resp.Allowed != tc.wantAllowed { + t.Fatalf("Authenticate allowed=%v, want %v", resp.Allowed, tc.wantAllowed) } - if !tt.wantCalled && (resp.Allowed || resp.Result.Code != http.StatusUnauthorized) { - t.Fatalf("Authenticate() = %#v, want 401 denial", resp) + if !resp.Allowed { + if resp.Result == nil || resp.Result.Code != http.StatusUnauthorized { + t.Fatalf("denied response = %#v, want 401", resp.Result) + } + if strings.Contains(resp.Result.Message, tc.token) && tc.token != "" { + t.Fatalf("response leaked token material: %q", resp.Result.Message) + } } }) } } -func TestAuthenticatorMapsVerifierErrors(t *testing.T) { - tests := []struct { - name string - err error - code int32 - reason metav1.StatusReason - }{ - {name: "unauthenticated", err: unauthenticated("bad token"), code: http.StatusUnauthorized, reason: metav1.StatusReasonUnauthorized}, - {name: "unauthorized", err: unauthorized("wrong group"), code: http.StatusForbidden, reason: metav1.StatusReasonForbidden}, - {name: "unknown", err: errors.New("boom"), code: http.StatusUnauthorized, reason: metav1.StatusReasonUnauthorized}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verifier := &fakeVerifier{err: tt.err} - authenticator := NewAuthenticator(verifier) - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.Header.Set("Authorization", "Bearer secret-token") - - resp := authenticator.Authenticate(context.Background(), req, admission.Request{ - AdmissionRequest: admissionv1.AdmissionRequest{UID: "uid"}, - }) - - if resp.Allowed || resp.Result.Code != tt.code || resp.Result.Reason != tt.reason { - t.Fatalf("Authenticate() = %#v, want %d %s denial", resp, tt.code, tt.reason) - } - if strings.Contains(resp.Result.Message, "secret-token") { - t.Fatalf("response leaked token material: %q", resp.Result.Message) - } - }) +// TestNilVerifierFailsClosed ensures a misconfigured authenticator (nil verifier) +// denies rather than panicking or allowing. +func TestNilVerifierFailsClosed(t *testing.T) { + auth := webhookauth.NewAuthenticator(nil) + httpReq := httptest.NewRequest(http.MethodPost, "/", nil) + httpReq.Header.Set("Authorization", "Bearer "+mkToken([]string{testAudience}, time.Now().Add(time.Minute), "apps")) + + resp := auth.Authenticate(context.Background(), httpReq, reqForGroup("apps")) + if resp.Allowed { + t.Fatal("nil verifier must deny") } } -func TestVerifierAllowsCoreAPIGroup(t *testing.T) { - f := newFixture(t) - token := f.token(t, tokenOptions{allowedAPIGroup: []string{""}}) +// TestAuthenticatorEndToEnd drives the authenticator through the real +// admission.Webhook.ServeHTTP pipeline (decode -> Authenticate -> Handle), +// proving the hook short-circuits an unauthenticated request before the handler +// and reuses controller-runtime's single AdmissionReview decode. +func TestAuthenticatorEndToEnd(t *testing.T) { + v := newVerifier(t) - result, err := f.verifier.Verify(context.Background(), token, admission.Request{ - AdmissionRequest: admissionv1.AdmissionRequest{ - Resource: metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, - }, + var reached bool + spy := admission.HandlerFunc(func(_ context.Context, _ admission.Request) admission.Response { + reached = true + return admission.Allowed("") }) + wh := &admission.Webhook{ + Handler: spy, + Authenticator: webhookauth.NewAuthenticator(v), + } + srv := httptest.NewServer(wh) + defer srv.Close() + + review := admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{APIVersion: "admission.k8s.io/v1", Kind: "AdmissionReview"}, + Request: &admissionv1.AdmissionRequest{ + UID: "req-uid", + Resource: metav1.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, + Name: "my-deploy", + }, + } + body, err := json.Marshal(review) if err != nil { - t.Fatalf("Verify() unexpected error: %v", err) + t.Fatalf("marshal review: %v", err) } - if result.AllowedAPIGroup != "" { - t.Fatalf("AllowedAPIGroup = %q, want core API group", result.AllowedAPIGroup) + + post := func(withToken bool) admissionv1.AdmissionReview { + t.Helper() + req, err := http.NewRequest(http.MethodPost, srv.URL, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if withToken { + req.Header.Set("Authorization", "Bearer "+mkToken([]string{testAudience}, time.Now().Add(5*time.Minute), "apps")) + } + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + var out admissionv1.AdmissionReview + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + return out } + + t.Run("valid token reaches handler and is allowed", func(t *testing.T) { + reached = false + out := post(true) + if out.Response == nil || !out.Response.Allowed { + t.Fatalf("expected Allowed=true, got %+v", out.Response) + } + if !reached { + t.Fatal("expected downstream handler to be reached") + } + }) + + t.Run("missing token is denied before handler", func(t *testing.T) { + reached = false + out := post(false) + if out.Response == nil || out.Response.Allowed { + t.Fatalf("expected Allowed=false, got %+v", out.Response) + } + if reached { + t.Fatal("expected downstream handler NOT to be reached") + } + }) } diff --git a/pkg/webhook/admission/webhookauth/doc.go b/pkg/webhook/admission/webhookauth/doc.go index 41b7a99552..57fc9d05e9 100644 --- a/pkg/webhook/admission/webhookauth/doc.go +++ b/pkg/webhook/admission/webhookauth/doc.go @@ -14,20 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package webhookauth verifies KEP-6060 admission webhook authentication JWTs. +// Package webhookauth is an opt-in controller-runtime adapter for KEP-6060 +// admission webhook authentication. // -// Verification is offline: JWS signatures are checked against cached -// OIDC-discovered JWKS, then issuer, audience, exp, nbf, iat, webhook binding, -// and allowed API group claims are enforced. TokenReview is never used by -// default. Unknown kid or cache expiry triggers JWKS refresh; refresh failure -// fails closed. +// It consumes the k8s.io/webhook-auth library rather than re-implementing token +// verification: NewAuthenticator wraps a *verify.Verifier as an +// admission.Authenticator that controller-runtime invokes after it has decoded +// the AdmissionReview and before the user handler runs. The API server's bearer +// token is verified entirely offline against the library's contract (signature, +// issuer, audience, exp/nbf/iat, webhook binding, and allowed API group) with no +// TokenReview round-trip. Any verification failure fails closed. // -// The default signing algorithm allow-list is RS256. Callers may configure -// other asymmetric algorithms, but "none" and symmetric HS* algorithms are -// rejected to follow RFC 8725 JWT BCP guidance against unsecured JWTs and -// algorithm confusion. RFC 9700 OAuth2 Security BCP likewise motivates strict -// audience-bound bearer-token validation. +// Opt-in / default-off: set the returned value on Webhook.Authenticator to turn +// verification on. Leaving Authenticator nil preserves existing behavior exactly, +// so a webhook that does not adopt verification is unaffected. // -// KEP-6060's wire format is provisional. All private claim names used here are -// isolated in verifier.go and must be reconciled after upstream API review. +// This package intentionally adds no crypto/OIDC dependency of its own. The +// caller constructs the verify.Verifier (supplying a verify.KeySet) and thus +// decides whether to pull in go-oidc. See NewAuthenticator for the zero-config +// in-cluster verifier the library offers and why it is left to the consumer. +// +// KEP-6060's wire format is provisional; the claim contract lives in +// k8s.io/webhook-auth and must be reconciled after upstream API review. package webhookauth diff --git a/pkg/webhook/admission/webhookauth/errors.go b/pkg/webhook/admission/webhookauth/errors.go deleted file mode 100644 index 338ef54d23..0000000000 --- a/pkg/webhook/admission/webhookauth/errors.go +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2026 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package webhookauth - -import "errors" - -type ErrorKind string - -const ( - ErrorKindUnauthenticated ErrorKind = "UNAUTHENTICATED" - ErrorKindUnauthorized ErrorKind = "UNAUTHORIZED" -) - -type Error struct { - Kind ErrorKind - Message string -} - -func (e *Error) Error() string { - return string(e.Kind) + ": " + e.Message -} - -func unauthenticated(message string) error { - return &Error{Kind: ErrorKindUnauthenticated, Message: message} -} - -func unauthorized(message string) error { - return &Error{Kind: ErrorKindUnauthorized, Message: message} -} - -func IsUnauthenticated(err error) bool { - var verifierErr *Error - return errors.As(err, &verifierErr) && verifierErr.Kind == ErrorKindUnauthenticated -} - -func IsUnauthorized(err error) bool { - var verifierErr *Error - return errors.As(err, &verifierErr) && verifierErr.Kind == ErrorKindUnauthorized -} diff --git a/pkg/webhook/admission/webhookauth/verifier.go b/pkg/webhook/admission/webhookauth/verifier.go deleted file mode 100644 index 9a4e0a3107..0000000000 --- a/pkg/webhook/admission/webhookauth/verifier.go +++ /dev/null @@ -1,504 +0,0 @@ -/* -Copyright 2026 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package webhookauth - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "sync" - "time" - - jose "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -// Provisional KEP-6060 wire constants. Upstream API review has not settled the -// private claim keys, binding field names, feature gate name, or audience -// derivation format; reconcile this single block with final Kubernetes API. -const ( - kubernetesPrivateClaimKey = "kubernetes.io" - validatingWebhookConfigurationClaimKey = "validatingWebhookConfiguration" - mutatingWebhookConfigurationClaimKey = "mutatingWebhookConfiguration" - attestationClaimsFieldKey = "attestationClaims" - attestationsFieldKey = "attestations" - AllowedAPIGroupClaim = "webhook-authentication.k8s.io/allowedAPIGroup" - allowedAPIGroupWildcard = "*" - webhookConfigurationBindingNameFieldKey = "name" - webhookConfigurationBindingUIDFieldKey = "uid" - wellKnownOpenIDConfigurationPathSegment = ".well-known/openid-configuration" -) - -const ( - defaultJWKSCacheTTL = 5 * time.Minute - defaultClockSkew = time.Minute -) - -type WebhookType string - -const ( - ValidatingWebhook WebhookType = "validating" - MutatingWebhook WebhookType = "mutating" -) - -type WebhookBinding struct { - Type WebhookType - Name string - UID string -} - -type Clock interface{ Now() time.Time } -type ClockFunc func() time.Time - -func (f ClockFunc) Now() time.Time { return f() } - -type KeySet interface { - Keys(context.Context, string) ([]jose.JSONWebKey, error) -} -type RefreshingKeySet interface { - KeySet - Refresh(context.Context) error -} - -type Options struct { - IssuerURL string - Audience string - Binding WebhookBinding - KeySet KeySet - HTTPClient *http.Client - Clock Clock - AllowedAlgorithms []jose.SignatureAlgorithm - ClockSkew time.Duration - JWKSCacheTTL time.Duration -} - -type Verifier interface { - Verify(context.Context, string, admission.Request) (*Result, error) -} - -type Result struct { - Subject string - Audience []string - AllowedAPIGroup string - Binding WebhookBinding - Expiration time.Time -} - -type verifier struct { - issuerURL string - audience string - binding WebhookBinding - keySet KeySet - refreshingKeySet RefreshingKeySet - clock Clock - allowedAlgs []jose.SignatureAlgorithm - clockSkew time.Duration -} - -func NewVerifier(opts Options) (Verifier, error) { - issuer, audience := strings.TrimSpace(opts.IssuerURL), strings.TrimSpace(opts.Audience) - if issuer == "" { - return nil, fmt.Errorf("issuer URL is required") - } - if audience == "" || audience == allowedAPIGroupWildcard { - return nil, fmt.Errorf("audience must be non-empty and non-wildcard") - } - if opts.Binding.Type != ValidatingWebhook && opts.Binding.Type != MutatingWebhook { - return nil, fmt.Errorf("binding type must be validating or mutating") - } - if strings.TrimSpace(opts.Binding.Name) == "" { - return nil, fmt.Errorf("binding name is required") - } - keySet := opts.KeySet - if keySet == nil { - remote, err := NewRemoteKeySet(RemoteKeySetOptions{IssuerURL: issuer, Client: opts.HTTPClient, CacheTTL: opts.JWKSCacheTTL}) - if err != nil { - return nil, err - } - keySet = remote - } - refreshing, _ := keySet.(RefreshingKeySet) - clock := opts.Clock - if clock == nil { - clock = ClockFunc(time.Now) - } - clockSkew := opts.ClockSkew - if clockSkew == 0 { - clockSkew = defaultClockSkew - } - if clockSkew < 0 || clockSkew > 5*time.Minute { - return nil, fmt.Errorf("clock skew must be between 0 and 5 minutes") - } - algs := opts.AllowedAlgorithms - if len(algs) == 0 { - algs = []jose.SignatureAlgorithm{jose.RS256} - } - for _, alg := range algs { - if alg == "" || alg == jose.SignatureAlgorithm("none") || strings.HasPrefix(string(alg), "HS") { - return nil, fmt.Errorf("unsupported signing algorithm %q", alg) - } - } - return &verifier{issuerURL: issuer, audience: audience, binding: opts.Binding, keySet: keySet, refreshingKeySet: refreshing, clock: clock, allowedAlgs: append([]jose.SignatureAlgorithm(nil), algs...), clockSkew: clockSkew}, nil -} - -func (v *verifier) Verify(ctx context.Context, raw string, req admission.Request) (*Result, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, unauthenticated("missing token") - } - if strings.Count(raw, ".") != 2 { - return nil, unauthenticated("malformed JWT") - } - parsed, err := jwt.ParseSigned(raw, v.allowedAlgs) - if err != nil { - return nil, unauthenticated("malformed or disallowed JWT") - } - if len(parsed.Headers) != 1 { - return nil, unauthenticated("JWT must contain exactly one signature") - } - claims, private, err := v.verifySignatureAndDecodeClaims(ctx, parsed) - if err != nil { - return nil, err - } - if err := claims.ValidateWithLeeway(jwt.Expected{Issuer: v.issuerURL, AnyAudience: jwt.Audience{v.audience}, Time: v.clock.Now()}, v.clockSkew); err != nil { - if errors.Is(err, jwt.ErrInvalidAudience) { - return nil, unauthorized("JWT audience is not authorized") - } - return nil, unauthenticated("JWT standard claims are invalid") - } - if claims.Expiry == nil { - return nil, unauthenticated("JWT expiration claim is required") - } - if claims.NotBefore == nil { - return nil, unauthenticated("JWT not-before claim is required") - } - if claims.IssuedAt == nil { - return nil, unauthenticated("JWT issued-at claim is required") - } - binding, err := extractAndVerifyBinding(private, v.binding) - if err != nil { - return nil, err - } - group, err := extractAndVerifyAllowedAPIGroup(private, req.Resource.Group) - if err != nil { - return nil, err - } - return &Result{Subject: claims.Subject, Audience: []string(claims.Audience), AllowedAPIGroup: group, Binding: binding, Expiration: claims.Expiry.Time()}, nil -} - -func (v *verifier) verifySignatureAndDecodeClaims(ctx context.Context, parsed *jwt.JSONWebToken) (jwt.Claims, map[string]json.RawMessage, error) { - kid := parsed.Headers[0].KeyID - keys, err := v.keySet.Keys(ctx, kid) - if err != nil { - return jwt.Claims{}, nil, unauthenticated("unable to obtain verification keys") - } - claims, private, ok := tryVerifyWithKeys(parsed, keys, parsed.Headers[0].Algorithm) - if ok { - return claims, private, nil - } - if v.refreshingKeySet != nil { - if err := v.refreshingKeySet.Refresh(ctx); err != nil { - return jwt.Claims{}, nil, unauthenticated("unable to refresh verification keys") - } - keys, err = v.refreshingKeySet.Keys(ctx, kid) - if err != nil { - return jwt.Claims{}, nil, unauthenticated("unable to obtain refreshed verification keys") - } - claims, private, ok = tryVerifyWithKeys(parsed, keys, parsed.Headers[0].Algorithm) - if ok { - return claims, private, nil - } - } - return jwt.Claims{}, nil, unauthenticated("JWT signature verification failed") -} - -func tryVerifyWithKeys(parsed *jwt.JSONWebToken, keys []jose.JSONWebKey, alg string) (jwt.Claims, map[string]json.RawMessage, bool) { - for _, key := range keys { - if key.Use != "" && key.Use != "sig" { - continue - } - if key.Algorithm != "" && key.Algorithm != alg { - continue - } - if !key.Valid() { - continue - } - var c jwt.Claims - p := map[string]json.RawMessage{} - if err := parsed.Claims(key.Key, &c, &p); err == nil { - return c, p, true - } - } - return jwt.Claims{}, nil, false -} - -func extractAndVerifyBinding(claims map[string]json.RawMessage, expected WebhookBinding) (WebhookBinding, error) { - k, err := nestedObject(claims, kubernetesPrivateClaimKey) - if err != nil { - return WebhookBinding{}, err - } - v, hv, err := bindingClaim(k, validatingWebhookConfigurationClaimKey) - if err != nil { - return WebhookBinding{}, err - } - m, hm, err := bindingClaim(k, mutatingWebhookConfigurationClaimKey) - if err != nil { - return WebhookBinding{}, err - } - if hv == hm { - return WebhookBinding{}, unauthorized("JWT must contain exactly one webhook configuration binding") - } - actual := WebhookBinding{} - if hv { - actual = WebhookBinding{Type: ValidatingWebhook, Name: v.Name, UID: v.UID} - } else { - actual = WebhookBinding{Type: MutatingWebhook, Name: m.Name, UID: m.UID} - } - if actual.Type != expected.Type || actual.Name != expected.Name { - return WebhookBinding{}, unauthorized("JWT webhook binding does not match expected webhook") - } - if expected.UID != "" && actual.UID != expected.UID { - return WebhookBinding{}, unauthorized("JWT webhook binding UID does not match expected webhook") - } - return actual, nil -} - -type webhookBindingClaim struct{ Name, UID string } - -func bindingClaim(claims map[string]json.RawMessage, key string) (webhookBindingClaim, bool, error) { - raw, ok := claims[key] - if !ok { - return webhookBindingClaim{}, false, nil - } - var claim map[string]json.RawMessage - if err := json.Unmarshal(raw, &claim); err != nil { - return webhookBindingClaim{}, false, unauthorized("JWT webhook binding claim is malformed") - } - name, err := stringField(claim, webhookConfigurationBindingNameFieldKey) - if err != nil || name == "" { - return webhookBindingClaim{}, false, unauthorized("JWT webhook binding name is required") - } - uid, err := optionalStringField(claim, webhookConfigurationBindingUIDFieldKey) - if err != nil { - return webhookBindingClaim{}, false, unauthorized("JWT webhook binding UID is malformed") - } - return webhookBindingClaim{Name: name, UID: uid}, true, nil -} - -func extractAndVerifyAllowedAPIGroup(claims map[string]json.RawMessage, resourceGroup string) (string, error) { - k, err := nestedObject(claims, kubernetesPrivateClaimKey) - if err != nil { - return "", err - } - attestations, err := attestationClaims(k) - if err != nil { - return "", err - } - vals, ok := attestations[AllowedAPIGroupClaim] - if !ok || len(vals) != 1 { - return "", unauthorized("JWT allowed API group claim must contain exactly one value") - } - if vals[0] != allowedAPIGroupWildcard && vals[0] != resourceGroup { - return "", unauthorized("JWT allowed API group claim does not match request resource") - } - return vals[0], nil -} - -func nestedObject(claims map[string]json.RawMessage, key string) (map[string]json.RawMessage, error) { - raw, ok := claims[key] - if !ok { - return nil, unauthorized("JWT Kubernetes private claims are required") - } - out := map[string]json.RawMessage{} - if err := json.Unmarshal(raw, &out); err != nil { - return nil, unauthorized("JWT Kubernetes private claims are malformed") - } - return out, nil -} - -func attestationClaims(k map[string]json.RawMessage) (map[string][]string, error) { - raw, ok := k[attestationClaimsFieldKey] - if !ok { - raw, ok = k[attestationsFieldKey] - } - if !ok { - return nil, unauthorized("JWT attestation claims are required") - } - out := map[string][]string{} - if err := json.Unmarshal(raw, &out); err != nil { - return nil, unauthorized("JWT attestation claims are malformed") - } - return out, nil -} - -func stringField(claims map[string]json.RawMessage, key string) (string, error) { - raw, ok := claims[key] - if !ok { - return "", errors.New("missing string field") - } - var out string - if err := json.Unmarshal(raw, &out); err != nil { - return "", err - } - return out, nil -} - -func optionalStringField(claims map[string]json.RawMessage, key string) (string, error) { - raw, ok := claims[key] - if !ok { - return "", nil - } - var out string - if err := json.Unmarshal(raw, &out); err != nil { - return "", err - } - return out, nil -} - -type RemoteKeySetOptions struct { - IssuerURL string - Client *http.Client - CacheTTL time.Duration -} - -type RemoteKeySet struct { - issuerURL string - client *http.Client - cacheTTL time.Duration - mu sync.RWMutex - keys jose.JSONWebKeySet - expiresAt time.Time -} - -func NewRemoteKeySet(opts RemoteKeySetOptions) (*RemoteKeySet, error) { - issuer := strings.TrimRight(strings.TrimSpace(opts.IssuerURL), "/") - if issuer == "" { - return nil, fmt.Errorf("issuer URL is required") - } - if _, err := url.ParseRequestURI(issuer); err != nil { - return nil, fmt.Errorf("issuer URL is invalid: %w", err) - } - client := opts.Client - if client == nil { - client = http.DefaultClient - } - ttl := opts.CacheTTL - if ttl == 0 { - ttl = defaultJWKSCacheTTL - } - if ttl < 0 { - return nil, fmt.Errorf("JWKS cache TTL must not be negative") - } - return &RemoteKeySet{issuerURL: issuer, client: client, cacheTTL: ttl}, nil -} - -func (s *RemoteKeySet) Keys(ctx context.Context, kid string) ([]jose.JSONWebKey, error) { - if err := s.ensureFresh(ctx); err != nil { - return nil, err - } - s.mu.RLock() - defer s.mu.RUnlock() - if kid != "" { - return append([]jose.JSONWebKey(nil), s.keys.Key(kid)...), nil - } - return append([]jose.JSONWebKey(nil), s.keys.Keys...), nil -} - -func (s *RemoteKeySet) Refresh(ctx context.Context) error { - d, err := s.fetchDiscovery(ctx) - if err != nil { - return err - } - keys, err := s.fetchJWKS(ctx, d.JWKSURI) - if err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - s.keys = keys - s.expiresAt = time.Now().Add(s.cacheTTL) - return nil -} - -func (s *RemoteKeySet) ensureFresh(ctx context.Context) error { - s.mu.RLock() - fresh := len(s.keys.Keys) > 0 && time.Now().Before(s.expiresAt) - s.mu.RUnlock() - if fresh { - return nil - } - return s.Refresh(ctx) -} - -type discoveryDocument struct { - Issuer string `json:"issuer"` - JWKSURI string `json:"jwks_uri"` -} - -func (s *RemoteKeySet) fetchDiscovery(ctx context.Context) (discoveryDocument, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.issuerURL+"/"+wellKnownOpenIDConfigurationPathSegment, nil) - if err != nil { - return discoveryDocument{}, err - } - resp, err := s.client.Do(req) - if err != nil { - return discoveryDocument{}, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return discoveryDocument{}, fmt.Errorf("OIDC discovery returned status %d", resp.StatusCode) - } - var doc discoveryDocument - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return discoveryDocument{}, err - } - if doc.Issuer != s.issuerURL { - return discoveryDocument{}, fmt.Errorf("OIDC discovery issuer mismatch") - } - if doc.JWKSURI == "" { - return discoveryDocument{}, fmt.Errorf("OIDC discovery jwks_uri is required") - } - return doc, nil -} - -func (s *RemoteKeySet) fetchJWKS(ctx context.Context, uri string) (jose.JSONWebKeySet, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) - if err != nil { - return jose.JSONWebKeySet{}, err - } - resp, err := s.client.Do(req) - if err != nil { - return jose.JSONWebKeySet{}, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return jose.JSONWebKeySet{}, fmt.Errorf("JWKS returned status %d", resp.StatusCode) - } - var keys jose.JSONWebKeySet - if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil { - return jose.JSONWebKeySet{}, err - } - if len(keys.Keys) == 0 { - return jose.JSONWebKeySet{}, fmt.Errorf("JWKS contains no keys") - } - return keys, nil -} diff --git a/pkg/webhook/admission/webhookauth/verifier_test.go b/pkg/webhook/admission/webhookauth/verifier_test.go deleted file mode 100644 index 7097946c18..0000000000 --- a/pkg/webhook/admission/webhookauth/verifier_test.go +++ /dev/null @@ -1,292 +0,0 @@ -/* -Copyright 2026 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package webhookauth - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "strings" - "testing" - "time" - - jose "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" - admissionv1 "k8s.io/api/admission/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -const ( - testIssuer = "https://issuer.example.test" - testAudience = "webhook.example.test/validating/widgets" - testSubject = "system:serviceaccount:kube-system:kube-apiserver" - testWebhook = "widgets.example.test" - testUID = "12345" - testKid = "kid-1" -) - -type staticKeySet struct{ keys jose.JSONWebKeySet } - -func (s staticKeySet) Keys(_ context.Context, keyID string) ([]jose.JSONWebKey, error) { - if keyID == "" { - return s.keys.Keys, nil - } - return s.keys.Key(keyID), nil -} - -func TestVerifierValidToken(t *testing.T) { - f := newFixture(t) - result, err := f.verify(t, f.token(t, tokenOptions{})) - if err != nil { - t.Fatalf("Verify() unexpected error: %v", err) - } - if result.Subject != testSubject || result.AllowedAPIGroup != "apps" { - t.Fatalf("result = %#v", result) - } -} - -func TestVerifierUnauthenticatedFailures(t *testing.T) { - f := newFixture(t) - otherKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - tests := []struct{ name, token string }{ - {name: "bad signature", token: f.tokenWithKey(t, otherKey, testKid, jose.RS256, tokenOptions{})}, - {name: "none alg", token: f.noneToken(t)}, - {name: "disallowed alg", token: f.tokenWithKey(t, []byte(strings.Repeat("s", 32)), testKid, jose.HS256, tokenOptions{})}, - {name: "malformed", token: "not-a-jwt"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := f.verify(t, tt.token) - if !IsUnauthenticated(err) { - t.Fatalf("Verify() error = %v, want unauthenticated", err) - } - if strings.Contains(err.Error(), tt.token) { - t.Fatalf("error leaked token material: %v", err) - } - }) - } -} - -func TestVerifierStandardClaimFailures(t *testing.T) { - f := newFixture(t) - tests := []struct { - name string - opts tokenOptions - wantUnauthenticated bool - }{ - {name: "wrong issuer", opts: tokenOptions{issuer: "https://other.example.test"}, wantUnauthenticated: true}, - {name: "expired", opts: tokenOptions{expiry: f.now.Add(-2 * time.Minute)}, wantUnauthenticated: true}, - {name: "not yet valid", opts: tokenOptions{notBefore: f.now.Add(2 * time.Minute)}, wantUnauthenticated: true}, - {name: "wrong audience", opts: tokenOptions{audience: []string{"other-audience"}}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := f.verify(t, f.token(t, tt.opts)) - if tt.wantUnauthenticated { - if !IsUnauthenticated(err) { - t.Fatalf("Verify() error = %v, want unauthenticated", err) - } - return - } - if !IsUnauthorized(err) { - t.Fatalf("Verify() error = %v, want unauthorized", err) - } - }) - } -} - -func TestVerifierWebhookBindingClaims(t *testing.T) { - f := newFixture(t) - tests := []struct { - name string - opts tokenOptions - }{ - {name: "missing binding", opts: tokenOptions{binding: map[string]any{}}}, - {name: "multiple binding claims", opts: tokenOptions{binding: map[string]any{validatingWebhookConfigurationClaimKey: binding(testWebhook, testUID), mutatingWebhookConfigurationClaimKey: binding(testWebhook, testUID)}}}, - {name: "wrong binding type", opts: tokenOptions{binding: map[string]any{mutatingWebhookConfigurationClaimKey: binding(testWebhook, testUID)}}}, - {name: "wrong binding name", opts: tokenOptions{binding: map[string]any{validatingWebhookConfigurationClaimKey: binding("other", testUID)}}}, - {name: "wrong binding uid", opts: tokenOptions{binding: map[string]any{validatingWebhookConfigurationClaimKey: binding(testWebhook, "other")}}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if _, err := f.verify(t, f.token(t, tt.opts)); !IsUnauthorized(err) { - t.Fatalf("Verify() error = %v, want unauthorized", err) - } - }) - } -} - -func TestVerifierAllowedAPIGroupClaims(t *testing.T) { - f := newFixture(t) - tests := []struct { - name string - opts tokenOptions - want string - }{ - {name: "missing allowed API group", opts: tokenOptions{allowedAPIGroup: []string{}}}, - {name: "multi valued allowed API group", opts: tokenOptions{allowedAPIGroup: []string{"apps", "batch"}}}, - {name: "wrong allowed API group", opts: tokenOptions{allowedAPIGroup: []string{"batch"}}}, - {name: "wildcard allowed API group", opts: tokenOptions{allowedAPIGroup: []string{allowedAPIGroupWildcard}}, want: allowedAPIGroupWildcard}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := f.verify(t, f.token(t, tt.opts)) - if tt.want != "" { - if err != nil || result.AllowedAPIGroup != tt.want { - t.Fatalf("Verify() = %#v, %v; want allowed API group %q", result, err, tt.want) - } - return - } - if !IsUnauthorized(err) { - t.Fatalf("Verify() error = %v, want unauthorized", err) - } - }) - } -} - -type fixture struct { - now time.Time - key *rsa.PrivateKey - verifier Verifier -} - -type tokenOptions struct { - issuer string - audience []string - expiry time.Time - notBefore time.Time - issuedAt time.Time - binding map[string]any - allowedAPIGroup []string -} - -func newFixture(t *testing.T) fixture { - t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 7, 7, 17, 0, 0, 0, time.UTC) - verifier, err := NewVerifier(Options{ - IssuerURL: testIssuer, - Audience: testAudience, - Binding: WebhookBinding{Type: ValidatingWebhook, Name: testWebhook, UID: testUID}, - KeySet: staticKeySet{keys: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: testKid, Use: "sig", Algorithm: string(jose.RS256)}}}}, - Clock: ClockFunc(func() time.Time { return now }), - }) - if err != nil { - t.Fatal(err) - } - return fixture{now: now, key: key, verifier: verifier} -} - -func (f fixture) verify(t *testing.T, token string) (*Result, error) { - t.Helper() - return f.verifier.Verify(context.Background(), token, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ - Resource: metav1.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, - }}) -} - -func (f fixture) token(t *testing.T, opts tokenOptions) string { - t.Helper() - return f.tokenWithKey(t, f.key, testKid, jose.RS256, opts) -} - -func (f fixture) tokenWithKey(t *testing.T, key any, kid string, alg jose.SignatureAlgorithm, opts tokenOptions) string { - t.Helper() - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: key}, (&jose.SignerOptions{}).WithHeader(jose.HeaderKey("kid"), kid)) - if err != nil { - t.Fatal(err) - } - issuer := opts.issuer - if issuer == "" { - issuer = testIssuer - } - audience := opts.audience - if audience == nil { - audience = []string{testAudience} - } - expiry := opts.expiry - if expiry.IsZero() { - expiry = f.now.Add(time.Hour) - } - notBefore := opts.notBefore - if notBefore.IsZero() { - notBefore = f.now.Add(-time.Minute) - } - issuedAt := opts.issuedAt - if issuedAt.IsZero() { - issuedAt = f.now.Add(-time.Minute) - } - claims := jwt.Claims{Issuer: issuer, Subject: testSubject, Audience: jwt.Audience(audience), Expiry: jwt.NewNumericDate(expiry), NotBefore: jwt.NewNumericDate(notBefore), IssuedAt: jwt.NewNumericDate(issuedAt)} - token, err := jwt.Signed(signer).Claims(claims).Claims(map[string]any{kubernetesPrivateClaimKey: f.privateKubernetesClaims(opts)}).Serialize() - if err != nil { - t.Fatal(err) - } - return token -} - -func (f fixture) privateKubernetesClaims(opts tokenOptions) map[string]any { - bindingClaims := opts.binding - if bindingClaims == nil { - bindingClaims = map[string]any{validatingWebhookConfigurationClaimKey: binding(testWebhook, testUID)} - } - out := map[string]any{} - for key, value := range bindingClaims { - out[key] = value - } - allowedAPIGroup := opts.allowedAPIGroup - if allowedAPIGroup == nil { - allowedAPIGroup = []string{"apps"} - } - out[attestationClaimsFieldKey] = map[string][]string{AllowedAPIGroupClaim: allowedAPIGroup} - return out -} - -func binding(name, uid string) map[string]string { - return map[string]string{webhookConfigurationBindingNameFieldKey: name, webhookConfigurationBindingUIDFieldKey: uid} -} - -func (f fixture) noneToken(t *testing.T) string { - t.Helper() - claims := jwt.Claims{Issuer: testIssuer, Subject: testSubject, Audience: jwt.Audience{testAudience}, Expiry: jwt.NewNumericDate(f.now.Add(time.Hour)), NotBefore: jwt.NewNumericDate(f.now.Add(-time.Minute)), IssuedAt: jwt.NewNumericDate(f.now.Add(-time.Minute))} - claimsBytes, err := json.Marshal(claims) - if err != nil { - t.Fatal(err) - } - var claimsMap map[string]any - if err := json.Unmarshal(claimsBytes, &claimsMap); err != nil { - t.Fatal(err) - } - claimsMap[kubernetesPrivateClaimKey] = f.privateKubernetesClaims(tokenOptions{}) - headerBytes, err := json.Marshal(map[string]string{"alg": "none"}) - if err != nil { - t.Fatal(err) - } - payloadBytes, err := json.Marshal(claimsMap) - if err != nil { - t.Fatal(err) - } - enc := base64.RawURLEncoding - return enc.EncodeToString(headerBytes) + "." + enc.EncodeToString(payloadBytes) + "." -} From 5bec321d11550e612631266b1250f44c210a31d6 Mon Sep 17 00:00:00 2001 From: "Benjamin A. Petersen" Date: Fri, 10 Jul 2026 12:11:36 -0400 Subject: [PATCH 3/6] :white_check_mark: KEP-6060: reconcile webhookauth adapter with v2 verify seam + strengthen tests The k8s.io/webhook-auth library moved to a single-arg verify.NewVerifier(TokenAuthenticator) seam (iss/aud/exp now live in the authenticator; oidckeyset renamed to oidc). Update the adapter doc comments to the v2 surface (comments only, no behavior change) and rewrite the adapter tests to inject a fake verify.TokenAuthenticator instead of the removed v1 KeySet. Tests: add mutating-bound and empty-bearer adapter cases; lock the core seam's denied-response default to 403 (was an uncovered fail-open branch). All pkg/webhook/admission tests green. --- pkg/webhook/admission/authenticator_test.go | 4 + .../admission/webhookauth/authenticator.go | 13 +- .../webhookauth/authenticator_test.go | 153 +++++++++++++----- pkg/webhook/admission/webhookauth/doc.go | 7 +- 4 files changed, 132 insertions(+), 45 deletions(-) diff --git a/pkg/webhook/admission/authenticator_test.go b/pkg/webhook/admission/authenticator_test.go index 448d255b80..f35da04e73 100644 --- a/pkg/webhook/admission/authenticator_test.go +++ b/pkg/webhook/admission/authenticator_test.go @@ -118,6 +118,10 @@ var _ = Describe("Admission webhook authenticators", func() { }, Entry("unauthenticated", Unauthenticated("missing bearer token"), int32(http.StatusUnauthorized), metav1.StatusReasonUnauthorized, "missing bearer token"), Entry("unauthorized", Unauthorized("api group is not allowed"), int32(http.StatusForbidden), metav1.StatusReasonForbidden, "api group is not allowed"), + // A bare denied Response (nil Result / zero Code) must be defaulted to 403 + // Forbidden. Without completeDeniedAuthenticationResponse doing this, Complete + // would silently default the code to 200 for a denied response. + Entry("bare denial defaults to forbidden", Response{}, int32(http.StatusForbidden), metav1.StatusReasonForbidden, ""), ) It("keeps typed, multi-handler, and standalone webhooks compatible", func() { diff --git a/pkg/webhook/admission/webhookauth/authenticator.go b/pkg/webhook/admission/webhookauth/authenticator.go index 72f7743804..5c342d3ee8 100644 --- a/pkg/webhook/admission/webhookauth/authenticator.go +++ b/pkg/webhook/admission/webhookauth/authenticator.go @@ -40,17 +40,20 @@ var _ admission.Authenticator = authenticator{} // The caller owns verifier construction, which keeps controller-runtime free of // any JOSE/OIDC dependency: build v with // -// verify.NewVerifier(keySet, issuer, audiences) +// verify.NewVerifier(authenticator) // -// supplying your own verify.KeySet (the single piece a real deployment provides). +// supplying your own verify.TokenAuthenticator (the single piece a real +// deployment provides): it verifies the token's signature and standard iss/aud/exp +// claims and returns a *verify.VerifiedClaims for the policy layer to finish. // // Opt-in / default-off: assign the returned value to Webhook.Authenticator to // turn verification on; leaving it nil preserves existing behavior exactly. // // Zero-config alternative (deliberately not wired here): the library also offers -// k8s.io/webhook-auth/verify/oidckeyset.NewInClusterVerifier, which discovers the -// cluster issuer from the pod's projected service-account token and builds an -// OIDC-discovery/JWKS KeySet with no explicit configuration. We do NOT call it +// k8s.io/webhook-auth/verify/oidc.InCluster, which discovers the cluster issuer +// from the pod's projected service-account token and builds an +// OIDC-discovery/JWKS authenticator with no explicit configuration (option-free; +// for an explicit issuer/audience use oidc.NewRemoteVerifier). We do NOT call it // from this package because it would pull the go-oidc dependency into // controller-runtime's module graph. If a zero-config helper is desired, we could // add a thin optional constructor (for example NewInClusterAuthenticator) in a diff --git a/pkg/webhook/admission/webhookauth/authenticator_test.go b/pkg/webhook/admission/webhookauth/authenticator_test.go index e26d1e7f2c..d8d673e179 100644 --- a/pkg/webhook/admission/webhookauth/authenticator_test.go +++ b/pkg/webhook/admission/webhookauth/authenticator_test.go @@ -20,11 +20,11 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" "testing" - "time" admissionv1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -37,42 +37,75 @@ import ( const ( testIssuer = "https://issuer.example.com" testAudience = "webhook.example.com" + + // allowedAPIGroupClaimKey mirrors the (unexported) claim key in + // k8s.io/webhook-auth/verify; external consumers must hardcode the literal. + allowedAPIGroupClaimKey = "webhook-authentication.k8s.io/allowedAPIGroup" + + // authFailureToken is a raw token that is NOT a decodable claims payload, so + // fakeAuthenticator's json.Unmarshal fails and it returns an error. In the v2 + // seam the core verify.Verifier no longer checks the signature or the standard + // iss/aud/exp claims — those move into the TokenAuthenticator — so this stands + // in for the real authenticator rejecting an expired / wrong-audience / + // bad-signature token. The Verifier collapses that into its single generic + // failure and the adapter fails closed. + authFailureToken = "simulated-signature-or-standard-claim-failure" ) -// fakeKeySet treats the raw token string as the already-signature-verified JSON -// claims payload and returns it verbatim. This is the stdlib-only fake from the -// k8s.io/webhook-auth examples: "signing" is just JSON marshaling, so the test -// carries no JOSE/OIDC dependency. -type fakeKeySet struct{} +// fakeAuthenticator is a stand-in verify.TokenAuthenticator that keeps the test +// pure-stdlib and offline. It treats the raw token as the already +// signature-verified JSON claims payload and decodes it into a +// *verify.VerifiedClaims — the only supported way to populate the unexported +// Kubernetes claims from outside the library. A json.Unmarshal failure plays the +// role of the real authenticator rejecting a token whose signature or standard +// claims (iss/aud/exp) did not verify. +type fakeAuthenticator struct{} -func (fakeKeySet) VerifySignature(_ context.Context, rawToken string) ([]byte, error) { - return []byte(rawToken), nil +func (fakeAuthenticator) AuthenticateToken(_ context.Context, rawToken string) (*verify.VerifiedClaims, error) { + var claims verify.VerifiedClaims + if err := json.Unmarshal([]byte(rawToken), &claims); err != nil { + return nil, fmt.Errorf("simulated authentication failure: %w", err) + } + return &claims, nil } // mkToken mints a token string (== JSON claims payload) matching the KEP-6060 -// contract shape used in the library's admissionhttp example_test.go. -func mkToken(aud []string, exp time.Time, group string) string { - claims := map[string]interface{}{ - "iss": testIssuer, - "aud": aud, - "exp": exp.Unix(), - "kubernetes.io": map[string]interface{}{ - "validatingWebhookConfiguration": map[string]string{ - "name": "my-webhook", - "uid": "webhook-uid", - }, - "attestationClaims": map[string][]string{ - verify.AllowedAPIGroupClaimKey: {group}, - }, +// contract. Only the "kubernetes.io" object is consulted by fakeAuthenticator's +// decode (and thus by the core policy); iss/sub/aud are carried for realism and +// are inert here because the real signature/standard-claim checks live in the +// authenticator, which this fake simulates. mutate customizes the +// "kubernetes.io" claims before marshaling; nil leaves the valid baseline +// (a single validating-webhook binding authorized for group). +func mkToken(t *testing.T, group string, mutate func(k8s map[string]any)) string { + t.Helper() + k8s := map[string]any{ + "validatingWebhookConfiguration": map[string]string{ + "name": "my-webhook", + "uid": "webhook-uid", }, + "attestationClaims": map[string][]string{ + allowedAPIGroupClaimKey: {group}, + }, + } + if mutate != nil { + mutate(k8s) + } + claims := map[string]any{ + "iss": testIssuer, + "sub": "system:serviceaccount:kube-system:webhook", + "aud": []string{testAudience}, + "kubernetes.io": k8s, + } + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) } - payload, _ := json.Marshal(claims) return string(payload) } func newVerifier(t *testing.T) *verify.Verifier { t.Helper() - v, err := verify.NewVerifier(fakeKeySet{}, testIssuer, []string{testAudience}) + v, err := verify.NewVerifier(fakeAuthenticator{}) if err != nil { t.Fatalf("NewVerifier: %v", err) } @@ -90,8 +123,22 @@ func reqForGroup(group string) admission.Request { } func TestAuthenticate(t *testing.T) { - future := time.Now().Add(5 * time.Minute) - past := time.Now().Add(-5 * time.Minute) + validApps := mkToken(t, "apps", nil) + wildcard := mkToken(t, "*", nil) + mutatingApps := mkToken(t, "apps", func(k8s map[string]any) { + // Exactly one bound object, but mutating instead of validating: the + // exactly-one rule treats the two symmetrically, so this must be accepted. + delete(k8s, "validatingWebhookConfiguration") + k8s["mutatingWebhookConfiguration"] = map[string]string{"name": "mwc", "uid": "mwc-uid"} + }) + bothBound := mkToken(t, "apps", func(k8s map[string]any) { + // Two bound objects violates the exactly-one rule. + k8s["mutatingWebhookConfiguration"] = map[string]string{"name": "mwc", "uid": "mwc-uid"} + }) + noBound := mkToken(t, "apps", func(k8s map[string]any) { + // Zero bound objects violates the exactly-one rule. + delete(k8s, "validatingWebhookConfiguration") + }) tests := []struct { name string @@ -102,9 +149,23 @@ func TestAuthenticate(t *testing.T) { wantAllowed bool }{ { - name: "valid token, matching group and audience, future exp", + name: "valid token, matching group", + setAuthHdr: true, + token: validApps, + reqGroup: "apps", + wantAllowed: true, + }, + { + name: "wildcard allowedAPIGroup matches any review group", + setAuthHdr: true, + token: wildcard, + reqGroup: "batch", + wantAllowed: true, + }, + { + name: "valid token bound to mutating webhook, matching group", setAuthHdr: true, - token: mkToken([]string{testAudience}, future, "apps"), + token: mutatingApps, reqGroup: "apps", wantAllowed: true, }, @@ -122,23 +183,41 @@ func TestAuthenticate(t *testing.T) { wantAllowed: false, }, { - name: "expired token", + // A "Bearer" scheme with no token must be treated as absent, not as an + // empty-string token handed to the verifier. + name: "empty bearer token", + setAuthHdr: true, + authHeader: "Bearer ", + reqGroup: "apps", + wantAllowed: false, + }, + { + // Stands in for expired / wrong-audience / bad-signature: in v2 those + // checks live in the authenticator, which here returns an error. + name: "authenticator error", + setAuthHdr: true, + token: authFailureToken, + reqGroup: "apps", + wantAllowed: false, + }, + { + name: "bound-object violation: both validating and mutating", setAuthHdr: true, - token: mkToken([]string{testAudience}, past, "apps"), + token: bothBound, reqGroup: "apps", wantAllowed: false, }, { - name: "wrong audience", + name: "bound-object violation: neither validating nor mutating", setAuthHdr: true, - token: mkToken([]string{"someone.else"}, future, "apps"), + token: noBound, reqGroup: "apps", wantAllowed: false, }, { - name: "group mismatch", + name: "allowedAPIGroup mismatch", setAuthHdr: true, - token: mkToken([]string{testAudience}, future, "apps"), + token: validApps, reqGroup: "batch", wantAllowed: false, }, @@ -165,7 +244,7 @@ func TestAuthenticate(t *testing.T) { if resp.Result == nil || resp.Result.Code != http.StatusUnauthorized { t.Fatalf("denied response = %#v, want 401", resp.Result) } - if strings.Contains(resp.Result.Message, tc.token) && tc.token != "" { + if tc.token != "" && strings.Contains(resp.Result.Message, tc.token) { t.Fatalf("response leaked token material: %q", resp.Result.Message) } } @@ -178,7 +257,7 @@ func TestAuthenticate(t *testing.T) { func TestNilVerifierFailsClosed(t *testing.T) { auth := webhookauth.NewAuthenticator(nil) httpReq := httptest.NewRequest(http.MethodPost, "/", nil) - httpReq.Header.Set("Authorization", "Bearer "+mkToken([]string{testAudience}, time.Now().Add(time.Minute), "apps")) + httpReq.Header.Set("Authorization", "Bearer "+mkToken(t, "apps", nil)) resp := auth.Authenticate(context.Background(), httpReq, reqForGroup("apps")) if resp.Allowed { @@ -227,7 +306,7 @@ func TestAuthenticatorEndToEnd(t *testing.T) { } req.Header.Set("Content-Type", "application/json") if withToken { - req.Header.Set("Authorization", "Bearer "+mkToken([]string{testAudience}, time.Now().Add(5*time.Minute), "apps")) + req.Header.Set("Authorization", "Bearer "+mkToken(t, "apps", nil)) } resp, err := srv.Client().Do(req) if err != nil { diff --git a/pkg/webhook/admission/webhookauth/doc.go b/pkg/webhook/admission/webhookauth/doc.go index 57fc9d05e9..dd84666e79 100644 --- a/pkg/webhook/admission/webhookauth/doc.go +++ b/pkg/webhook/admission/webhookauth/doc.go @@ -30,9 +30,10 @@ limitations under the License. // so a webhook that does not adopt verification is unaffected. // // This package intentionally adds no crypto/OIDC dependency of its own. The -// caller constructs the verify.Verifier (supplying a verify.KeySet) and thus -// decides whether to pull in go-oidc. See NewAuthenticator for the zero-config -// in-cluster verifier the library offers and why it is left to the consumer. +// caller constructs the verify.Verifier (supplying a verify.TokenAuthenticator) +// and thus decides whether to pull in go-oidc. See NewAuthenticator for the +// zero-config in-cluster verifier the library offers and why it is left to the +// consumer. // // KEP-6060's wire format is provisional; the claim contract lives in // k8s.io/webhook-auth and must be reconciled after upstream API review. From b027da384a2211c90880254cb201e64bd1f9f91b Mon Sep 17 00:00:00 2001 From: "Benjamin A. Petersen" Date: Thu, 16 Jul 2026 15:47:13 -0400 Subject: [PATCH 4/6] feat(webhook): native admission.Authenticator for KEP-6060 on k8s.io/webhookauth v3 Replace the pkg/webhook/admission/webhookauth/ subpackage adapter with a native adapter in pkg/webhook/admission (verifierAuthenticator / NewAuthenticator + fluent WithInClusterAuthenticator / WithRemoteAuthenticator). Migrate to the renamed no-dash k8s.io/webhookauth v3 API: - Verify is now error-only - admissionhttp.VerifyAdmissionRequest for admission-request verification - incluster.InCluster for the in-cluster authenticator The interim local `replace k8s.io/webhookauth => ` plus the v0.0.0 pseudo-version is deliberate and load-bearing: the module is still unpublished, so the local staging checkout is required to build. Comprehensive authenticator_ext_test.go binding-violation coverage (bothBound / noBound / mutatingApps) is deferred to a follow-up commit; TODO markers are left in-file. Signed-off-by: Benjamin A. Petersen --- go.mod | 8 +- go.sum | 10 + pkg/webhook/admission/authenticator.go | 122 ++++++++++ ...ator_test.go => authenticator_ext_test.go} | 219 ++++++++++-------- pkg/webhook/admission/authenticator_test.go | 56 ++--- .../admission/example_authenticator_test.go | 102 ++++++++ pkg/webhook/admission/http.go | 4 +- pkg/webhook/admission/webhook.go | 11 +- .../admission/webhookauth/authenticator.go | 88 ------- pkg/webhook/admission/webhookauth/doc.go | 40 ---- 10 files changed, 391 insertions(+), 269 deletions(-) create mode 100644 pkg/webhook/admission/authenticator.go rename pkg/webhook/admission/{webhookauth/authenticator_test.go => authenticator_ext_test.go} (54%) create mode 100644 pkg/webhook/admission/example_authenticator_test.go delete mode 100644 pkg/webhook/admission/webhookauth/authenticator.go delete mode 100644 pkg/webhook/admission/webhookauth/doc.go diff --git a/go.mod b/go.mod index e67d67e8ca..1f851cd27f 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( sigs.k8s.io/yaml v1.6.0 ) -require k8s.io/webhook-auth v0.0.0-00010101000000-000000000000 +require k8s.io/webhookauth v0.0.0-00010101000000-000000000000 require ( cel.dev/expr v0.25.1 // indirect @@ -41,6 +41,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc v2.5.0+incompatible // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -73,6 +74,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/spf13/cobra v1.10.2 // indirect @@ -90,6 +92,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.55.1-0.20260602153038-42abb857022c // indirect golang.org/x/oauth2 v0.36.0 // indirect @@ -101,6 +104,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/component-base v0.37.0-alpha.1 // indirect k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974 // indirect @@ -110,4 +114,4 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect ) -replace k8s.io/webhook-auth => /Users/benjaminpetersen/github.com/benjaminapetersen_forks/kubernetes/staging/src/k8s.io/webhook-auth +replace k8s.io/webhookauth => /Users/benjaminpetersen/github.com/benjaminapetersen_forks/kubernetes/staging/src/k8s.io/webhookauth diff --git a/go.sum b/go.sum index 04a9692a3e..b653688730 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc v2.5.0+incompatible h1:6W0vGJR3Tu0r0PwfmjOrRZSlfxeEln8dsejt3ZWIvwo= +github.com/coreos/go-oidc v2.5.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -132,6 +134,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= +github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -152,6 +156,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -194,6 +199,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= @@ -231,8 +238,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= +gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.37.0-alpha.1 h1:fTnRCeg6apyW4NEMAzh4I0QKmhLrfBn/Wd7ofdiWrWs= diff --git a/pkg/webhook/admission/authenticator.go b/pkg/webhook/admission/authenticator.go new file mode 100644 index 0000000000..ca8586cc48 --- /dev/null +++ b/pkg/webhook/admission/authenticator.go @@ -0,0 +1,122 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "context" + "net/http" + + "k8s.io/webhookauth/verify" + "k8s.io/webhookauth/verify/admissionhttp" + "k8s.io/webhookauth/verify/oidc" + "k8s.io/webhookauth/verify/oidc/incluster" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// verifierAuthenticator adapts a k8s.io/webhookauth *verify.Verifier onto the +// admission.Authenticator seam. +type verifierAuthenticator struct{ v *verify.Verifier } + +var _ Authenticator = verifierAuthenticator{} + +// NewAuthenticator wraps a k8s.io/webhookauth *verify.Verifier as an +// Authenticator that enforces KEP-6060 API server authentication. +// +// This is the advanced / bring-your-own verifier entry point. Most users should +// prefer the fluent Webhook.WithInClusterAuthenticator (zero-config, in-cluster) +// or Webhook.WithRemoteAuthenticator (explicit issuer/audience) methods, which +// build the verifier for you. +// +// The verifier verifies the API server's bearer token entirely offline against +// the library's contract (signature, issuer, audience, exp/nbf/iat, webhook +// binding, and allowed API group). Any verification failure fails closed. +func NewAuthenticator(v *verify.Verifier) Authenticator { + return verifierAuthenticator{v: v} +} + +// Authenticate verifies the API server's bearer token against the KEP-6060 +// contract and fails closed on any error. It reuses the AdmissionRequest that +// controller-runtime already decoded, so the review is never decoded twice. +// +// The library's failure model is deliberately opaque: every rejection is a single +// generic error (verify.ErrVerificationFailed). We therefore always deny with a +// 401 and log only a static message; callers must not branch on the reason and no +// claim material is ever surfaced. +func (a verifierAuthenticator) Authenticate(ctx context.Context, r *http.Request, req Request) Response { + token, ok := admissionhttp.BearerToken(r) + if !ok { + return Unauthenticated("missing or malformed bearer token") + } + if a.v == nil { + return Unauthenticated("verifier is not configured") + } + // Reuse controller-runtime's single decode; never re-read r.Body. + if err := admissionhttp.VerifyAdmissionRequest(ctx, a.v, &req.AdmissionRequest, token); err != nil { + logf.FromContext(ctx).Info("webhook-auth: denied unauthenticated request") + return Unauthenticated("unauthenticated") + } + return Allowed("") +} + +// WithAuthenticator sets a custom Authenticator (bring-your-own / testing) and +// returns the Webhook for fluent chaining. A nil Authenticator preserves the +// default admission webhook behavior. +func (wh *Webhook) WithAuthenticator(a Authenticator) *Webhook { + wh.authenticator = a + return wh +} + +// WithInClusterAuthenticator configures zero-config in-cluster KEP-6060 +// authentication: the issuer, audience, and trusted CA are read from the pod's +// own projected service-account token (see k8s.io/webhookauth/verify/oidc/incluster.InCluster). +// +// It performs OIDC discovery now (a network round-trip) and returns an error so +// setup fails fast at startup. Pass a process-lifetime ctx (e.g. the manager's) — +// it governs OIDC discovery and the long-lived background JWKS refresh. +// +// On success it returns the Webhook for fluent chaining. +func (wh *Webhook) WithInClusterAuthenticator(ctx context.Context) (*Webhook, error) { + v, err := incluster.InCluster(ctx) + if err != nil { + return nil, err + } + wh.authenticator = NewAuthenticator(v) + return wh, nil +} + +// WithRemoteAuthenticator configures explicit issuer/audience KEP-6060 +// authentication via OIDC discovery (see k8s.io/webhookauth/verify/oidc.NewRemoteVerifier). +// +// httpClient (may be nil) is the transport that trusts the issuer's serving CA. +// It performs OIDC discovery now (a network round-trip) and returns an error so +// setup fails fast at startup. Pass a process-lifetime ctx — it governs OIDC +// discovery and the long-lived background JWKS refresh. +// +// On success it returns the Webhook for fluent chaining. +func (wh *Webhook) WithRemoteAuthenticator(ctx context.Context, issuer, audience string, httpClient *http.Client) (*Webhook, error) { + var opts []oidc.Option + if httpClient != nil { + opts = append(opts, oidc.WithHTTPClient(httpClient)) + } + v, err := oidc.NewRemoteVerifier(ctx, issuer, audience, opts...) + if err != nil { + return nil, err + } + wh.authenticator = NewAuthenticator(v) + return wh, nil +} diff --git a/pkg/webhook/admission/webhookauth/authenticator_test.go b/pkg/webhook/admission/authenticator_ext_test.go similarity index 54% rename from pkg/webhook/admission/webhookauth/authenticator_test.go rename to pkg/webhook/admission/authenticator_ext_test.go index d8d673e179..ed580fe318 100644 --- a/pkg/webhook/admission/webhookauth/authenticator_test.go +++ b/pkg/webhook/admission/authenticator_ext_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package webhookauth_test +package admission_test import ( "bytes" @@ -25,27 +25,23 @@ import ( "net/http/httptest" "strings" "testing" + "time" admissionv1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/webhook-auth/verify" + "k8s.io/webhookauth/verify" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission/webhookauth" ) const ( testIssuer = "https://issuer.example.com" testAudience = "webhook.example.com" - // allowedAPIGroupClaimKey mirrors the (unexported) claim key in - // k8s.io/webhook-auth/verify; external consumers must hardcode the literal. - allowedAPIGroupClaimKey = "webhook-authentication.k8s.io/allowedAPIGroup" - // authFailureToken is a raw token that is NOT a decodable claims payload, so - // fakeAuthenticator's json.Unmarshal fails and it returns an error. In the v2 + // fakeAuthenticator's json.Unmarshal fails and it returns an error. In the v3 // seam the core verify.Verifier no longer checks the signature or the standard - // iss/aud/exp claims — those move into the TokenAuthenticator — so this stands + // iss/aud/exp claims — those live in the TokenAuthenticator — so this stands // in for the real authenticator rejecting an expired / wrong-audience / // bad-signature token. The Verifier collapses that into its single generic // failure and the adapter fails closed. @@ -53,52 +49,37 @@ const ( ) // fakeAuthenticator is a stand-in verify.TokenAuthenticator that keeps the test -// pure-stdlib and offline. It treats the raw token as the already -// signature-verified JSON claims payload and decodes it into a -// *verify.VerifiedClaims — the only supported way to populate the unexported -// Kubernetes claims from outside the library. A json.Unmarshal failure plays the -// role of the real authenticator rejecting a token whose signature or standard -// claims (iss/aud/exp) did not verify. +// pure-stdlib and offline. In the v3 seam AuthenticateToken returns the token's +// allowedAPIGroup values (or an error simulating a signature / iss / aud / exp +// failure); the webhook-binding and exactly-one-of-(validating|mutating) checks +// now live inside the real authenticator, so they are not exercised through this +// fake. It decodes the raw token as a JSON payload carrying the allowed groups; a +// json.Unmarshal failure plays the role of the real authenticator rejecting a +// token whose signature or standard claims did not verify. type fakeAuthenticator struct{} -func (fakeAuthenticator) AuthenticateToken(_ context.Context, rawToken string) (*verify.VerifiedClaims, error) { - var claims verify.VerifiedClaims - if err := json.Unmarshal([]byte(rawToken), &claims); err != nil { +func (fakeAuthenticator) AuthenticateToken(_ context.Context, rawToken string) ([]string, error) { + var payload struct { + AllowedAPIGroups []string `json:"allowedAPIGroups"` + } + if err := json.Unmarshal([]byte(rawToken), &payload); err != nil { return nil, fmt.Errorf("simulated authentication failure: %w", err) } - return &claims, nil + return payload.AllowedAPIGroups, nil } -// mkToken mints a token string (== JSON claims payload) matching the KEP-6060 -// contract. Only the "kubernetes.io" object is consulted by fakeAuthenticator's -// decode (and thus by the core policy); iss/sub/aud are carried for realism and -// are inert here because the real signature/standard-claim checks live in the -// authenticator, which this fake simulates. mutate customizes the -// "kubernetes.io" claims before marshaling; nil leaves the valid baseline -// (a single validating-webhook binding authorized for group). -func mkToken(t *testing.T, group string, mutate func(k8s map[string]any)) string { +// mkToken mints a token string for the offline fake: a JSON payload carrying the +// allowedAPIGroup values fakeAuthenticator returns. In the v3 seam the +// signature / iss / aud / exp checks and the webhook-binding checks live inside +// the real authenticator, so this offline fake models only the allowedAPIGroup +// list the Verifier matches against the review's group. +func mkToken(t *testing.T, group string) string { t.Helper() - k8s := map[string]any{ - "validatingWebhookConfiguration": map[string]string{ - "name": "my-webhook", - "uid": "webhook-uid", - }, - "attestationClaims": map[string][]string{ - allowedAPIGroupClaimKey: {group}, - }, - } - if mutate != nil { - mutate(k8s) - } - claims := map[string]any{ - "iss": testIssuer, - "sub": "system:serviceaccount:kube-system:webhook", - "aud": []string{testAudience}, - "kubernetes.io": k8s, - } - payload, err := json.Marshal(claims) + payload, err := json.Marshal(map[string]any{ + "allowedAPIGroups": []string{group}, + }) if err != nil { - t.Fatalf("marshal claims: %v", err) + t.Fatalf("marshal token payload: %v", err) } return string(payload) } @@ -122,23 +103,17 @@ func reqForGroup(group string) admission.Request { } } -func TestAuthenticate(t *testing.T) { - validApps := mkToken(t, "apps", nil) - wildcard := mkToken(t, "*", nil) - mutatingApps := mkToken(t, "apps", func(k8s map[string]any) { - // Exactly one bound object, but mutating instead of validating: the - // exactly-one rule treats the two symmetrically, so this must be accepted. - delete(k8s, "validatingWebhookConfiguration") - k8s["mutatingWebhookConfiguration"] = map[string]string{"name": "mwc", "uid": "mwc-uid"} - }) - bothBound := mkToken(t, "apps", func(k8s map[string]any) { - // Two bound objects violates the exactly-one rule. - k8s["mutatingWebhookConfiguration"] = map[string]string{"name": "mwc", "uid": "mwc-uid"} - }) - noBound := mkToken(t, "apps", func(k8s map[string]any) { - // Zero bound objects violates the exactly-one rule. - delete(k8s, "validatingWebhookConfiguration") - }) +func TestNewAuthenticator(t *testing.T) { + validApps := mkToken(t, "apps") + wildcard := mkToken(t, "*") + + // TODO(kep-6060): rebuild bothBound / noBound / mutatingApps binding-violation + // coverage under the v3 seam (commit 2, post-review). In v3 the + // exactly-one-of-(validating|mutating) webhook-binding checks moved INTO the + // real authenticator; the offline fakeAuthenticator only returns the + // allowedAPIGroup list, so those cases cannot be exercised through this + // Verifier fake without replicating the binding logic. They are dropped here + // to restore compilation and re-added with real v3 semantics in commit 2. tests := []struct { name string @@ -162,13 +137,6 @@ func TestAuthenticate(t *testing.T) { reqGroup: "batch", wantAllowed: true, }, - { - name: "valid token bound to mutating webhook, matching group", - setAuthHdr: true, - token: mutatingApps, - reqGroup: "apps", - wantAllowed: true, - }, { name: "missing token", setAuthHdr: false, @@ -192,7 +160,7 @@ func TestAuthenticate(t *testing.T) { wantAllowed: false, }, { - // Stands in for expired / wrong-audience / bad-signature: in v2 those + // Stands in for expired / wrong-audience / bad-signature: in v3 those // checks live in the authenticator, which here returns an error. name: "authenticator error", setAuthHdr: true, @@ -200,20 +168,6 @@ func TestAuthenticate(t *testing.T) { reqGroup: "apps", wantAllowed: false, }, - { - name: "bound-object violation: both validating and mutating", - setAuthHdr: true, - token: bothBound, - reqGroup: "apps", - wantAllowed: false, - }, - { - name: "bound-object violation: neither validating nor mutating", - setAuthHdr: true, - token: noBound, - reqGroup: "apps", - wantAllowed: false, - }, { name: "allowedAPIGroup mismatch", setAuthHdr: true, @@ -223,7 +177,7 @@ func TestAuthenticate(t *testing.T) { }, } - auth := webhookauth.NewAuthenticator(newVerifier(t)) + auth := admission.NewAuthenticator(newVerifier(t)) for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -252,12 +206,12 @@ func TestAuthenticate(t *testing.T) { } } -// TestNilVerifierFailsClosed ensures a misconfigured authenticator (nil verifier) -// denies rather than panicking or allowing. -func TestNilVerifierFailsClosed(t *testing.T) { - auth := webhookauth.NewAuthenticator(nil) +// TestNewAuthenticatorNilVerifierFailsClosed ensures a misconfigured +// authenticator (nil verifier) denies rather than panicking or allowing. +func TestNewAuthenticatorNilVerifierFailsClosed(t *testing.T) { + auth := admission.NewAuthenticator(nil) httpReq := httptest.NewRequest(http.MethodPost, "/", nil) - httpReq.Header.Set("Authorization", "Bearer "+mkToken(t, "apps", nil)) + httpReq.Header.Set("Authorization", "Bearer "+mkToken(t, "apps")) resp := auth.Authenticate(context.Background(), httpReq, reqForGroup("apps")) if resp.Allowed { @@ -265,11 +219,11 @@ func TestNilVerifierFailsClosed(t *testing.T) { } } -// TestAuthenticatorEndToEnd drives the authenticator through the real -// admission.Webhook.ServeHTTP pipeline (decode -> Authenticate -> Handle), -// proving the hook short-circuits an unauthenticated request before the handler -// and reuses controller-runtime's single AdmissionReview decode. -func TestAuthenticatorEndToEnd(t *testing.T) { +// TestWithAuthenticatorEndToEnd drives a verifier-backed authenticator through +// the real admission.Webhook.ServeHTTP pipeline (decode -> Authenticate -> +// Handle), proving the hook short-circuits an unauthenticated request before the +// handler and reuses controller-runtime's single AdmissionReview decode. +func TestWithAuthenticatorEndToEnd(t *testing.T) { v := newVerifier(t) var reached bool @@ -278,10 +232,9 @@ func TestAuthenticatorEndToEnd(t *testing.T) { return admission.Allowed("") }) - wh := &admission.Webhook{ - Handler: spy, - Authenticator: webhookauth.NewAuthenticator(v), - } + wh := (&admission.Webhook{ + Handler: spy, + }).WithAuthenticator(admission.NewAuthenticator(v)) srv := httptest.NewServer(wh) defer srv.Close() @@ -306,7 +259,7 @@ func TestAuthenticatorEndToEnd(t *testing.T) { } req.Header.Set("Content-Type", "application/json") if withToken { - req.Header.Set("Authorization", "Bearer "+mkToken(t, "apps", nil)) + req.Header.Set("Authorization", "Bearer "+mkToken(t, "apps")) } resp, err := srv.Client().Do(req) if err != nil { @@ -342,3 +295,65 @@ func TestAuthenticatorEndToEnd(t *testing.T) { } }) } + +// TestWithInClusterAuthenticatorErrorPropagation exercises the fail-fast method. +// In a unit-test environment there is no projected service-account token at +// /var/run/secrets/kubernetes.io/serviceaccount/token, so oidc.InCluster fails +// and the method must surface that error (and a nil Webhook). +// +// The happy path performs live OIDC discovery and background JWKS refresh, so it +// is intentionally not tested here — it is covered by the library's own tests and +// by the compile-time examples. We do not stand up a real OIDC server. +func TestWithInClusterAuthenticatorErrorPropagation(t *testing.T) { + wh, err := (&admission.Webhook{}).WithInClusterAuthenticator(context.Background()) + if err == nil { + t.Fatalf("expected an error with no projected service-account token, got nil (wh=%v)", wh) + } + if wh != nil { + t.Fatalf("expected a nil Webhook on error, got %v", wh) + } +} + +// TestWithRemoteAuthenticatorErrorPropagation exercises the explicit +// issuer/audience path. A bogus, unreachable issuer combined with an +// already-expired context makes OIDC discovery fail fast, so the method must +// surface that error (and a nil Webhook) without any network round-trip +// completing. +func TestWithRemoteAuthenticatorErrorPropagation(t *testing.T) { + // Already-cancelled context so discovery fails immediately rather than + // waiting on a real network dial. + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + + wh, err := (&admission.Webhook{}).WithRemoteAuthenticator(ctx, "https://issuer.invalid", testAudience, nil) + if err == nil { + t.Fatalf("expected an error for an unreachable issuer, got nil (wh=%v)", wh) + } + if wh != nil { + t.Fatalf("expected a nil Webhook on error, got %v", wh) + } +} + +// TestWithRemoteAuthenticatorValidatesArgs confirms the empty-issuer/empty-audience +// guards in the underlying oidc.NewRemoteVerifier propagate through the method. +// This path is fully offline (the guards reject before any discovery). +func TestWithRemoteAuthenticatorValidatesArgs(t *testing.T) { + for _, tc := range []struct { + name string + issuer string + audience string + }{ + {name: "empty issuer", issuer: "", audience: testAudience}, + {name: "empty audience", issuer: testIssuer, audience: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + wh, err := (&admission.Webhook{}).WithRemoteAuthenticator(context.Background(), tc.issuer, tc.audience, nil) + if err == nil { + t.Fatalf("expected an error for %s, got nil (wh=%v)", tc.name, wh) + } + if wh != nil { + t.Fatalf("expected a nil Webhook on error, got %v", wh) + } + }) + } +} diff --git a/pkg/webhook/admission/authenticator_test.go b/pkg/webhook/admission/authenticator_test.go index f35da04e73..8ebf805248 100644 --- a/pkg/webhook/admission/authenticator_test.go +++ b/pkg/webhook/admission/authenticator_test.go @@ -68,15 +68,14 @@ var _ = Describe("Admission webhook authenticators", func() { return Allowed("handled") }, } - webhook := &Webhook{ + webhook := (&Webhook{ Handler: handler, - Authenticator: authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { - events = append(events, "authenticator") - Expect(httpReq.Header.Get("Authorization")).To(Equal("Bearer token")) - Expect(req.Resource.Group).To(Equal("apps")) - return Allowed("") - }), - } + }).WithAuthenticator(authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + events = append(events, "authenticator") + Expect(httpReq.Header.Get("Authorization")).To(Equal("Bearer token")) + Expect(req.Resource.Group).To(Equal("apps")) + return Allowed("") + })) req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"resource":{"group":"apps","version":"v1","resource":"deployments"}}}`, gvkJSONv1)) req.Header.Set("Authorization", "Bearer token") @@ -91,13 +90,12 @@ var _ = Describe("Admission webhook authenticators", func() { DescribeTable("short-circuits denied authentication responses with the request AdmissionReview version", func(authResponse Response, expectedCode int32, expectedReason metav1.StatusReason, expectedMessage string) { handler := &fakeHandler{} - webhook := &Webhook{ + webhook := (&Webhook{ Handler: handler, - Authenticator: authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { - Expect(req.Resource.Group).To(Equal("apps")) - return authResponse - }), - } + }).WithAuthenticator(authenticatorFunc(func(ctx context.Context, httpReq *http.Request, req Request) Response { + Expect(req.Resource.Group).To(Equal("apps")) + return authResponse + })) req := newAdmissionReviewHTTPRequest(fmt.Sprintf(`{%s,"request":{"uid":"auth-uid","resource":{"group":"apps","version":"v1","resource":"deployments"}}}`, gvkJSONv1beta1)) respRecorder := httptest.NewRecorder() @@ -131,36 +129,30 @@ var _ = Describe("Admission webhook authenticators", func() { constructors := map[string]func(Authenticator) http.Handler{ "WithValidator": func(authenticator Authenticator) http.Handler { webhook := WithValidator[*corev1.Pod](scheme, podValidator{}) - webhook.Authenticator = authenticator - return webhook + return webhook.WithAuthenticator(authenticator) }, "WithDefaulter": func(authenticator Authenticator) http.Handler { webhook := WithDefaulter[*corev1.Pod](scheme, podDefaulter{}) - webhook.Authenticator = authenticator - return webhook + return webhook.WithAuthenticator(authenticator) }, "WithCustomDefaulter": func(authenticator Authenticator) http.Handler { webhook := WithCustomDefaulter(scheme, &corev1.Pod{}, customPodDefaulter{}) - webhook.Authenticator = authenticator - return webhook + return webhook.WithAuthenticator(authenticator) }, "MultiValidatingHandler": func(authenticator Authenticator) http.Handler { - return &Webhook{ - Handler: MultiValidatingHandler(&fakeHandler{}), - Authenticator: authenticator, - } + return (&Webhook{ + Handler: MultiValidatingHandler(&fakeHandler{}), + }).WithAuthenticator(authenticator) }, "MultiMutatingHandler": func(authenticator Authenticator) http.Handler { - return &Webhook{ - Handler: MultiMutatingHandler(&fakeHandler{}), - Authenticator: authenticator, - } + return (&Webhook{ + Handler: MultiMutatingHandler(&fakeHandler{}), + }).WithAuthenticator(authenticator) }, "StandaloneWebhook": func(authenticator Authenticator) http.Handler { - handler, err := StandaloneWebhook(&Webhook{ - Handler: &fakeHandler{}, - Authenticator: authenticator, - }, StandaloneOptions{}) + handler, err := StandaloneWebhook((&Webhook{ + Handler: &fakeHandler{}, + }).WithAuthenticator(authenticator), StandaloneOptions{}) Expect(err).NotTo(HaveOccurred()) return handler }, diff --git a/pkg/webhook/admission/example_authenticator_test.go b/pkg/webhook/admission/example_authenticator_test.go new file mode 100644 index 0000000000..5355a02fd8 --- /dev/null +++ b/pkg/webhook/admission/example_authenticator_test.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission_test + +import ( + "context" + "net/http" + + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// exampleHandler is a no-op admission handler used only to complete the wiring +// in these compile-time documentation examples. +var exampleHandler = admission.HandlerFunc(func(context.Context, admission.Request) admission.Response { + return admission.Allowed("") +}) + +// ExampleWebhook_WithInClusterAuthenticator shows the zero-config, in-cluster +// wiring: the issuer, audience, and trusted CA are discovered from the pod's +// projected service-account token. The fluent method turns KEP-6060 API-server +// authentication on and fails fast at startup on any discovery error. +// +// This example is compiled but not executed (it has no "// Output:" line): the +// method performs OIDC discovery and background JWKS refresh against the live +// in-cluster issuer, which is unavailable in a unit-test environment. +func ExampleWebhook_WithInClusterAuthenticator() { + // Pass a process-lifetime context (e.g. the manager's) — it governs OIDC + // discovery and the long-lived background JWKS refresh. + ctx := context.Background() + + wh, err := (&admission.Webhook{ + Handler: exampleHandler, + }).WithInClusterAuthenticator(ctx) + if err != nil { + // In a real setup, fail startup; here we simply return. + return + } + _ = wh +} + +// ExampleWebhook_WithRemoteAuthenticator shows the out-of-cluster wiring with an +// explicit issuer and audience. Pass an *http.Client whose transport trusts the +// issuer's serving CA (nil uses the default transport). +// +// This example is compiled but not executed (it has no "// Output:" line): the +// method performs OIDC discovery against the issuer, which is unavailable in a +// unit-test environment. +func ExampleWebhook_WithRemoteAuthenticator() { + // Pass a process-lifetime context — it governs OIDC discovery and the + // long-lived background JWKS refresh. + ctx := context.Background() + + const ( + issuer = "https://issuer.example.com" + audience = "webhook.example.com" + ) + + // A default http.Client is used here for illustration; in a real + // deployment pass a client with a transport that trusts the issuer's + // serving CA. + httpClient := &http.Client{} + + wh, err := (&admission.Webhook{ + Handler: exampleHandler, + }).WithRemoteAuthenticator(ctx, issuer, audience, httpClient) + if err != nil { + // In a real setup, fail startup; here we simply return. + return + } + _ = wh +} + +// ExampleWebhook_WithAuthenticator shows bring-your-own wiring: wrap a +// pre-built k8s.io/webhookauth *verify.Verifier with admission.NewAuthenticator +// and set it via the fluent method. Prefer WithInClusterAuthenticator or +// WithRemoteAuthenticator unless you need full control over verifier +// construction. +func ExampleWebhook_WithAuthenticator() { + // In a real setup, build a *verify.Verifier (e.g. via k8s.io/webhookauth's + // oidc helpers) and wrap it with admission.NewAuthenticator. A nil + // authenticator preserves the default admission webhook behavior. + var custom admission.Authenticator + + wh := (&admission.Webhook{ + Handler: exampleHandler, + }).WithAuthenticator(custom) + _ = wh +} diff --git a/pkg/webhook/admission/http.go b/pkg/webhook/admission/http.go index 3c8ae0552c..14658b7165 100644 --- a/pkg/webhook/admission/http.go +++ b/pkg/webhook/admission/http.go @@ -117,8 +117,8 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { } wh.getLogger(&req).V(5).Info("received request") - if wh.Authenticator != nil { - response := wh.Authenticator.Authenticate(ctx, r, req) + if wh.authenticator != nil { + response := wh.authenticator.Authenticate(ctx, r, req) if !response.Allowed { if err := completeDeniedAuthenticationResponse(&response, req); err != nil { wh.getLogger(&req).Error(err, "unable to encode authentication response") diff --git a/pkg/webhook/admission/webhook.go b/pkg/webhook/admission/webhook.go index 5c816d6a76..286ae5588c 100644 --- a/pkg/webhook/admission/webhook.go +++ b/pkg/webhook/admission/webhook.go @@ -147,9 +147,14 @@ type Webhook struct { // outside the context of requests. LogConstructor func(base logr.Logger, req *Request) logr.Logger - // Authenticator verifies the HTTP caller after the AdmissionReview is decoded - // and before Handler is called. Nil preserves existing behavior. - Authenticator Authenticator + // authenticator verifies the HTTP caller after the AdmissionReview is decoded + // and before Handler is called. Nil preserves existing behavior exactly. + // + // It is set only via the fluent With*Authenticator methods: + // WithInClusterAuthenticator (zero-config, in-cluster KEP-6060 auth), + // WithRemoteAuthenticator (explicit issuer/audience), or WithAuthenticator + // (bring-your-own / testing). + authenticator Authenticator setupLogOnce sync.Once log logr.Logger diff --git a/pkg/webhook/admission/webhookauth/authenticator.go b/pkg/webhook/admission/webhookauth/authenticator.go deleted file mode 100644 index 5c342d3ee8..0000000000 --- a/pkg/webhook/admission/webhookauth/authenticator.go +++ /dev/null @@ -1,88 +0,0 @@ -/* -Copyright 2026 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package webhookauth - -import ( - "context" - "net/http" - - admissionv1 "k8s.io/api/admission/v1" - "k8s.io/webhook-auth/verify" - "k8s.io/webhook-auth/verify/admissionhttp" - - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -// authenticator adapts a k8s.io/webhook-auth *verify.Verifier onto the -// controller-runtime admission.Authenticator seam. -type authenticator struct{ v *verify.Verifier } - -var _ admission.Authenticator = authenticator{} - -// NewAuthenticator returns an admission.Authenticator that enforces KEP-6060 -// API server authentication using the supplied k8s.io/webhook-auth verifier. -// -// The caller owns verifier construction, which keeps controller-runtime free of -// any JOSE/OIDC dependency: build v with -// -// verify.NewVerifier(authenticator) -// -// supplying your own verify.TokenAuthenticator (the single piece a real -// deployment provides): it verifies the token's signature and standard iss/aud/exp -// claims and returns a *verify.VerifiedClaims for the policy layer to finish. -// -// Opt-in / default-off: assign the returned value to Webhook.Authenticator to -// turn verification on; leaving it nil preserves existing behavior exactly. -// -// Zero-config alternative (deliberately not wired here): the library also offers -// k8s.io/webhook-auth/verify/oidc.InCluster, which discovers the cluster issuer -// from the pod's projected service-account token and builds an -// OIDC-discovery/JWKS authenticator with no explicit configuration (option-free; -// for an explicit issuer/audience use oidc.NewRemoteVerifier). We do NOT call it -// from this package because it would pull the go-oidc dependency into -// controller-runtime's module graph. If a zero-config helper is desired, we could -// add a thin optional constructor (for example NewInClusterAuthenticator) in a -// separate, optional package so consumers opt into the extra dependency. Pending -// review with SIG-Auth. -func NewAuthenticator(v *verify.Verifier) admission.Authenticator { - return authenticator{v: v} -} - -// Authenticate verifies the API server's bearer token against the KEP-6060 -// contract and fails closed on any error. It reuses the AdmissionRequest that -// controller-runtime already decoded, so the review is never decoded twice. -// -// The library's failure model is deliberately opaque: every rejection is a single -// generic error. We therefore always deny with a 401 and log only the -// non-sensitive verify.Reason string; callers must not branch on the reason. -func (a authenticator) Authenticate(ctx context.Context, r *http.Request, req admission.Request) admission.Response { - token, ok := admissionhttp.BearerToken(r) - if !ok { - return admission.Unauthenticated("missing or malformed bearer token") - } - if a.v == nil { - return admission.Unauthenticated("verifier is not configured") - } - // Reuse controller-runtime's single decode; never re-read r.Body. - ar := &admissionv1.AdmissionReview{Request: &req.AdmissionRequest} - if err := admissionhttp.VerifyAdmissionReview(ctx, a.v, ar, token); err != nil { - logf.FromContext(ctx).Info("webhook-auth: denied unauthenticated request", "reason", verify.Reason(err)) - return admission.Unauthenticated("unauthenticated") - } - return admission.Allowed("") -} diff --git a/pkg/webhook/admission/webhookauth/doc.go b/pkg/webhook/admission/webhookauth/doc.go deleted file mode 100644 index dd84666e79..0000000000 --- a/pkg/webhook/admission/webhookauth/doc.go +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright 2026 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Package webhookauth is an opt-in controller-runtime adapter for KEP-6060 -// admission webhook authentication. -// -// It consumes the k8s.io/webhook-auth library rather than re-implementing token -// verification: NewAuthenticator wraps a *verify.Verifier as an -// admission.Authenticator that controller-runtime invokes after it has decoded -// the AdmissionReview and before the user handler runs. The API server's bearer -// token is verified entirely offline against the library's contract (signature, -// issuer, audience, exp/nbf/iat, webhook binding, and allowed API group) with no -// TokenReview round-trip. Any verification failure fails closed. -// -// Opt-in / default-off: set the returned value on Webhook.Authenticator to turn -// verification on. Leaving Authenticator nil preserves existing behavior exactly, -// so a webhook that does not adopt verification is unaffected. -// -// This package intentionally adds no crypto/OIDC dependency of its own. The -// caller constructs the verify.Verifier (supplying a verify.TokenAuthenticator) -// and thus decides whether to pull in go-oidc. See NewAuthenticator for the -// zero-config in-cluster verifier the library offers and why it is left to the -// consumer. -// -// KEP-6060's wire format is provisional; the claim contract lives in -// k8s.io/webhook-auth and must be reconciled after upstream API review. -package webhookauth From 24989476201ea32d023fa5d27ff3b587adbba54b Mon Sep 17 00:00:00 2001 From: "Benjamin A. Petersen" Date: Thu, 16 Jul 2026 16:10:37 -0400 Subject: [PATCH 5/6] test(webhook): comprehensive fail-closed coverage for KEP-6060 authenticator Restores the authenticator test coverage deferred from b027da38 (commit 1 left MINIMAL-compile TODO markers). Exercises controller-runtime's OWN adapter fail-closed contract: verify-error deny (which at the CR layer subsumes the old bothBound/noBound/mutatingApps binding violations), missing/malformed/empty/ wrong-scheme bearer token, nil verifier, and the 401 + HTTP-200 deny shape through the ServeHTTP seam so a failurePolicy:Ignore webhook cannot flip a deny into an admit. Also asserts no token material leaks into the deny response body. The v3 exactly-one-of-(validating|mutating) webhook-binding checks moved INTO k8s.io/webhookauth's authenticator; controller-runtime's offline verifier fake cannot drive that internal binding logic, so those checks are covered by the library's own test suite (documented via an in-file NOTE rather than a hollow CR test). Signed-off-by: Benjamin A. Petersen --- .../admission/authenticator_ext_test.go | 95 +++++++++++++++---- 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/pkg/webhook/admission/authenticator_ext_test.go b/pkg/webhook/admission/authenticator_ext_test.go index ed580fe318..e6646481ae 100644 --- a/pkg/webhook/admission/authenticator_ext_test.go +++ b/pkg/webhook/admission/authenticator_ext_test.go @@ -21,6 +21,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -107,13 +108,17 @@ func TestNewAuthenticator(t *testing.T) { validApps := mkToken(t, "apps") wildcard := mkToken(t, "*") - // TODO(kep-6060): rebuild bothBound / noBound / mutatingApps binding-violation - // coverage under the v3 seam (commit 2, post-review). In v3 the - // exactly-one-of-(validating|mutating) webhook-binding checks moved INTO the - // real authenticator; the offline fakeAuthenticator only returns the - // allowedAPIGroup list, so those cases cannot be exercised through this - // Verifier fake without replicating the binding logic. They are dropped here - // to restore compilation and re-added with real v3 semantics in commit 2. + // NOTE: the old bothBound / noBound / mutatingApps binding-violation cases are + // intentionally NOT rebuilt as controller-runtime tests. In the v3 seam the + // exactly-one-of-(validating|mutating) webhook-binding checks live INSIDE the + // library authenticator, not in CR's adapter. The offline fakeAuthenticator only + // returns the allowedAPIGroup list, so it cannot drive that binding logic without + // re-implementing it — a hollow test that would only assert our own fake. At CR's + // layer every such violation collapses to the same observable outcome: the library + // returns an error and the adapter fails closed. That single fail-closed contract + // is exercised by the "authenticator error" case below and, end-to-end through the + // ServeHTTP seam, by TestWithAuthenticatorEndToEnd's "verify error" subtest. The + // binding rules themselves are owned and tested by k8s.io/webhookauth. tests := []struct { name string @@ -198,6 +203,9 @@ func TestNewAuthenticator(t *testing.T) { if resp.Result == nil || resp.Result.Code != http.StatusUnauthorized { t.Fatalf("denied response = %#v, want 401", resp.Result) } + if resp.Result.Reason != metav1.StatusReasonUnauthorized { + t.Fatalf("denied reason = %q, want %q", resp.Result.Reason, metav1.StatusReasonUnauthorized) + } if tc.token != "" && strings.Contains(resp.Result.Message, tc.token) { t.Fatalf("response leaked token material: %q", resp.Result.Message) } @@ -207,22 +215,34 @@ func TestNewAuthenticator(t *testing.T) { } // TestNewAuthenticatorNilVerifierFailsClosed ensures a misconfigured -// authenticator (nil verifier) denies rather than panicking or allowing. +// authenticator (nil verifier) denies rather than panicking or allowing, and +// that the denial is a generic 401 that leaks no token material. func TestNewAuthenticatorNilVerifierFailsClosed(t *testing.T) { auth := admission.NewAuthenticator(nil) httpReq := httptest.NewRequest(http.MethodPost, "/", nil) - httpReq.Header.Set("Authorization", "Bearer "+mkToken(t, "apps")) + token := mkToken(t, "apps") + httpReq.Header.Set("Authorization", "Bearer "+token) resp := auth.Authenticate(context.Background(), httpReq, reqForGroup("apps")) if resp.Allowed { t.Fatal("nil verifier must deny") } + if resp.Result == nil || resp.Result.Code != http.StatusUnauthorized { + t.Fatalf("nil-verifier denial = %#v, want 401 Unauthenticated", resp.Result) + } + if strings.Contains(resp.Result.Message, token) { + t.Fatalf("nil-verifier denial leaked token material: %q", resp.Result.Message) + } } // TestWithAuthenticatorEndToEnd drives a verifier-backed authenticator through // the real admission.Webhook.ServeHTTP pipeline (decode -> Authenticate -> // Handle), proving the hook short-circuits an unauthenticated request before the // handler and reuses controller-runtime's single AdmissionReview decode. +// +// It also pins the load-bearing transport property: a denial is delivered as an +// HTTP 200 carrying Allowed:false (never a non-2xx), so a webhook configured with +// failurePolicy: Ignore cannot flip a deny into an admit. func TestWithAuthenticatorEndToEnd(t *testing.T) { v := newVerifier(t) @@ -251,31 +271,40 @@ func TestWithAuthenticatorEndToEnd(t *testing.T) { t.Fatalf("marshal review: %v", err) } - post := func(withToken bool) admissionv1.AdmissionReview { + // post sends the review with the given Authorization header (empty = none) and + // returns the decoded AdmissionReview, the HTTP transport status, and the raw + // response body (so callers can assert no token material leaked). + post := func(authHeader string) (out admissionv1.AdmissionReview, status int, raw []byte) { t.Helper() req, err := http.NewRequest(http.MethodPost, srv.URL, bytes.NewReader(body)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", "application/json") - if withToken { - req.Header.Set("Authorization", "Bearer "+mkToken(t, "apps")) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) } resp, err := srv.Client().Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() - var out admissionv1.AdmissionReview - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + raw, err = io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("decode response: %v", err) } - return out + return out, resp.StatusCode, raw } - t.Run("valid token reaches handler and is allowed", func(t *testing.T) { + t.Run("valid token reaches handler and is allowed (HTTP 200)", func(t *testing.T) { reached = false - out := post(true) + out, status, _ := post("Bearer " + mkToken(t, "apps")) + if status != http.StatusOK { + t.Fatalf("transport status = %d, want 200", status) + } if out.Response == nil || !out.Response.Allowed { t.Fatalf("expected Allowed=true, got %+v", out.Response) } @@ -284,15 +313,43 @@ func TestWithAuthenticatorEndToEnd(t *testing.T) { } }) - t.Run("missing token is denied before handler", func(t *testing.T) { + t.Run("missing token is denied before handler (HTTP 200)", func(t *testing.T) { + reached = false + out, status, _ := post("") + if status != http.StatusOK { + t.Fatalf("transport status = %d, want 200 — a non-2xx would let failurePolicy:Ignore admit", status) + } + if out.Response == nil || out.Response.Allowed { + t.Fatalf("expected Allowed=false, got %+v", out.Response) + } + if reached { + t.Fatal("expected downstream handler NOT to be reached") + } + }) + + t.Run("verify error is denied before handler (HTTP 200, 401, no leak)", func(t *testing.T) { + // authFailureToken makes fakeAuthenticator return an error, so + // VerifyAdmissionRequest fails and the adapter fails closed. At CR's layer + // this single case SUBSUMES the old bothBound / noBound / mutatingApps + // binding violations — in v3 the library reports every one of them as the + // same generic error, and CR denies identically. reached = false - out := post(false) + out, status, raw := post("Bearer " + authFailureToken) + if status != http.StatusOK { + t.Fatalf("transport status = %d, want 200", status) + } if out.Response == nil || out.Response.Allowed { t.Fatalf("expected Allowed=false, got %+v", out.Response) } if reached { t.Fatal("expected downstream handler NOT to be reached") } + if out.Response.Result == nil || out.Response.Result.Code != http.StatusUnauthorized { + t.Fatalf("denied response = %#v, want 401", out.Response.Result) + } + if strings.Contains(string(raw), authFailureToken) { + t.Fatalf("response body leaked token material: %s", raw) + } }) } From 7968376ecc56acd941146034f6b4a5ab5cee6cf7 Mon Sep 17 00:00:00 2001 From: "Benjamin A. Petersen" Date: Thu, 16 Jul 2026 16:33:47 -0400 Subject: [PATCH 6/6] feat(webhook): add readyz health check for KEP-6060 authenticator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the library verifier's HealthCheck() through Webhook.HealthCheck, a healthz.Checker-compatible method (raw func(*http.Request) error, so the admission package takes no healthz dependency). It is directly assignable to mgr.AddReadyzCheck and safe to register unconditionally: when no verifier-backed authenticator is configured it always reports ready. When an in-cluster/remote authenticator is configured, readiness delegates to the library verifier and reports not-ready until the audience is bound. This prevents a pod that can never derive its audience from silently denying all traffic — it fails readiness and is restarted instead. Addresses McManus OBS-1 / cr-4 7.1. Signed-off-by: Benjamin A. Petersen --- pkg/webhook/admission/authenticator.go | 41 ++++++++++++++++++- pkg/webhook/admission/authenticator_test.go | 45 +++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/pkg/webhook/admission/authenticator.go b/pkg/webhook/admission/authenticator.go index ca8586cc48..593f692896 100644 --- a/pkg/webhook/admission/authenticator.go +++ b/pkg/webhook/admission/authenticator.go @@ -28,11 +28,18 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" ) +// healthChecker is implemented by Authenticators whose readiness can be probed +// (e.g. the verifier-backed authenticator, which is not ready until its audience is bound). +type healthChecker interface{ HealthCheck() error } + // verifierAuthenticator adapts a k8s.io/webhookauth *verify.Verifier onto the // admission.Authenticator seam. type verifierAuthenticator struct{ v *verify.Verifier } -var _ Authenticator = verifierAuthenticator{} +var ( + _ Authenticator = verifierAuthenticator{} + _ healthChecker = verifierAuthenticator{} +) // NewAuthenticator wraps a k8s.io/webhookauth *verify.Verifier as an // Authenticator that enforces KEP-6060 API server authentication. @@ -73,6 +80,38 @@ func (a verifierAuthenticator) Authenticate(ctx context.Context, r *http.Request return Allowed("") } +// HealthCheck reports whether the backing verifier is ready to verify API server +// tokens, delegating to the library verifier's own HealthCheck. A nil verifier +// gates nothing and always reports ready. +func (a verifierAuthenticator) HealthCheck() error { + if a.v == nil { + return nil // no verifier configured → nothing to gate readiness on + } + return a.v.HealthCheck() +} + +// HealthCheck reports whether the webhook's KEP-6060 authenticator (if any) is +// ready to verify API server tokens. It has the signature of healthz.Checker, so +// it can be registered directly: +// +// mgr.AddReadyzCheck("webhook-auth", wh.HealthCheck) +// +// This is a readiness check: register it with AddReadyzCheck, not AddHealthzCheck. +// An authenticator that cannot yet bind its audience should stay not-ready (so the +// pod is not sent traffic) rather than fail liveness, which would crash-loop the pod. +// +// When no verifier-backed authenticator is configured it always reports ready, so +// it is safe to register unconditionally. When an in-cluster/remote authenticator +// is configured, it reports not-ready until the verifier has bound its audience — +// so a pod that can never derive its audience fails readiness and is restarted, +// instead of silently denying all traffic. +func (wh *Webhook) HealthCheck(_ *http.Request) error { + if hc, ok := wh.authenticator.(healthChecker); ok { + return hc.HealthCheck() + } + return nil +} + // WithAuthenticator sets a custom Authenticator (bring-your-own / testing) and // returns the Webhook for fluent chaining. A nil Authenticator preserves the // default admission webhook behavior. diff --git a/pkg/webhook/admission/authenticator_test.go b/pkg/webhook/admission/authenticator_test.go index 8ebf805248..097f2ba1a0 100644 --- a/pkg/webhook/admission/authenticator_test.go +++ b/pkg/webhook/admission/authenticator_test.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -180,12 +181,56 @@ var _ = Describe("Admission webhook authenticators", func() { }) }) +var _ = Describe("Webhook.HealthCheck (readyz seam)", func() { + It("reports ready when no authenticator is configured", func() { + wh := &Webhook{Handler: &fakeHandler{}} + Expect(wh.HealthCheck(nil)).To(Succeed()) + }) + + It("reports ready when the authenticator does not implement HealthCheck", func() { + // A plain authenticatorFunc has no HealthCheck method, so registering the + // checker unconditionally must be safe (always ready). + wh := (&Webhook{Handler: &fakeHandler{}}).WithAuthenticator( + authenticatorFunc(func(context.Context, *http.Request, Request) Response { + return Allowed("") + })) + Expect(wh.HealthCheck(nil)).To(Succeed()) + }) + + It("surfaces the not-ready error from a health-aware authenticator", func() { + sentinel := errors.New("audience not bound") + wh := (&Webhook{Handler: &fakeHandler{}}).WithAuthenticator( + healthAwareAuthenticator{healthErr: sentinel}) + Expect(wh.HealthCheck(nil)).To(MatchError(sentinel)) + }) + + It("reports ready when a health-aware authenticator is ready", func() { + wh := (&Webhook{Handler: &fakeHandler{}}).WithAuthenticator( + healthAwareAuthenticator{healthErr: nil}) + Expect(wh.HealthCheck(nil)).To(Succeed()) + }) + + It("reports ready for a verifierAuthenticator with a nil verifier", func() { + Expect(verifierAuthenticator{}.HealthCheck()).To(Succeed()) + }) +}) + type authenticatorFunc func(context.Context, *http.Request, Request) Response func (f authenticatorFunc) Authenticate(ctx context.Context, httpReq *http.Request, req Request) Response { return f(ctx, httpReq, req) } +// healthAwareAuthenticator is a minimal Authenticator that also implements the +// unexported healthChecker seam, returning healthErr from HealthCheck. +type healthAwareAuthenticator struct{ healthErr error } + +func (healthAwareAuthenticator) Authenticate(context.Context, *http.Request, Request) Response { + return Allowed("") +} + +func (a healthAwareAuthenticator) HealthCheck() error { return a.healthErr } + func newAdmissionReviewHTTPRequest(body string) *http.Request { req := httptest.NewRequest(http.MethodPost, "/admission", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json")