// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package jwtcachefiller

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/rsa"
	"crypto/tls"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/require"
	"gopkg.in/square/go-jose.v2"
	"gopkg.in/square/go-jose.v2/jwt"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/apiserver/pkg/authentication/authenticator"
	"k8s.io/apiserver/pkg/authentication/user"

	auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
	pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake"
	pinnipedinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions"
	"go.pinniped.dev/internal/controller/authenticator/authncache"
	"go.pinniped.dev/internal/controllerlib"
	"go.pinniped.dev/internal/crypto/ptls"
	"go.pinniped.dev/internal/mocks/mocktokenauthenticatorcloser"
	"go.pinniped.dev/internal/testutil"
	"go.pinniped.dev/internal/testutil/testlogger"
	"go.pinniped.dev/internal/testutil/tlsserver"
)

func TestController(t *testing.T) {
	t.Parallel()

	const (
		goodECSigningKeyID  = "some-ec-key-id"
		goodRSASigningKeyID = "some-rsa-key-id"
		goodAudience        = "some-audience"
	)

	goodECSigningKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	goodECSigningAlgo := jose.ES256
	require.NoError(t, err)

	goodRSASigningKey, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)
	goodRSASigningAlgo := jose.RS256

	customGroupsClaim := "my-custom-groups-claim"
	distributedGroups := []string{"some-distributed-group-1", "some-distributed-group-2"}

	mux := http.NewServeMux()
	server := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tlsserver.AssertTLS(t, r, ptls.Default)
		mux.ServeHTTP(w, r)
	}), tlsserver.RecordTLSHello)

	mux.Handle("/.well-known/openid-configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, err := fmt.Fprintf(w, `{"issuer": "%s", "jwks_uri": "%s"}`, server.URL, server.URL+"/jwks.json")
		require.NoError(t, err)
	}))
	mux.Handle("/jwks.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ecJWK := jose.JSONWebKey{
			Key:       goodECSigningKey,
			KeyID:     goodECSigningKeyID,
			Algorithm: string(goodECSigningAlgo),
			Use:       "sig",
		}
		rsaJWK := jose.JSONWebKey{
			Key:       goodRSASigningKey,
			KeyID:     goodRSASigningKeyID,
			Algorithm: string(goodRSASigningAlgo),
			Use:       "sig",
		}
		jwks := jose.JSONWebKeySet{
			Keys: []jose.JSONWebKey{ecJWK.Public(), rsaJWK.Public()},
		}
		require.NoError(t, json.NewEncoder(w).Encode(jwks))
	}))
	// Claims without the subject, to be used distributed claims tests.
	// OIDC 1.0 section 5.6.2:
	// A sub (subject) Claim SHOULD NOT be returned from the Claims Provider unless its value
	// is an identifier for the End-User at the Claims Provider (and not for the OpenID Provider or another party);
	// this typically means that a sub Claim SHOULD NOT be provided.
	claimsWithoutSubject := jwt.Claims{
		Issuer:    server.URL,
		Audience:  []string{goodAudience},
		Expiry:    jwt.NewNumericDate(time.Now().Add(time.Hour)),
		NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
		IssuedAt:  jwt.NewNumericDate(time.Now().Add(-time.Hour)),
	}
	mux.Handle("/claim_source", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Unfortunately we have to set this up pretty early in the test because we can't redeclare
		// mux.Handle. This means that we can't return a different groups claim per test; we have to
		// return both and predecide which groups are returned.
		sig, err := jose.NewSigner(
			jose.SigningKey{Algorithm: goodECSigningAlgo, Key: goodECSigningKey},
			(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", goodECSigningKeyID),
		)
		require.NoError(t, err)

		builder := jwt.Signed(sig).Claims(claimsWithoutSubject)

		builder = builder.Claims(map[string]interface{}{customGroupsClaim: distributedGroups})
		builder = builder.Claims(map[string]interface{}{"groups": distributedGroups})

		distributedClaimsJwt, err := builder.CompactSerialize()
		require.NoError(t, err)

		_, err = w.Write([]byte(distributedClaimsJwt))
		require.NoError(t, err)
	}))
	mux.Handle("/wrong_claim_source", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Unfortunately we have to set this up pretty early in the test because we can't redeclare
		// mux.Handle. This means that we can't return a different groups claim per test; we have to
		// return both and predecide which groups are returned.
		sig, err := jose.NewSigner(
			jose.SigningKey{Algorithm: goodECSigningAlgo, Key: goodECSigningKey},
			(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", goodECSigningKeyID),
		)
		require.NoError(t, err)

		builder := jwt.Signed(sig).Claims(claimsWithoutSubject)

		builder = builder.Claims(map[string]interface{}{"some-other-claim": distributedGroups})

		distributedClaimsJwt, err := builder.CompactSerialize()
		require.NoError(t, err)

		_, err = w.Write([]byte(distributedClaimsJwt))
		require.NoError(t, err)
	}))

	goodIssuer := server.URL

	someJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
		Issuer:   goodIssuer,
		Audience: goodAudience,
		TLS:      tlsSpecFromTLSConfig(server.TLS),
	}
	someJWTAuthenticatorSpecWithUsernameClaim := &auth1alpha1.JWTAuthenticatorSpec{
		Issuer:   goodIssuer,
		Audience: goodAudience,
		TLS:      tlsSpecFromTLSConfig(server.TLS),
		Claims: auth1alpha1.JWTTokenClaims{
			Username: "my-custom-username-claim",
		},
	}
	someJWTAuthenticatorSpecWithGroupsClaim := &auth1alpha1.JWTAuthenticatorSpec{
		Issuer:   goodIssuer,
		Audience: goodAudience,
		TLS:      tlsSpecFromTLSConfig(server.TLS),
		Claims: auth1alpha1.JWTTokenClaims{
			Groups: customGroupsClaim,
		},
	}
	otherJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
		Issuer:   "https://some-other-issuer.com",
		Audience: goodAudience,
		TLS:      &auth1alpha1.TLSSpec{CertificateAuthorityData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURVVENDQWptZ0F3SUJBZ0lWQUpzNStTbVRtaTJXeUI0bGJJRXBXaUs5a1RkUE1BMEdDU3FHU0liM0RRRUIKQ3dVQU1COHhDekFKQmdOVkJBWVRBbFZUTVJBd0RnWURWUVFLREFkUWFYWnZkR0ZzTUI0WERUSXdNRFV3TkRFMgpNamMxT0ZvWERUSTBNRFV3TlRFMk1qYzFPRm93SHpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBb01CMUJwCmRtOTBZV3d3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRERZWmZvWGR4Z2NXTEMKZEJtbHB5a0tBaG9JMlBuUWtsVFNXMno1cGcwaXJjOGFRL1E3MXZzMTRZYStmdWtFTGlvOTRZYWw4R01DdVFrbApMZ3AvUEE5N1VYelhQNDBpK25iNXcwRGpwWWd2dU9KQXJXMno2MFRnWE5NSFh3VHk4ME1SZEhpUFVWZ0VZd0JpCmtkNThzdEFVS1Y1MnBQTU1reTJjNy9BcFhJNmRXR2xjalUvaFBsNmtpRzZ5dEw2REtGYjJQRWV3MmdJM3pHZ2IKOFVVbnA1V05DZDd2WjNVY0ZHNXlsZEd3aGc3cnZ4U1ZLWi9WOEhCMGJmbjlxamlrSVcxWFM4dzdpUUNlQmdQMApYZWhKZmVITlZJaTJtZlczNlVQbWpMdnVKaGpqNDIrdFBQWndvdDkzdWtlcEgvbWpHcFJEVm9wamJyWGlpTUYrCkYxdnlPNGMxQWdNQkFBR2pnWU13Z1lBd0hRWURWUjBPQkJZRUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1IKTUI4R0ExVWRJd1FZTUJhQUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1JNQjBHQTFVZEpRUVdNQlFHQ0NzRwpBUVVGQndNQ0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BNEdBMVVkRHdFQi93UUVBd0lCCkJqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFYbEh4M2tIMDZwY2NDTDlEVE5qTnBCYnlVSytGd2R6T2IwWFYKcmpNaGtxdHVmdEpUUnR5T3hKZ0ZKNXhUR3pCdEtKamcrVU1pczBOV0t0VDBNWThVMU45U2c5SDl0RFpHRHBjVQpxMlVRU0Y4dXRQMVR3dnJIUzIrdzB2MUoxdHgrTEFiU0lmWmJCV0xXQ21EODUzRlVoWlFZekkvYXpFM28vd0p1CmlPUklMdUpNUk5vNlBXY3VLZmRFVkhaS1RTWnk3a25FcHNidGtsN3EwRE91eUFWdG9HVnlkb3VUR0FOdFhXK2YKczNUSTJjKzErZXg3L2RZOEJGQTFzNWFUOG5vZnU3T1RTTzdiS1kzSkRBUHZOeFQzKzVZUXJwNGR1Nmh0YUFMbAppOHNaRkhidmxpd2EzdlhxL3p1Y2JEaHEzQzBhZnAzV2ZwRGxwSlpvLy9QUUFKaTZLQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"},
	}
	missingTLSJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
		Issuer:   goodIssuer,
		Audience: goodAudience,
	}
	invalidTLSJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
		Issuer:   "https://some-other-issuer.com",
		Audience: goodAudience,
		TLS:      &auth1alpha1.TLSSpec{CertificateAuthorityData: "invalid base64-encoded data"},
	}

	tests := []struct {
		name                             string
		cache                            func(*testing.T, *authncache.Cache, bool)
		syncKey                          controllerlib.Key
		jwtAuthenticators                []runtime.Object
		wantClose                        bool
		wantErr                          testutil.RequireErrorStringFunc
		wantLogs                         []string
		wantCacheEntries                 int
		wantUsernameClaim                string
		wantGroupsClaim                  string
		runTestsOnResultingAuthenticator bool
	}{
		{
			name:    "not found",
			syncKey: controllerlib.Key{Name: "test-name"},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="Sync() found that the JWTAuthenticator does not exist yet or was deleted"`,
			},
		},
		{
			name:    "valid jwt authenticator with CA",
			syncKey: controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *someJWTAuthenticatorSpec,
				},
			},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
			},
			wantCacheEntries:                 1,
			runTestsOnResultingAuthenticator: true,
		},
		{
			name:    "valid jwt authenticator with custom username claim",
			syncKey: controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *someJWTAuthenticatorSpecWithUsernameClaim,
				},
			},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
			},
			wantCacheEntries:                 1,
			wantUsernameClaim:                someJWTAuthenticatorSpecWithUsernameClaim.Claims.Username,
			runTestsOnResultingAuthenticator: true,
		},
		{
			name:    "valid jwt authenticator with custom groups claim",
			syncKey: controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *someJWTAuthenticatorSpecWithGroupsClaim,
				},
			},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
			},
			wantCacheEntries:                 1,
			wantGroupsClaim:                  someJWTAuthenticatorSpecWithGroupsClaim.Claims.Groups,
			runTestsOnResultingAuthenticator: true,
		},
		{
			name: "updating jwt authenticator with new fields closes previous instance",
			cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
				cache.Store(
					authncache.Key{
						Name:     "test-name",
						Kind:     "JWTAuthenticator",
						APIGroup: auth1alpha1.SchemeGroupVersion.Group,
					},
					newCacheValue(t, *otherJWTAuthenticatorSpec, wantClose),
				)
			},
			wantClose: true,
			syncKey:   controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *someJWTAuthenticatorSpec,
				},
			},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
			},
			wantCacheEntries:                 1,
			runTestsOnResultingAuthenticator: true,
		},
		{
			name: "updating jwt authenticator with the same value does nothing",
			cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
				cache.Store(
					authncache.Key{
						Name:     "test-name",
						Kind:     "JWTAuthenticator",
						APIGroup: auth1alpha1.SchemeGroupVersion.Group,
					},
					newCacheValue(t, *someJWTAuthenticatorSpec, wantClose),
				)
			},
			wantClose: false,
			syncKey:   controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *someJWTAuthenticatorSpec,
				},
			},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="actual jwt authenticator and desired jwt authenticator are the same" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
			},
			wantCacheEntries:                 1,
			runTestsOnResultingAuthenticator: false, // skip the tests because the authenticator left in the cache is the mock version that was added above
		},
		{
			name: "updating jwt authenticator when cache value is wrong type",
			cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
				cache.Store(
					authncache.Key{
						Name:     "test-name",
						Kind:     "JWTAuthenticator",
						APIGroup: auth1alpha1.SchemeGroupVersion.Group,
					},
					struct{ authenticator.Token }{},
				)
			},
			syncKey: controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *someJWTAuthenticatorSpec,
				},
			},
			wantLogs: []string{
				`jwtcachefiller-controller "level"=0 "msg"="wrong JWT authenticator type in cache" "actualType"="struct { authenticator.Token }"`,
				`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
			},
			wantCacheEntries:                 1,
			runTestsOnResultingAuthenticator: true,
		},
		{
			name:    "valid jwt authenticator without CA",
			syncKey: controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *missingTLSJWTAuthenticatorSpec,
				},
			},
			wantErr: testutil.WantX509UntrustedCertErrorString(`failed to build jwt authenticator: could not initialize provider: Get "`+goodIssuer+`/.well-known/openid-configuration": %s`, "Acme Co"),
		},
		{
			name:    "invalid jwt authenticator CA",
			syncKey: controllerlib.Key{Name: "test-name"},
			jwtAuthenticators: []runtime.Object{
				&auth1alpha1.JWTAuthenticator{
					ObjectMeta: metav1.ObjectMeta{
						Name: "test-name",
					},
					Spec: *invalidTLSJWTAuthenticatorSpec,
				},
			},
			wantErr: testutil.WantExactErrorString("failed to build jwt authenticator: invalid TLS configuration: illegal base64 data at input byte 7"),
		},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			fakeClient := pinnipedfake.NewSimpleClientset(tt.jwtAuthenticators...)
			informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
			cache := authncache.New()
			testLog := testlogger.NewLegacy(t) //nolint:staticcheck  // old test with lots of log statements

			if tt.cache != nil {
				tt.cache(t, cache, tt.wantClose)
			}

			controller := New(cache, informers.Authentication().V1alpha1().JWTAuthenticators(), testLog.Logger)

			ctx, cancel := context.WithCancel(context.Background())
			defer cancel()

			informers.Start(ctx.Done())
			controllerlib.TestRunSynchronously(t, controller)

			syncCtx := controllerlib.Context{Context: ctx, Key: tt.syncKey}

			if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != nil {
				testutil.RequireErrorStringFromErr(t, err, tt.wantErr)
			} else {
				require.NoError(t, err)
			}
			require.Equal(t, tt.wantLogs, testLog.Lines())
			require.Equal(t, tt.wantCacheEntries, len(cache.Keys()))

			if !tt.runTestsOnResultingAuthenticator {
				return // end of test unless we wanted to run tests on the resulting authenticator from the cache
			}

			// We expected the cache to have an entry, so pull that entry from the cache and test it.
			expectedCacheKey := authncache.Key{
				APIGroup: auth1alpha1.GroupName,
				Kind:     "JWTAuthenticator",
				Name:     syncCtx.Key.Name,
			}
			cachedAuthenticator := cache.Get(expectedCacheKey)
			require.NotNil(t, cachedAuthenticator)

			// Schedule it to be closed at the end of the test.
			t.Cleanup(cachedAuthenticator.(*jwtAuthenticator).Close)

			const (
				goodSubject  = "some-subject"
				group0       = "some-group-0"
				group1       = "some-group-1"
				goodUsername = "pinny123"
			)

			if tt.wantUsernameClaim == "" {
				tt.wantUsernameClaim = "username"
			}

			if tt.wantGroupsClaim == "" {
				tt.wantGroupsClaim = "groups"
			}

			for _, test := range testTableForAuthenticateTokenTests(
				t,
				goodRSASigningKey,
				goodRSASigningAlgo,
				goodRSASigningKeyID,
				group0,
				group1,
				goodUsername,
				tt.wantUsernameClaim,
				tt.wantGroupsClaim,
				goodIssuer,
			) {
				test := test
				t.Run(test.name, func(t *testing.T) {
					t.Parallel()

					wellKnownClaims := jwt.Claims{
						Issuer:    goodIssuer,
						Subject:   goodSubject,
						Audience:  []string{goodAudience},
						Expiry:    jwt.NewNumericDate(time.Now().Add(time.Hour)),
						NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
						IssuedAt:  jwt.NewNumericDate(time.Now().Add(-time.Hour)),
					}
					var groups interface{}
					username := goodUsername
					if test.jwtClaims != nil {
						test.jwtClaims(&wellKnownClaims, &groups, &username)
					}

					var signingKey interface{} = goodECSigningKey
					signingAlgo := goodECSigningAlgo
					signingKID := goodECSigningKeyID
					if test.jwtSignature != nil {
						test.jwtSignature(&signingKey, &signingAlgo, &signingKID)
					}

					jwt := createJWT(
						t,
						signingKey,
						signingAlgo,
						signingKID,
						&wellKnownClaims,
						tt.wantGroupsClaim,
						groups,
						test.distributedGroupsClaimURL,
						tt.wantUsernameClaim,
						username,
					)

					// Loop for a while here to allow the underlying OIDC authenticator to initialize itself asynchronously.
					var (
						rsp           *authenticator.Response
						authenticated bool
						err           error
					)
					_ = wait.PollUntilContextTimeout(context.Background(), 10*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) {
						rsp, authenticated, err = cachedAuthenticator.AuthenticateToken(context.Background(), jwt)
						return !isNotInitialized(err), nil
					})
					if test.wantErr != nil {
						testutil.RequireErrorStringFromErr(t, err, test.wantErr)
					} else {
						require.NoError(t, err)
						require.Equal(t, test.wantResponse, rsp)
						require.Equal(t, test.wantAuthenticated, authenticated)
					}
				})
			}
		})
	}
}

