k8s.io/apiserver@v0.31.1/plugin/pkg/authenticator/token/webhook/webhook_v1_test.go (about)

     1  /*
     2  Copyright 2016 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package webhook
    18  
    19  import (
    20  	"context"
    21  	"crypto/tls"
    22  	"crypto/x509"
    23  	"encoding/json"
    24  	"fmt"
    25  	"io/ioutil"
    26  	"net/http"
    27  	"net/http/httptest"
    28  	"net/url"
    29  	"os"
    30  	"reflect"
    31  	"testing"
    32  	"time"
    33  
    34  	authenticationv1 "k8s.io/api/authentication/v1"
    35  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    36  	"k8s.io/apimachinery/pkg/util/wait"
    37  	"k8s.io/apiserver/pkg/authentication/authenticator"
    38  	"k8s.io/apiserver/pkg/authentication/token/cache"
    39  	"k8s.io/apiserver/pkg/authentication/user"
    40  	webhookutil "k8s.io/apiserver/pkg/util/webhook"
    41  	v1 "k8s.io/client-go/tools/clientcmd/api/v1"
    42  )
    43  
    44  var testRetryBackoff = wait.Backoff{
    45  	Duration: 5 * time.Millisecond,
    46  	Factor:   1.5,
    47  	Jitter:   0.2,
    48  	Steps:    5,
    49  }
    50  
    51  // V1Service mocks a remote authentication service.
    52  type V1Service interface {
    53  	// Review looks at the TokenReviewSpec and provides an authentication
    54  	// response in the TokenReviewStatus.
    55  	Review(*authenticationv1.TokenReview)
    56  	HTTPStatusCode() int
    57  }
    58  
    59  // NewV1TestServer wraps a V1Service as an httptest.Server.
    60  func NewV1TestServer(s V1Service, cert, key, caCert []byte) (*httptest.Server, error) {
    61  	const webhookPath = "/testserver"
    62  	var tlsConfig *tls.Config
    63  	if cert != nil {
    64  		cert, err := tls.X509KeyPair(cert, key)
    65  		if err != nil {
    66  			return nil, err
    67  		}
    68  		tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
    69  	}
    70  
    71  	if caCert != nil {
    72  		rootCAs := x509.NewCertPool()
    73  		rootCAs.AppendCertsFromPEM(caCert)
    74  		if tlsConfig == nil {
    75  			tlsConfig = &tls.Config{}
    76  		}
    77  		tlsConfig.ClientCAs = rootCAs
    78  		tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
    79  	}
    80  
    81  	serveHTTP := func(w http.ResponseWriter, r *http.Request) {
    82  		if r.Method != "POST" {
    83  			http.Error(w, fmt.Sprintf("unexpected method: %v", r.Method), http.StatusMethodNotAllowed)
    84  			return
    85  		}
    86  		if r.URL.Path != webhookPath {
    87  			http.Error(w, fmt.Sprintf("unexpected path: %v", r.URL.Path), http.StatusNotFound)
    88  			return
    89  		}
    90  
    91  		var review authenticationv1.TokenReview
    92  		bodyData, _ := ioutil.ReadAll(r.Body)
    93  		if err := json.Unmarshal(bodyData, &review); err != nil {
    94  			http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest)
    95  			return
    96  		}
    97  		// ensure we received the serialized tokenreview as expected
    98  		if review.APIVersion != "authentication.k8s.io/v1" {
    99  			http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest)
   100  			return
   101  		}
   102  		// once we have a successful request, always call the review to record that we were called
   103  		s.Review(&review)
   104  		if s.HTTPStatusCode() < 200 || s.HTTPStatusCode() >= 300 {
   105  			http.Error(w, "HTTP Error", s.HTTPStatusCode())
   106  			return
   107  		}
   108  		type userInfo struct {
   109  			Username string              `json:"username"`
   110  			UID      string              `json:"uid"`
   111  			Groups   []string            `json:"groups"`
   112  			Extra    map[string][]string `json:"extra"`
   113  		}
   114  		type status struct {
   115  			Authenticated bool     `json:"authenticated"`
   116  			User          userInfo `json:"user"`
   117  			Audiences     []string `json:"audiences"`
   118  		}
   119  
   120  		var extra map[string][]string
   121  		if review.Status.User.Extra != nil {
   122  			extra = map[string][]string{}
   123  			for k, v := range review.Status.User.Extra {
   124  				extra[k] = v
   125  			}
   126  		}
   127  
   128  		resp := struct {
   129  			Kind       string `json:"kind"`
   130  			APIVersion string `json:"apiVersion"`
   131  			Status     status `json:"status"`
   132  		}{
   133  			Kind:       "TokenReview",
   134  			APIVersion: authenticationv1.SchemeGroupVersion.String(),
   135  			Status: status{
   136  				review.Status.Authenticated,
   137  				userInfo{
   138  					Username: review.Status.User.Username,
   139  					UID:      review.Status.User.UID,
   140  					Groups:   review.Status.User.Groups,
   141  					Extra:    extra,
   142  				},
   143  				review.Status.Audiences,
   144  			},
   145  		}
   146  		w.Header().Set("Content-Type", "application/json")
   147  		json.NewEncoder(w).Encode(resp)
   148  	}
   149  
   150  	server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP))
   151  	server.TLS = tlsConfig
   152  	server.StartTLS()
   153  
   154  	// Adjust the path to point to our custom path
   155  	serverURL, _ := url.Parse(server.URL)
   156  	serverURL.Path = webhookPath
   157  	server.URL = serverURL.String()
   158  
   159  	return server, nil
   160  }
   161  
   162  // A service that can be set to say yes or no to authentication requests.
   163  type mockV1Service struct {
   164  	allow      bool
   165  	statusCode int
   166  	called     int
   167  }
   168  
   169  func (m *mockV1Service) Review(r *authenticationv1.TokenReview) {
   170  	m.called++
   171  	r.Status.Authenticated = m.allow
   172  	if m.allow {
   173  		r.Status.User.Username = "realHooman@email.com"
   174  	}
   175  }
   176  func (m *mockV1Service) Allow()              { m.allow = true }
   177  func (m *mockV1Service) Deny()               { m.allow = false }
   178  func (m *mockV1Service) HTTPStatusCode() int { return m.statusCode }
   179  
   180  // newV1TokenAuthenticator creates a temporary kubeconfig file from the provided
   181  // arguments and attempts to load a new WebhookTokenAuthenticator from it.
   182  func newV1TokenAuthenticator(serverURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration, implicitAuds authenticator.Audiences, metrics AuthenticatorMetrics) (authenticator.Token, error) {
   183  	tempfile, err := ioutil.TempFile("", "")
   184  	if err != nil {
   185  		return nil, err
   186  	}
   187  	p := tempfile.Name()
   188  	defer os.Remove(p)
   189  	config := v1.Config{
   190  		Clusters: []v1.NamedCluster{
   191  			{
   192  				Cluster: v1.Cluster{Server: serverURL, CertificateAuthorityData: ca},
   193  			},
   194  		},
   195  		AuthInfos: []v1.NamedAuthInfo{
   196  			{
   197  				AuthInfo: v1.AuthInfo{ClientCertificateData: clientCert, ClientKeyData: clientKey},
   198  			},
   199  		},
   200  	}
   201  	if err := json.NewEncoder(tempfile).Encode(config); err != nil {
   202  		return nil, err
   203  	}
   204  
   205  	clientConfig, err := webhookutil.LoadKubeconfig(p, nil)
   206  	if err != nil {
   207  		return nil, err
   208  	}
   209  
   210  	c, err := tokenReviewInterfaceFromConfig(clientConfig, "v1", testRetryBackoff)
   211  	if err != nil {
   212  		return nil, err
   213  	}
   214  
   215  	authn, err := newWithBackoff(c, testRetryBackoff, implicitAuds, 10*time.Second, metrics)
   216  	if err != nil {
   217  		return nil, err
   218  	}
   219  
   220  	return cache.New(authn, false, cacheTime, cacheTime), nil
   221  }
   222  
   223  func TestV1TLSConfig(t *testing.T) {
   224  	tests := []struct {
   225  		test                            string
   226  		clientCert, clientKey, clientCA []byte
   227  		serverCert, serverKey, serverCA []byte
   228  		wantErr                         bool
   229  	}{
   230  		{
   231  			test:       "TLS setup between client and server",
   232  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   233  			serverCert: serverCert, serverKey: serverKey, serverCA: caCert,
   234  		},
   235  		{
   236  			test:       "Server does not require client auth",
   237  			clientCA:   caCert,
   238  			serverCert: serverCert, serverKey: serverKey,
   239  		},
   240  		{
   241  			test:       "Server does not require client auth, client provides it",
   242  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   243  			serverCert: serverCert, serverKey: serverKey,
   244  		},
   245  		{
   246  			test:       "Client does not trust server",
   247  			clientCert: clientCert, clientKey: clientKey,
   248  			serverCert: serverCert, serverKey: serverKey,
   249  			wantErr: true,
   250  		},
   251  		{
   252  			test:       "Server does not trust client",
   253  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   254  			serverCert: serverCert, serverKey: serverKey, serverCA: badCACert,
   255  			wantErr: true,
   256  		},
   257  		{
   258  			// Plugin does not support insecure configurations.
   259  			test:    "Server is using insecure connection",
   260  			wantErr: true,
   261  		},
   262  	}
   263  	for _, tt := range tests {
   264  		// Use a closure so defer statements trigger between loop iterations.
   265  		func() {
   266  			service := new(mockV1Service)
   267  			service.statusCode = 200
   268  
   269  			server, err := NewV1TestServer(service, tt.serverCert, tt.serverKey, tt.serverCA)
   270  			if err != nil {
   271  				t.Errorf("%s: failed to create server: %v", tt.test, err)
   272  				return
   273  			}
   274  			defer server.Close()
   275  
   276  			wh, err := newV1TokenAuthenticator(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0, nil, noopAuthenticatorMetrics())
   277  			if err != nil {
   278  				t.Errorf("%s: failed to create client: %v", tt.test, err)
   279  				return
   280  			}
   281  
   282  			// Allow all and see if we get an error.
   283  			service.Allow()
   284  			_, authenticated, err := wh.AuthenticateToken(context.Background(), "t0k3n")
   285  			if tt.wantErr {
   286  				if err == nil {
   287  					t.Errorf("expected error making authorization request: %v", err)
   288  				}
   289  				return
   290  			}
   291  			if !authenticated {
   292  				t.Errorf("%s: failed to authenticate token", tt.test)
   293  				return
   294  			}
   295  
   296  			service.Deny()
   297  			_, authenticated, err = wh.AuthenticateToken(context.Background(), "t0k3n")
   298  			if err != nil {
   299  				t.Errorf("%s: unexpectedly failed AuthenticateToken", tt.test)
   300  			}
   301  			if authenticated {
   302  				t.Errorf("%s: incorrectly authenticated token", tt.test)
   303  			}
   304  		}()
   305  	}
   306  }
   307  
   308  // recorderV1Service records all token review requests, and responds with the
   309  // provided TokenReviewStatus.
   310  type recorderV1Service struct {
   311  	lastRequest authenticationv1.TokenReview
   312  	response    authenticationv1.TokenReviewStatus
   313  }
   314  
   315  func (rec *recorderV1Service) Review(r *authenticationv1.TokenReview) {
   316  	rec.lastRequest = *r
   317  	r.Status = rec.response
   318  }
   319  
   320  func (rec *recorderV1Service) HTTPStatusCode() int { return 200 }
   321  
   322  func TestV1WebhookTokenAuthenticator(t *testing.T) {
   323  	serv := &recorderV1Service{}
   324  
   325  	s, err := NewV1TestServer(serv, serverCert, serverKey, caCert)
   326  	if err != nil {
   327  		t.Fatal(err)
   328  	}
   329  	defer s.Close()
   330  
   331  	expTypeMeta := metav1.TypeMeta{
   332  		APIVersion: "authentication.k8s.io/v1",
   333  		Kind:       "TokenReview",
   334  	}
   335  
   336  	tests := []struct {
   337  		description           string
   338  		implicitAuds, reqAuds authenticator.Audiences
   339  		serverResponse        authenticationv1.TokenReviewStatus
   340  		expectedAuthenticated bool
   341  		expectedUser          *user.DefaultInfo
   342  		expectedAuds          authenticator.Audiences
   343  	}{
   344  		{
   345  			description: "successful response should pass through all user info.",
   346  			serverResponse: authenticationv1.TokenReviewStatus{
   347  				Authenticated: true,
   348  				User: authenticationv1.UserInfo{
   349  					Username: "somebody",
   350  				},
   351  			},
   352  			expectedAuthenticated: true,
   353  			expectedUser: &user.DefaultInfo{
   354  				Name: "somebody",
   355  			},
   356  		},
   357  		{
   358  			description: "successful response should pass through all user info.",
   359  			serverResponse: authenticationv1.TokenReviewStatus{
   360  				Authenticated: true,
   361  				User: authenticationv1.UserInfo{
   362  					Username: "person@place.com",
   363  					UID:      "abcd-1234",
   364  					Groups:   []string{"stuff-dev", "main-eng"},
   365  					Extra:    map[string]authenticationv1.ExtraValue{"foo": {"bar", "baz"}},
   366  				},
   367  			},
   368  			expectedAuthenticated: true,
   369  			expectedUser: &user.DefaultInfo{
   370  				Name:   "person@place.com",
   371  				UID:    "abcd-1234",
   372  				Groups: []string{"stuff-dev", "main-eng"},
   373  				Extra:  map[string][]string{"foo": {"bar", "baz"}},
   374  			},
   375  		},
   376  		{
   377  			description: "unauthenticated shouldn't even include extra provided info.",
   378  			serverResponse: authenticationv1.TokenReviewStatus{
   379  				Authenticated: false,
   380  				User: authenticationv1.UserInfo{
   381  					Username: "garbage",
   382  					UID:      "abcd-1234",
   383  					Groups:   []string{"not-actually-used"},
   384  				},
   385  			},
   386  			expectedAuthenticated: false,
   387  			expectedUser:          nil,
   388  		},
   389  		{
   390  			description: "unauthenticated shouldn't even include extra provided info.",
   391  			serverResponse: authenticationv1.TokenReviewStatus{
   392  				Authenticated: false,
   393  			},
   394  			expectedAuthenticated: false,
   395  			expectedUser:          nil,
   396  		},
   397  		{
   398  			description:  "good audience",
   399  			implicitAuds: apiAuds,
   400  			reqAuds:      apiAuds,
   401  			serverResponse: authenticationv1.TokenReviewStatus{
   402  				Authenticated: true,
   403  				User: authenticationv1.UserInfo{
   404  					Username: "somebody",
   405  				},
   406  			},
   407  			expectedAuthenticated: true,
   408  			expectedUser: &user.DefaultInfo{
   409  				Name: "somebody",
   410  			},
   411  			expectedAuds: apiAuds,
   412  		},
   413  		{
   414  			description:  "good audience",
   415  			implicitAuds: append(apiAuds, "other"),
   416  			reqAuds:      apiAuds,
   417  			serverResponse: authenticationv1.TokenReviewStatus{
   418  				Authenticated: true,
   419  				User: authenticationv1.UserInfo{
   420  					Username: "somebody",
   421  				},
   422  			},
   423  			expectedAuthenticated: true,
   424  			expectedUser: &user.DefaultInfo{
   425  				Name: "somebody",
   426  			},
   427  			expectedAuds: apiAuds,
   428  		},
   429  		{
   430  			description:  "bad audiences",
   431  			implicitAuds: apiAuds,
   432  			reqAuds:      authenticator.Audiences{"other"},
   433  			serverResponse: authenticationv1.TokenReviewStatus{
   434  				Authenticated: false,
   435  			},
   436  			expectedAuthenticated: false,
   437  		},
   438  		{
   439  			description:  "bad audiences",
   440  			implicitAuds: apiAuds,
   441  			reqAuds:      authenticator.Audiences{"other"},
   442  			// webhook authenticator hasn't been upgraded to support audience.
   443  			serverResponse: authenticationv1.TokenReviewStatus{
   444  				Authenticated: true,
   445  				User: authenticationv1.UserInfo{
   446  					Username: "somebody",
   447  				},
   448  			},
   449  			expectedAuthenticated: false,
   450  		},
   451  		{
   452  			description:  "audience aware backend",
   453  			implicitAuds: apiAuds,
   454  			reqAuds:      apiAuds,
   455  			serverResponse: authenticationv1.TokenReviewStatus{
   456  				Authenticated: true,
   457  				User: authenticationv1.UserInfo{
   458  					Username: "somebody",
   459  				},
   460  				Audiences: []string(apiAuds),
   461  			},
   462  			expectedAuthenticated: true,
   463  			expectedUser: &user.DefaultInfo{
   464  				Name: "somebody",
   465  			},
   466  			expectedAuds: apiAuds,
   467  		},
   468  		{
   469  			description: "audience aware backend",
   470  			serverResponse: authenticationv1.TokenReviewStatus{
   471  				Authenticated: true,
   472  				User: authenticationv1.UserInfo{
   473  					Username: "somebody",
   474  				},
   475  				Audiences: []string(apiAuds),
   476  			},
   477  			expectedAuthenticated: true,
   478  			expectedUser: &user.DefaultInfo{
   479  				Name: "somebody",
   480  			},
   481  		},
   482  		{
   483  			description:  "audience aware backend",
   484  			implicitAuds: apiAuds,
   485  			reqAuds:      apiAuds,
   486  			serverResponse: authenticationv1.TokenReviewStatus{
   487  				Authenticated: true,
   488  				User: authenticationv1.UserInfo{
   489  					Username: "somebody",
   490  				},
   491  				Audiences: []string{"other"},
   492  			},
   493  			expectedAuthenticated: false,
   494  		},
   495  	}
   496  	token := "my-s3cr3t-t0ken" // Fake token for testing.
   497  	for _, tt := range tests {
   498  		t.Run(tt.description, func(t *testing.T) {
   499  			wh, err := newV1TokenAuthenticator(s.URL, clientCert, clientKey, caCert, 0, tt.implicitAuds, noopAuthenticatorMetrics())
   500  			if err != nil {
   501  				t.Fatal(err)
   502  			}
   503  
   504  			ctx := context.Background()
   505  			if tt.reqAuds != nil {
   506  				ctx = authenticator.WithAudiences(ctx, tt.reqAuds)
   507  			}
   508  
   509  			serv.response = tt.serverResponse
   510  			resp, authenticated, err := wh.AuthenticateToken(ctx, token)
   511  			if err != nil {
   512  				t.Fatalf("authentication failed: %v", err)
   513  			}
   514  			if serv.lastRequest.Spec.Token != token {
   515  				t.Errorf("Server did not see correct token. Got %q, expected %q.",
   516  					serv.lastRequest.Spec.Token, token)
   517  			}
   518  			if !reflect.DeepEqual(serv.lastRequest.TypeMeta, expTypeMeta) {
   519  				t.Errorf("Server did not see correct TypeMeta. Got %v, expected %v",
   520  					serv.lastRequest.TypeMeta, expTypeMeta)
   521  			}
   522  			if authenticated != tt.expectedAuthenticated {
   523  				t.Errorf("Plugin returned incorrect authentication response. Got %t, expected %t.",
   524  					authenticated, tt.expectedAuthenticated)
   525  			}
   526  			if resp != nil && tt.expectedUser != nil && !reflect.DeepEqual(resp.User, tt.expectedUser) {
   527  				t.Errorf("Plugin returned incorrect user. Got %#v, expected %#v",
   528  					resp.User, tt.expectedUser)
   529  			}
   530  			if resp != nil && tt.expectedAuds != nil && !reflect.DeepEqual(resp.Audiences, tt.expectedAuds) {
   531  				t.Errorf("Plugin returned incorrect audiences. Got %#v, expected %#v",
   532  					resp.Audiences, tt.expectedAuds)
   533  			}
   534  		})
   535  	}
   536  }
   537  
   538  type authenticationV1UserInfo authenticationv1.UserInfo
   539  
   540  func (a *authenticationV1UserInfo) GetName() string     { return a.Username }
   541  func (a *authenticationV1UserInfo) GetUID() string      { return a.UID }
   542  func (a *authenticationV1UserInfo) GetGroups() []string { return a.Groups }
   543  
   544  func (a *authenticationV1UserInfo) GetExtra() map[string][]string {
   545  	if a.Extra == nil {
   546  		return nil
   547  	}
   548  	ret := map[string][]string{}
   549  	for k, v := range a.Extra {
   550  		ret[k] = []string(v)
   551  	}
   552  
   553  	return ret
   554  }
   555  
   556  // Ensure authenticationv1.UserInfo contains the fields necessary to implement the
   557  // user.Info interface.
   558  var _ user.Info = (*authenticationV1UserInfo)(nil)
   559  
   560  // TestWebhookCache verifies that error responses from the server are not
   561  // cached, but successful responses are. It also ensures that the webhook
   562  // call is retried on 429 and 500+ errors
   563  func TestV1WebhookCacheAndRetry(t *testing.T) {
   564  	serv := new(mockV1Service)
   565  	s, err := NewV1TestServer(serv, serverCert, serverKey, caCert)
   566  	if err != nil {
   567  		t.Fatal(err)
   568  	}
   569  	defer s.Close()
   570  
   571  	// Create an authenticator that caches successful responses "forever" (100 days).
   572  	wh, err := newV1TokenAuthenticator(s.URL, clientCert, clientKey, caCert, 2400*time.Hour, nil, noopAuthenticatorMetrics())
   573  	if err != nil {
   574  		t.Fatal(err)
   575  	}
   576  
   577  	testcases := []struct {
   578  		description string
   579  
   580  		token string
   581  		allow bool
   582  		code  int
   583  
   584  		expectError bool
   585  		expectOk    bool
   586  		expectCalls int
   587  	}{
   588  		{
   589  			description: "t0k3n, 500 error, retries and fails",
   590  
   591  			token: "t0k3n",
   592  			allow: false,
   593  			code:  500,
   594  
   595  			expectError: true,
   596  			expectOk:    false,
   597  			expectCalls: 5,
   598  		},
   599  		{
   600  			description: "t0k3n, 404 error, fails (but no retry)",
   601  
   602  			token: "t0k3n",
   603  			allow: false,
   604  			code:  404,
   605  
   606  			expectError: true,
   607  			expectOk:    false,
   608  			expectCalls: 1,
   609  		},
   610  		{
   611  			description: "t0k3n, 200 response, allowed, succeeds with a single call",
   612  
   613  			token: "t0k3n",
   614  			allow: true,
   615  			code:  200,
   616  
   617  			expectError: false,
   618  			expectOk:    true,
   619  			expectCalls: 1,
   620  		},
   621  		{
   622  			description: "t0k3n, 500 response, disallowed, but never called because previous 200 response was cached",
   623  
   624  			token: "t0k3n",
   625  			allow: false,
   626  			code:  500,
   627  
   628  			expectError: false,
   629  			expectOk:    true,
   630  			expectCalls: 0,
   631  		},
   632  
   633  		{
   634  			description: "an0th3r_t0k3n, 500 response, disallowed, should be called again with retries",
   635  
   636  			token: "an0th3r_t0k3n",
   637  			allow: false,
   638  			code:  500,
   639  
   640  			expectError: true,
   641  			expectOk:    false,
   642  			expectCalls: 5,
   643  		},
   644  		{
   645  			description: "an0th3r_t0k3n, 429 response, disallowed, should be called again with retries",
   646  
   647  			token: "an0th3r_t0k3n",
   648  			allow: false,
   649  			code:  429,
   650  
   651  			expectError: true,
   652  			expectOk:    false,
   653  			expectCalls: 5,
   654  		},
   655  		{
   656  			description: "an0th3r_t0k3n, 200 response, allowed, succeeds with a single call",
   657  
   658  			token: "an0th3r_t0k3n",
   659  			allow: true,
   660  			code:  200,
   661  
   662  			expectError: false,
   663  			expectOk:    true,
   664  			expectCalls: 1,
   665  		},
   666  		{
   667  			description: "an0th3r_t0k3n, 500 response, disallowed, but never called because previous 200 response was cached",
   668  
   669  			token: "an0th3r_t0k3n",
   670  			allow: false,
   671  			code:  500,
   672  
   673  			expectError: false,
   674  			expectOk:    true,
   675  			expectCalls: 0,
   676  		},
   677  	}
   678  
   679  	for _, testcase := range testcases {
   680  		t.Run(testcase.description, func(t *testing.T) {
   681  			serv.allow = testcase.allow
   682  			serv.statusCode = testcase.code
   683  			serv.called = 0
   684  
   685  			_, ok, err := wh.AuthenticateToken(context.Background(), testcase.token)
   686  			hasError := err != nil
   687  			if hasError != testcase.expectError {
   688  				t.Errorf("Webhook returned HTTP %d, expected error=%v, but got error %v", testcase.code, testcase.expectError, err)
   689  			}
   690  			if serv.called != testcase.expectCalls {
   691  				t.Errorf("Expected %d calls, got %d", testcase.expectCalls, serv.called)
   692  			}
   693  			if ok != testcase.expectOk {
   694  				t.Errorf("Expected ok=%v, got %v", testcase.expectOk, ok)
   695  			}
   696  		})
   697  	}
   698  }
   699  
   700  func noopAuthenticatorMetrics() AuthenticatorMetrics {
   701  	return AuthenticatorMetrics{
   702  		RecordRequestTotal:   noopMetrics{}.RequestTotal,
   703  		RecordRequestLatency: noopMetrics{}.RequestLatency,
   704  	}
   705  }