// isNotInitialized checks if the error is the internally-defined "oidc: authenticator not initialized" error from
// the underlying OIDC authenticator or "verifier is not initialized" from verifying distributed claims,
// both of which are initialized asynchronously.
func isNotInitialized(err error) bool {
	return err != nil && (strings.Contains(err.Error(), "authenticator not initialized") || strings.Contains(err.Error(), "verifier not initialized"))
}

func testTableForAuthenticateTokenTests(
	t *testing.T,
	goodRSASigningKey *rsa.PrivateKey,
	goodRSASigningAlgo jose.SignatureAlgorithm,
	goodRSASigningKeyID string,
	group0 string,
	group1 string,
	goodUsername string,
	expectedUsernameClaim string,
	expectedGroupsClaim string,
	issuer string,
) []struct {
	name                      string
	jwtClaims                 func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string)
	jwtSignature              func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
	wantResponse              *authenticator.Response
	wantAuthenticated         bool
	wantErr                   testutil.RequireErrorStringFunc
	distributedGroupsClaimURL string
} {
	tests := []struct {
		name                      string
		jwtClaims                 func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string)
		jwtSignature              func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
		wantResponse              *authenticator.Response
		wantAuthenticated         bool
		wantErr                   testutil.RequireErrorStringFunc
		distributedGroupsClaimURL string
	}{
		{
			name: "good token without groups and with EC signature",
			wantResponse: &authenticator.Response{
				User: &user.DefaultInfo{
					Name: goodUsername,
				},
			},
			wantAuthenticated: true,
		},
		{
			name: "good token without groups and with RSA signature",
			jwtSignature: func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) {
				*key = goodRSASigningKey
				*algo = goodRSASigningAlgo
				*kid = goodRSASigningKeyID
			},
			wantResponse: &authenticator.Response{
				User: &user.DefaultInfo{
					Name: goodUsername,
				},
			},
			wantAuthenticated: true,
		},
		{
			name: "good token with groups as array",
			jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
				*groups = []string{group0, group1}
			},
			wantResponse: &authenticator.Response{
				User: &user.DefaultInfo{
					Name:   goodUsername,
					Groups: []string{group0, group1},
				},
			},
			wantAuthenticated: true,
		},
		{
			name: "good token with good distributed groups",
			jwtClaims: func(claims *jwt.Claims, groups *interface{}, username *string) {
			},
			distributedGroupsClaimURL: issuer + "/claim_source",
			wantResponse: &authenticator.Response{
				User: &user.DefaultInfo{
					Name:   goodUsername,
					Groups: []string{"some-distributed-group-1", "some-distributed-group-2"},
				},
			},
			wantAuthenticated: true,
		},
		{
			name: "distributed groups returns a 404",
			jwtClaims: func(claims *jwt.Claims, groups *interface{}, username *string) {
			},
			distributedGroupsClaimURL: issuer + "/not_found_claim_source",
			wantErr:                   testutil.WantMatchingErrorString(`oidc: could not expand distributed claims: while getting distributed claim "` + expectedGroupsClaim + `": error while getting distributed claim JWT: 404 Not Found`),
		},
		{
			name: "distributed groups doesn't return the right claim",
			jwtClaims: func(claims *jwt.Claims, groups *interface{}, username *string) {
			},
			distributedGroupsClaimURL: issuer + "/wrong_claim_source",
			wantErr:                   testutil.WantMatchingErrorString(`oidc: could not expand distributed claims: jwt returned by distributed claim endpoint "` + issuer + `/wrong_claim_source" did not contain claim: `),
		},
		{
			name: "good token with groups as string",
			jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
				*groups = group0
			},
			wantResponse: &authenticator.Response{
				User: &user.DefaultInfo{
					Name:   goodUsername,
					Groups: []string{group0},
				},
			},
			wantAuthenticated: true,
		},
		{
			name: "good token with nbf unset",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.NotBefore = nil
			},
			wantResponse: &authenticator.Response{
				User: &user.DefaultInfo{
					Name: goodUsername,
				},
			},
			wantAuthenticated: true,
		},
		{
			name: "bad token with groups as map",
			jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
				*groups = map[string]string{"not an array": "or a string"}
			},
			wantErr: testutil.WantMatchingErrorString("oidc: parse groups claim \"" + expectedGroupsClaim + "\": json: cannot unmarshal object into Go value of type string"),
		},
		{
			name: "bad token with wrong issuer",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.Issuer = "wrong-issuer"
			},
			wantResponse:      nil,
			wantAuthenticated: false,
		},
		{
			name: "bad token with no audience",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.Audience = nil
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: expected audience "some-audience" got \[\]`),
		},
		{
			name: "bad token with wrong audience",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.Audience = []string{"wrong-audience"}
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: expected audience "some-audience" got \["wrong-audience"\]`),
		},
		{
			name: "bad token with nbf in the future",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.NotBefore = jwt.NewNumericDate(time.Date(3020, 2, 3, 4, 5, 6, 7, time.UTC))
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: current time .* before the nbf \(not before\) time: 3020-.*`),
		},
		{
			name: "bad token with exp in past",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.Expiry = jwt.NewNumericDate(time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC))
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: token is expired \(Token Expiry: .+`),
		},
		{
			name: "bad token without exp",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				claims.Expiry = nil
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: token is expired \(Token Expiry: .+`),
		},
		{
			name: "token does not have username claim",
			jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
				*username = ""
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: parse username claims "` + expectedUsernameClaim + `": claim not present`),
		},
		{
			name: "signing key is wrong",
			jwtSignature: func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) {
				var err error
				*key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
				require.NoError(t, err)
				*algo = jose.ES256
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: failed to verify signature: failed to verify id token signature`),
		},
		{
			name: "signing algo is unsupported",
			jwtSignature: func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) {
				var err error
				*key, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
				require.NoError(t, err)
				*algo = jose.ES384
			},
			wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: id token signed with unsupported algorithm, expected \["RS256" "ES256"\] got "ES384"`),
		},
	}

	return tests
}

func tlsSpecFromTLSConfig(tls *tls.Config) *auth1alpha1.TLSSpec {
	pemData := make([]byte, 0)
	for _, certificate := range tls.Certificates {
		for _, reallyCertificate := range certificate.Certificate {
			pemData = append(pemData, pem.EncodeToMemory(&pem.Block{
				Type:  "CERTIFICATE",
				Bytes: reallyCertificate,
			})...)
		}
	}
	return &auth1alpha1.TLSSpec{
		CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemData),
	}
}

func createJWT(
	t *testing.T,
	signingKey interface{},
	signingAlgo jose.SignatureAlgorithm,
	kid string,
	claims *jwt.Claims,
	groupsClaim string,
	groupsValue interface{},
	distributedGroupsClaimURL string,
	usernameClaim string,
	usernameValue string,
) string {
	t.Helper()

	sig, err := jose.NewSigner(
		jose.SigningKey{Algorithm: signingAlgo, Key: signingKey},
		(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid),
	)
	require.NoError(t, err)

	builder := jwt.Signed(sig).Claims(claims)
	if groupsValue != nil {
		builder = builder.Claims(map[string]interface{}{groupsClaim: groupsValue})
	}
	if distributedGroupsClaimURL != "" {
		builder = builder.Claims(map[string]interface{}{"_claim_names": map[string]string{groupsClaim: "src1"}})
		builder = builder.Claims(map[string]interface{}{"_claim_sources": map[string]interface{}{"src1": map[string]string{"endpoint": distributedGroupsClaimURL}}})
	}
	if usernameValue != "" {
		builder = builder.Claims(map[string]interface{}{usernameClaim: usernameValue})
	}
	jwt, err := builder.CompactSerialize()
	require.NoError(t, err)

	return jwt
}

func newCacheValue(t *testing.T, spec auth1alpha1.JWTAuthenticatorSpec, wantClose bool) authncache.Value {
	ctrl := gomock.NewController(t)
	t.Cleanup(ctrl.Finish)
	tokenAuthenticatorCloser := mocktokenauthenticatorcloser.NewMockTokenAuthenticatorCloser(ctrl)

	wantCloses := 0
	if wantClose {
		wantCloses++
	}
	tokenAuthenticatorCloser.EXPECT().Close().Times(wantCloses)

	return &jwtAuthenticator{
		tokenAuthenticatorCloser: tokenAuthenticatorCloser,
		spec:                     &spec,
	}
}