Merge branch 'main' into token-refresh

This commit is contained in:
Ryan Richard
2020-12-09 09:58:21 -08:00
73 changed files with 4423 additions and 356 deletions
@@ -0,0 +1,29 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package authenticator contains helper code for dealing with *Authenticator CRDs.
package authenticator
import (
"encoding/base64"
auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
)
// Closer is a type that can be closed idempotently.
//
// This type is slightly different from io.Closer, because io.Closer can return an error and is not
// necessarily idempotent.
type Closer interface {
Close()
}
// CABundle returns a PEM-encoded CA bundle from the provided spec. If the provided spec is nil, a
// nil CA bundle will be returned. If the provided spec contains a CA bundle that is not properly
// encoded, an error will be returned.
func CABundle(spec *auth1alpha1.TLSSpec) ([]byte, error) {
if spec == nil {
return nil, nil
}
return base64.StdEncoding.DecodeString(spec.CertificateAuthorityData)
}
@@ -12,8 +12,10 @@ import (
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/klog/v2"
loginapi "go.pinniped.dev/generated/1.19/apis/concierge/login"
"go.pinniped.dev/internal/plog"
)
var (
@@ -93,6 +95,12 @@ func (c *Cache) AuthenticateTokenCredentialRequest(ctx context.Context, req *log
val := c.Get(key)
if val == nil {
plog.Debug(
"authenticator does not exist",
"authenticator", klog.KRef(key.Namespace, key.Name),
"kind", key.Kind,
"apiGroup", key.APIGroup,
)
return nil, ErrNoSuchAuthenticator
}
@@ -0,0 +1,114 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package cachecleaner implements a controller for garbage collecting authenticators from an authenticator cache.
package cachecleaner
import (
"fmt"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/klog/v2"
auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
authinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions/authentication/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controller/authenticator"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
)
// New instantiates a new controllerlib.Controller which will garbage collect authenticators from the provided Cache.
func New(
cache *authncache.Cache,
webhooks authinformers.WebhookAuthenticatorInformer,
jwtAuthenticators authinformers.JWTAuthenticatorInformer,
log logr.Logger,
) controllerlib.Controller {
return controllerlib.New(
controllerlib.Config{
Name: "cachecleaner-controller",
Syncer: &controller{
cache: cache,
webhooks: webhooks,
jwtAuthenticators: jwtAuthenticators,
log: log.WithName("cachecleaner-controller"),
},
},
controllerlib.WithInformer(
webhooks,
pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
controllerlib.WithInformer(
jwtAuthenticators,
pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
)
}
type controller struct {
cache *authncache.Cache
webhooks authinformers.WebhookAuthenticatorInformer
jwtAuthenticators authinformers.JWTAuthenticatorInformer
log logr.Logger
}
// Sync implements controllerlib.Syncer.
func (c *controller) Sync(_ controllerlib.Context) error {
webhooks, err := c.webhooks.Lister().List(labels.Everything())
if err != nil {
return fmt.Errorf("failed to list WebhookAuthenticators: %w", err)
}
jwtAuthenticators, err := c.jwtAuthenticators.Lister().List(labels.Everything())
if err != nil {
return fmt.Errorf("failed to list JWTAuthenticators: %w", err)
}
// Index the current authenticators by cache key.
authenticatorSet := map[authncache.Key]bool{}
for _, webhook := range webhooks {
key := authncache.Key{
Namespace: webhook.Namespace,
Name: webhook.Name,
Kind: "WebhookAuthenticator",
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
}
authenticatorSet[key] = true
}
for _, jwtAuthenticator := range jwtAuthenticators {
key := authncache.Key{
Namespace: jwtAuthenticator.Namespace,
Name: jwtAuthenticator.Name,
Kind: "JWTAuthenticator",
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
}
authenticatorSet[key] = true
}
// Delete any entries from the cache which are no longer in the cluster.
for _, key := range c.cache.Keys() {
if key.APIGroup != auth1alpha1.SchemeGroupVersion.Group || (key.Kind != "WebhookAuthenticator" && key.Kind != "JWTAuthenticator") {
continue
}
if _, exists := authenticatorSet[key]; !exists {
c.log.WithValues(
"authenticator",
klog.KRef(key.Namespace, key.Name),
"kind",
key.Kind,
).Info("deleting authenticator from cache")
value := c.cache.Get(key)
if closer, ok := value.(authenticator.Closer); ok {
closer.Close()
}
c.cache.Delete(key)
}
}
return nil
}
@@ -0,0 +1,198 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package cachecleaner
import (
"context"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
authv1alpha "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned/fake"
pinnipedinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/mocks/mocktokenauthenticatorcloser"
"go.pinniped.dev/internal/testutil/testlogger"
)
func TestController(t *testing.T) {
t.Parallel()
testWebhookKey1 := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "WebhookAuthenticator",
Namespace: "test-namespace",
Name: "test-webhook-name-one",
}
testWebhookKey2 := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "WebhookAuthenticator",
Namespace: "test-namespace",
Name: "test-webhook-name-two",
}
testJWTAuthenticatorKey1 := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "JWTAuthenticator",
Namespace: "test-namespace",
Name: "test-jwt-authenticator-name-one",
}
testJWTAuthenticatorKey2 := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "JWTAuthenticator",
Namespace: "test-namespace",
Name: "test-jwt-authenticator-name-two",
}
testKeyUnknownType := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "SomeOtherAuthenticator",
Namespace: "test-namespace",
Name: "test-name-one",
}
tests := []struct {
name string
objects []runtime.Object
initialCache func(t *testing.T, cache *authncache.Cache)
wantErr string
wantLogs []string
wantCacheKeys []authncache.Key
}{
{
name: "no change",
initialCache: func(t *testing.T, cache *authncache.Cache) {
cache.Store(testWebhookKey1, nil)
cache.Store(testJWTAuthenticatorKey1, nil)
},
objects: []runtime.Object{
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testWebhookKey1.Namespace,
Name: testWebhookKey1.Name,
},
},
&authv1alpha.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testJWTAuthenticatorKey1.Namespace,
Name: testJWTAuthenticatorKey1.Name,
},
},
},
wantCacheKeys: []authncache.Key{testWebhookKey1, testJWTAuthenticatorKey1},
},
{
name: "authenticators not yet added",
objects: []runtime.Object{
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testWebhookKey1.Namespace,
Name: testWebhookKey1.Name,
},
},
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testWebhookKey2.Namespace,
Name: testWebhookKey2.Name,
},
},
&authv1alpha.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testJWTAuthenticatorKey1.Namespace,
Name: testJWTAuthenticatorKey1.Name,
},
},
&authv1alpha.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testJWTAuthenticatorKey2.Namespace,
Name: testJWTAuthenticatorKey2.Name,
},
},
},
wantCacheKeys: []authncache.Key{},
},
{
name: "successful cleanup",
initialCache: func(t *testing.T, cache *authncache.Cache) {
cache.Store(testWebhookKey1, nil)
cache.Store(testWebhookKey2, nil)
cache.Store(testJWTAuthenticatorKey1, newClosableCacheValue(t, 0))
cache.Store(testJWTAuthenticatorKey2, newClosableCacheValue(t, 1))
cache.Store(testKeyUnknownType, nil)
},
objects: []runtime.Object{
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testWebhookKey1.Namespace,
Name: testWebhookKey1.Name,
},
},
&authv1alpha.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testJWTAuthenticatorKey1.Namespace,
Name: testJWTAuthenticatorKey1.Name,
},
},
},
wantLogs: []string{
`cachecleaner-controller "level"=0 "msg"="deleting authenticator from cache" "authenticator"={"name":"test-jwt-authenticator-name-two","namespace":"test-namespace"} "kind"="JWTAuthenticator"`,
`cachecleaner-controller "level"=0 "msg"="deleting authenticator from cache" "authenticator"={"name":"test-webhook-name-two","namespace":"test-namespace"} "kind"="WebhookAuthenticator"`,
},
wantCacheKeys: []authncache.Key{testWebhookKey1, testJWTAuthenticatorKey1, testKeyUnknownType},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
// When we have t.Parallel() here, this test blocks pretty consistently...y tho?
fakeClient := pinnipedfake.NewSimpleClientset(tt.objects...)
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
cache := authncache.New()
if tt.initialCache != nil {
tt.initialCache(t, cache)
}
testLog := testlogger.New(t)
webhooks := informers.Authentication().V1alpha1().WebhookAuthenticators()
jwtAuthenticators := informers.Authentication().V1alpha1().JWTAuthenticators()
controller := New(cache, webhooks, jwtAuthenticators, testLog)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
informers.Start(ctx.Done())
informers.WaitForCacheSync(ctx.Done())
controllerlib.TestRunSynchronously(t, controller)
syncCtx := controllerlib.Context{
Context: ctx,
Key: controllerlib.Key{
Namespace: "test-namespace",
Name: "test-webhook-name-one",
},
}
if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.ElementsMatch(t, tt.wantLogs, testLog.Lines())
require.ElementsMatch(t, tt.wantCacheKeys, cache.Keys())
})
}
}
func newClosableCacheValue(t *testing.T, wantCloses int) authncache.Value {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
tac := mocktokenauthenticatorcloser.NewMockTokenAuthenticatorCloser(ctrl)
tac.EXPECT().Close().Times(wantCloses)
return tac
}
@@ -0,0 +1,189 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package jwtcachefiller implements a controller for filling an authncache.Cache with each
// added/updated JWTAuthenticator.
package jwtcachefiller
import (
"fmt"
"io/ioutil"
"os"
"reflect"
"github.com/go-logr/logr"
"gopkg.in/square/go-jose.v2"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/plugin/pkg/authenticator/token/oidc"
"k8s.io/klog/v2"
auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
authinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions/authentication/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
)
// These default values come from the way that the Supervisor issues and signs tokens. We make these
// the defaults for a JWTAuthenticator so that they can easily integrate with the Supervisor.
const (
defaultUsernameClaim = "sub"
defaultGroupsClaim = "groups"
)
// defaultSupportedSigningAlgos returns the default signing algos that this JWTAuthenticator
// supports (i.e., if none are supplied by the user).
func defaultSupportedSigningAlgos() []string {
return []string{
// RS256 is recommended by the OIDC spec and required, in some capacity. Since we want the
// JWTAuthenticator to be able to support many OIDC ID tokens out of the box, we include this
// algorithm by default.
string(jose.RS256),
// ES256 is what the Supervisor does, by default. We want integration with the JWTAuthenticator
// to be as seamless as possible, so we include this algorithm by default.
string(jose.ES256),
}
}
type tokenAuthenticatorCloser interface {
authenticator.Token
pinnipedauthenticator.Closer
}
type jwtAuthenticator struct {
tokenAuthenticatorCloser
spec *auth1alpha1.JWTAuthenticatorSpec
}
// New instantiates a new controllerlib.Controller which will populate the provided authncache.Cache.
func New(
cache *authncache.Cache,
jwtAuthenticators authinformers.JWTAuthenticatorInformer,
log logr.Logger,
) controllerlib.Controller {
return controllerlib.New(
controllerlib.Config{
Name: "jwtcachefiller-controller",
Syncer: &controller{
cache: cache,
jwtAuthenticators: jwtAuthenticators,
log: log.WithName("jwtcachefiller-controller"),
},
},
controllerlib.WithInformer(
jwtAuthenticators,
pinnipedcontroller.MatchAnythingFilter(nil), // nil parent func is fine because each event is distinct
controllerlib.InformerOption{},
),
)
}
type controller struct {
cache *authncache.Cache
jwtAuthenticators authinformers.JWTAuthenticatorInformer
log logr.Logger
}
// Sync implements controllerlib.Syncer.
func (c *controller) Sync(ctx controllerlib.Context) error {
obj, err := c.jwtAuthenticators.Lister().JWTAuthenticators(ctx.Key.Namespace).Get(ctx.Key.Name)
if err != nil && errors.IsNotFound(err) {
c.log.Info("Sync() found that the JWTAuthenticator does not exist yet or was deleted")
return nil
}
if err != nil {
return fmt.Errorf("failed to get JWTAuthenticator %s/%s: %w", ctx.Key.Namespace, ctx.Key.Name, err)
}
cacheKey := authncache.Key{
APIGroup: auth1alpha1.GroupName,
Kind: "JWTAuthenticator",
Namespace: ctx.Key.Namespace,
Name: ctx.Key.Name,
}
// If this authenticator already exists, then only recreate it if is different from the desired
// authenticator. We don't want to be creating a new authenticator for every resync period.
//
// If we do need to recreate the authenticator, then make sure we close the old one to avoid
// goroutine leaks.
if value := c.cache.Get(cacheKey); value != nil {
jwtAuthenticator := c.extractValueAsJWTAuthenticator(value)
if jwtAuthenticator != nil {
if reflect.DeepEqual(jwtAuthenticator.spec, &obj.Spec) {
c.log.WithValues("jwtAuthenticator", klog.KObj(obj), "issuer", obj.Spec.Issuer).Info("actual jwt authenticator and desired jwt authenticator are the same")
return nil
}
jwtAuthenticator.Close()
}
}
// Make a deep copy of the spec so we aren't storing pointers to something that the informer cache
// may mutate!
jwtAuthenticator, err := newJWTAuthenticator(obj.Spec.DeepCopy())
if err != nil {
return fmt.Errorf("failed to build jwt authenticator: %w", err)
}
c.cache.Store(cacheKey, jwtAuthenticator)
c.log.WithValues("jwtAuthenticator", klog.KObj(obj), "issuer", obj.Spec.Issuer).Info("added new jwt authenticator")
return nil
}
func (c *controller) extractValueAsJWTAuthenticator(value authncache.Value) *jwtAuthenticator {
jwtAuthenticator, ok := value.(*jwtAuthenticator)
if !ok {
actualType := "<nil>"
if t := reflect.TypeOf(value); t != nil {
actualType = t.String()
}
c.log.WithValues("actualType", actualType).Info("wrong JWT authenticator type in cache")
return nil
}
return jwtAuthenticator
}
// newJWTAuthenticator creates a jwt authenticator from the provided spec.
func newJWTAuthenticator(spec *auth1alpha1.JWTAuthenticatorSpec) (*jwtAuthenticator, error) {
caBundle, err := pinnipedauthenticator.CABundle(spec.TLS)
if err != nil {
return nil, fmt.Errorf("invalid TLS configuration: %w", err)
}
var caFile string
if caBundle != nil {
temp, err := ioutil.TempFile("", "pinniped-jwkauthenticator-cafile-*")
if err != nil {
return nil, fmt.Errorf("unable to create temporary file: %w", err)
}
// We can safely remove the temp file at the end of this function since oidc.New() reads the
// provided CA file and then forgets about it.
defer func() { _ = os.Remove(temp.Name()) }()
if _, err := temp.Write(caBundle); err != nil {
return nil, fmt.Errorf("cannot write CA file: %w", err)
}
caFile = temp.Name()
}
authenticator, err := oidc.New(oidc.Options{
IssuerURL: spec.Issuer,
ClientID: spec.Audience,
UsernameClaim: defaultUsernameClaim,
GroupsClaim: defaultGroupsClaim,
SupportedSigningAlgs: defaultSupportedSigningAlgos(),
CAFile: caFile,
})
if err != nil {
return nil, fmt.Errorf("could not initialize authenticator: %w", err)
}
return &jwtAuthenticator{
tokenAuthenticatorCloser: authenticator,
spec: spec,
}, nil
}
@@ -0,0 +1,557 @@
// Copyright 2020 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"
"net/http/httptest"
"testing"
"time"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned/fake"
pinnipedinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/mocks/mocktokenauthenticatorcloser"
"go.pinniped.dev/internal/testutil/testlogger"
)
func TestController(t *testing.T) {
t.Parallel()
someJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
Issuer: "https://some-issuer.com",
Audience: "some-audience",
TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURVVENDQWptZ0F3SUJBZ0lWQUpzNStTbVRtaTJXeUI0bGJJRXBXaUs5a1RkUE1BMEdDU3FHU0liM0RRRUIKQ3dVQU1COHhDekFKQmdOVkJBWVRBbFZUTVJBd0RnWURWUVFLREFkUWFYWnZkR0ZzTUI0WERUSXdNRFV3TkRFMgpNamMxT0ZvWERUSTBNRFV3TlRFMk1qYzFPRm93SHpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBb01CMUJwCmRtOTBZV3d3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRERZWmZvWGR4Z2NXTEMKZEJtbHB5a0tBaG9JMlBuUWtsVFNXMno1cGcwaXJjOGFRL1E3MXZzMTRZYStmdWtFTGlvOTRZYWw4R01DdVFrbApMZ3AvUEE5N1VYelhQNDBpK25iNXcwRGpwWWd2dU9KQXJXMno2MFRnWE5NSFh3VHk4ME1SZEhpUFVWZ0VZd0JpCmtkNThzdEFVS1Y1MnBQTU1reTJjNy9BcFhJNmRXR2xjalUvaFBsNmtpRzZ5dEw2REtGYjJQRWV3MmdJM3pHZ2IKOFVVbnA1V05DZDd2WjNVY0ZHNXlsZEd3aGc3cnZ4U1ZLWi9WOEhCMGJmbjlxamlrSVcxWFM4dzdpUUNlQmdQMApYZWhKZmVITlZJaTJtZlczNlVQbWpMdnVKaGpqNDIrdFBQWndvdDkzdWtlcEgvbWpHcFJEVm9wamJyWGlpTUYrCkYxdnlPNGMxQWdNQkFBR2pnWU13Z1lBd0hRWURWUjBPQkJZRUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1IKTUI4R0ExVWRJd1FZTUJhQUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1JNQjBHQTFVZEpRUVdNQlFHQ0NzRwpBUVVGQndNQ0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BNEdBMVVkRHdFQi93UUVBd0lCCkJqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFYbEh4M2tIMDZwY2NDTDlEVE5qTnBCYnlVSytGd2R6T2IwWFYKcmpNaGtxdHVmdEpUUnR5T3hKZ0ZKNXhUR3pCdEtKamcrVU1pczBOV0t0VDBNWThVMU45U2c5SDl0RFpHRHBjVQpxMlVRU0Y4dXRQMVR3dnJIUzIrdzB2MUoxdHgrTEFiU0lmWmJCV0xXQ21EODUzRlVoWlFZekkvYXpFM28vd0p1CmlPUklMdUpNUk5vNlBXY3VLZmRFVkhaS1RTWnk3a25FcHNidGtsN3EwRE91eUFWdG9HVnlkb3VUR0FOdFhXK2YKczNUSTJjKzErZXg3L2RZOEJGQTFzNWFUOG5vZnU3T1RTTzdiS1kzSkRBUHZOeFQzKzVZUXJwNGR1Nmh0YUFMbAppOHNaRkhidmxpd2EzdlhxL3p1Y2JEaHEzQzBhZnAzV2ZwRGxwSlpvLy9QUUFKaTZLQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"},
}
otherJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
Issuer: "https://some-other-issuer.com",
Audience: "some-audience",
TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURVVENDQWptZ0F3SUJBZ0lWQUpzNStTbVRtaTJXeUI0bGJJRXBXaUs5a1RkUE1BMEdDU3FHU0liM0RRRUIKQ3dVQU1COHhDekFKQmdOVkJBWVRBbFZUTVJBd0RnWURWUVFLREFkUWFYWnZkR0ZzTUI0WERUSXdNRFV3TkRFMgpNamMxT0ZvWERUSTBNRFV3TlRFMk1qYzFPRm93SHpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBb01CMUJwCmRtOTBZV3d3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRERZWmZvWGR4Z2NXTEMKZEJtbHB5a0tBaG9JMlBuUWtsVFNXMno1cGcwaXJjOGFRL1E3MXZzMTRZYStmdWtFTGlvOTRZYWw4R01DdVFrbApMZ3AvUEE5N1VYelhQNDBpK25iNXcwRGpwWWd2dU9KQXJXMno2MFRnWE5NSFh3VHk4ME1SZEhpUFVWZ0VZd0JpCmtkNThzdEFVS1Y1MnBQTU1reTJjNy9BcFhJNmRXR2xjalUvaFBsNmtpRzZ5dEw2REtGYjJQRWV3MmdJM3pHZ2IKOFVVbnA1V05DZDd2WjNVY0ZHNXlsZEd3aGc3cnZ4U1ZLWi9WOEhCMGJmbjlxamlrSVcxWFM4dzdpUUNlQmdQMApYZWhKZmVITlZJaTJtZlczNlVQbWpMdnVKaGpqNDIrdFBQWndvdDkzdWtlcEgvbWpHcFJEVm9wamJyWGlpTUYrCkYxdnlPNGMxQWdNQkFBR2pnWU13Z1lBd0hRWURWUjBPQkJZRUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1IKTUI4R0ExVWRJd1FZTUJhQUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1JNQjBHQTFVZEpRUVdNQlFHQ0NzRwpBUVVGQndNQ0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BNEdBMVVkRHdFQi93UUVBd0lCCkJqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFYbEh4M2tIMDZwY2NDTDlEVE5qTnBCYnlVSytGd2R6T2IwWFYKcmpNaGtxdHVmdEpUUnR5T3hKZ0ZKNXhUR3pCdEtKamcrVU1pczBOV0t0VDBNWThVMU45U2c5SDl0RFpHRHBjVQpxMlVRU0Y4dXRQMVR3dnJIUzIrdzB2MUoxdHgrTEFiU0lmWmJCV0xXQ21EODUzRlVoWlFZekkvYXpFM28vd0p1CmlPUklMdUpNUk5vNlBXY3VLZmRFVkhaS1RTWnk3a25FcHNidGtsN3EwRE91eUFWdG9HVnlkb3VUR0FOdFhXK2YKczNUSTJjKzErZXg3L2RZOEJGQTFzNWFUOG5vZnU3T1RTTzdiS1kzSkRBUHZOeFQzKzVZUXJwNGR1Nmh0YUFMbAppOHNaRkhidmxpd2EzdlhxL3p1Y2JEaHEzQzBhZnAzV2ZwRGxwSlpvLy9QUUFKaTZLQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"},
}
missingTLSJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
Issuer: "https://some-issuer.com",
Audience: "some-audience",
}
invalidTLSJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
Issuer: "https://some-other-issuer.com",
Audience: "some-audience",
TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "invalid base64-encoded data"},
}
tests := []struct {
name string
cache func(*testing.T, *authncache.Cache, bool)
wantClose bool
syncKey controllerlib.Key
jwtAuthenticators []runtime.Object
wantErr string
wantLogs []string
wantCacheEntries int
}{
{
name: "not found",
syncKey: controllerlib.Key{Namespace: "test-namespace", 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{Namespace: "test-namespace", Name: "test-name"},
jwtAuthenticators: []runtime.Object{
&auth1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpec,
},
},
wantLogs: []string{
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="https://some-issuer.com" "jwtAuthenticator"={"name":"test-name","namespace":"test-namespace"}`,
},
wantCacheEntries: 1,
},
{
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",
Namespace: "test-namespace",
Kind: "JWTAuthenticator",
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
},
newCacheValue(t, *otherJWTAuthenticatorSpec, wantClose),
)
},
wantClose: true,
syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"},
jwtAuthenticators: []runtime.Object{
&auth1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpec,
},
},
wantLogs: []string{
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="https://some-issuer.com" "jwtAuthenticator"={"name":"test-name","namespace":"test-namespace"}`,
},
wantCacheEntries: 1,
},
{
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",
Namespace: "test-namespace",
Kind: "JWTAuthenticator",
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
},
newCacheValue(t, *someJWTAuthenticatorSpec, wantClose),
)
},
wantClose: false,
syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"},
jwtAuthenticators: []runtime.Object{
&auth1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpec,
},
},
wantLogs: []string{
`jwtcachefiller-controller "level"=0 "msg"="actual jwt authenticator and desired jwt authenticator are the same" "issuer"="https://some-issuer.com" "jwtAuthenticator"={"name":"test-name","namespace":"test-namespace"}`,
},
wantCacheEntries: 1,
},
{
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",
Namespace: "test-namespace",
Kind: "JWTAuthenticator",
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
},
struct{ authenticator.Token }{},
)
},
syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"},
jwtAuthenticators: []runtime.Object{
&auth1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
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"="https://some-issuer.com" "jwtAuthenticator"={"name":"test-name","namespace":"test-namespace"}`,
},
wantCacheEntries: 1,
},
{
name: "valid jwt authenticator without CA",
syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"},
jwtAuthenticators: []runtime.Object{
&auth1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-name",
},
Spec: *missingTLSJWTAuthenticatorSpec,
},
},
wantLogs: []string{
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="https://some-issuer.com" "jwtAuthenticator"={"name":"test-name","namespace":"test-namespace"}`,
},
wantCacheEntries: 1,
},
{
name: "invalid jwt authenticator CA",
syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"},
jwtAuthenticators: []runtime.Object{
&auth1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-name",
},
Spec: *invalidTLSJWTAuthenticatorSpec,
},
},
wantErr: "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.New(t)
if tt.cache != nil {
tt.cache(t, cache, tt.wantClose)
}
controller := New(cache, informers.Authentication().V1alpha1().JWTAuthenticators(), testLog)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
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 != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantLogs, testLog.Lines())
require.Equal(t, tt.wantCacheEntries, len(cache.Keys()))
})
}
}
func TestNewJWTAuthenticator(t *testing.T) {
t.Parallel()
const (
goodSubject = "some-subject"
goodAudience = "some-audience"
group0 = "some-group-0"
group1 = "some-group-1"
goodECSigningKeyID = "some-ec-key-id"
goodRSASigningKeyID = "some-rsa-key-id"
)
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
mux := http.NewServeMux()
server := httptest.NewTLSServer(mux)
t.Cleanup(server.Close)
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))
}))
goodIssuer := server.URL
a, err := newJWTAuthenticator(&auth1alpha1.JWTAuthenticatorSpec{
Issuer: goodIssuer,
Audience: goodAudience,
TLS: tlsSpecFromTLSConfig(server.TLS),
})
require.NoError(t, err)
t.Cleanup(a.Close)
// The implementation of AuthenticateToken() that we use waits 10 seconds after creation to
// perform OIDC discovery. Therefore, the JWTAuthenticator is not functional for the first 10
// seconds. We sleep for 13 seconds in this unit test to give a little bit of cushion to that 10
// second delay.
//
// We should get rid of this 10 second delay. See
// https://github.com/vmware-tanzu/pinniped/issues/260.
if testing.Short() {
t.Skip("skipping this test when '-short' flag is passed to avoid necessary 13 second sleep")
}
time.Sleep(time.Second * 13)
var tests = []struct {
name string
jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{})
jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
wantResponse *authenticator.Response
wantAuthenticated bool
wantErrorRegexp string
}{
{
name: "good token without groups and with EC signature",
wantResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: goodSubject,
},
},
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: goodSubject,
},
},
wantAuthenticated: true,
},
{
name: "good token with groups as array",
jwtClaims: func(_ *jwt.Claims, groups *interface{}) {
*groups = []string{group0, group1}
},
wantResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: goodSubject,
Groups: []string{group0, group1},
},
},
wantAuthenticated: true,
},
{
name: "good token with groups as string",
jwtClaims: func(_ *jwt.Claims, groups *interface{}) {
*groups = group0
},
wantResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: goodSubject,
Groups: []string{group0},
},
},
wantAuthenticated: true,
},
{
name: "good token with nbf unset",
jwtClaims: func(claims *jwt.Claims, _ *interface{}) {
claims.NotBefore = nil
},
wantResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: goodSubject,
},
},
wantAuthenticated: true,
},
{
name: "bad token with groups as map",
jwtClaims: func(_ *jwt.Claims, groups *interface{}) {
*groups = map[string]string{"not an array": "or a string"}
},
wantErrorRegexp: "oidc: parse groups claim \"groups\": json: cannot unmarshal object into Go value of type string",
},
{
name: "bad token with wrong issuer",
jwtClaims: func(claims *jwt.Claims, _ *interface{}) {
claims.Issuer = "wrong-issuer"
},
wantResponse: nil,
wantAuthenticated: false,
},
{
name: "bad token with no audience",
jwtClaims: func(claims *jwt.Claims, _ *interface{}) {
claims.Audience = nil
},
wantErrorRegexp: `oidc: verify token: oidc: expected audience "some-audience" got \[\]`,
},
{
name: "bad token with wrong audience",
jwtClaims: func(claims *jwt.Claims, _ *interface{}) {
claims.Audience = []string{"wrong-audience"}
},
wantErrorRegexp: `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{}) {
claims.NotBefore = jwt.NewNumericDate(time.Date(3020, 2, 3, 4, 5, 6, 7, time.UTC))
},
wantErrorRegexp: `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{}) {
claims.Expiry = jwt.NewNumericDate(time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC))
},
wantErrorRegexp: `oidc: verify token: oidc: token is expired \(Token Expiry: 0001-02-02 23:09:04 -0456 LMT\)`,
},
{
name: "bad token without exp",
jwtClaims: func(claims *jwt.Claims, _ *interface{}) {
claims.Expiry = nil
},
wantErrorRegexp: `oidc: verify token: oidc: token is expired \(Token Expiry: 0001-01-01 00:00:00 \+0000 UTC\)`,
},
{
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
},
wantErrorRegexp: `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
},
wantErrorRegexp: `oidc: verify token: oidc: id token signed with unsupported algorithm, expected \["RS256" "ES256"\] got "ES384"`,
},
}
for _, test := range tests {
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{}
if test.jwtClaims != nil {
test.jwtClaims(&wellKnownClaims, &groups)
}
var signingKey interface{} = goodECSigningKey
signingAlgo := goodECSigningAlgo
signingKID := goodECSigningKeyID
if test.jwtSignature != nil {
test.jwtSignature(&signingKey, &signingAlgo, &signingKID)
}
jwt := createJWT(t, signingKey, signingAlgo, signingKID, &wellKnownClaims, groups)
rsp, authenticated, err := a.AuthenticateToken(context.Background(), jwt)
if test.wantErrorRegexp != "" {
require.Error(t, err)
require.Regexp(t, test.wantErrorRegexp, err.Error())
} else {
require.NoError(t, err)
require.Equal(t, test.wantResponse, rsp)
require.Equal(t, test.wantAuthenticated, authenticated)
}
})
}
}
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,
groups interface{},
) 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 groups != nil {
builder = builder.Claims(map[string]interface{}{"groups": groups})
}
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,
}
}
@@ -1,71 +0,0 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package webhookcachecleaner implements a controller for garbage collecting webhook authenticators from an authenticator cache.
package webhookcachecleaner
import (
"fmt"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/klog/v2"
auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
authinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions/authentication/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
)
// New instantiates a new controllerlib.Controller which will garbage collect webhooks from the provided Cache.
func New(cache *authncache.Cache, webhooks authinformers.WebhookAuthenticatorInformer, log logr.Logger) controllerlib.Controller {
return controllerlib.New(
controllerlib.Config{
Name: "webhookcachecleaner-controller",
Syncer: &controller{
cache: cache,
webhooks: webhooks,
log: log.WithName("webhookcachecleaner-controller"),
},
},
controllerlib.WithInformer(
webhooks,
pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
)
}
type controller struct {
cache *authncache.Cache
webhooks authinformers.WebhookAuthenticatorInformer
log logr.Logger
}
// Sync implements controllerlib.Syncer.
func (c *controller) Sync(_ controllerlib.Context) error {
webhooks, err := c.webhooks.Lister().List(labels.Everything())
if err != nil {
return fmt.Errorf("failed to list WebhookAuthenticators: %w", err)
}
// Index the current webhooks by key.
webhooksByKey := map[controllerlib.Key]*auth1alpha1.WebhookAuthenticator{}
for _, webhook := range webhooks {
key := controllerlib.Key{Namespace: webhook.Namespace, Name: webhook.Name}
webhooksByKey[key] = webhook
}
// Delete any entries from the cache which are no longer in the cluster.
for _, key := range c.cache.Keys() {
if key.APIGroup != auth1alpha1.SchemeGroupVersion.Group || key.Kind != "WebhookAuthenticator" {
continue
}
if _, exists := webhooksByKey[controllerlib.Key{Namespace: key.Namespace, Name: key.Name}]; !exists {
c.log.WithValues("webhook", klog.KRef(key.Namespace, key.Name)).Info("deleting webhook authenticator from cache")
c.cache.Delete(key)
}
}
return nil
}
@@ -1,144 +0,0 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package webhookcachecleaner
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
authv1alpha "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/1.19/client/concierge/clientset/versioned/fake"
pinnipedinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/testutil/testlogger"
)
func TestController(t *testing.T) {
t.Parallel()
testKey1 := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "WebhookAuthenticator",
Namespace: "test-namespace",
Name: "test-name-one",
}
testKey2 := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "WebhookAuthenticator",
Namespace: "test-namespace",
Name: "test-name-two",
}
testKeyNonwebhook := authncache.Key{
APIGroup: "authentication.concierge.pinniped.dev",
Kind: "SomeOtherAuthenticator",
Namespace: "test-namespace",
Name: "test-name-one",
}
tests := []struct {
name string
webhooks []runtime.Object
initialCache map[authncache.Key]authncache.Value
wantErr string
wantLogs []string
wantCacheKeys []authncache.Key
}{
{
name: "no change",
initialCache: map[authncache.Key]authncache.Value{testKey1: nil},
webhooks: []runtime.Object{
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testKey1.Namespace,
Name: testKey1.Name,
},
},
},
wantCacheKeys: []authncache.Key{testKey1},
},
{
name: "authenticators not yet added",
initialCache: nil,
webhooks: []runtime.Object{
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testKey1.Namespace,
Name: testKey1.Name,
},
},
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testKey2.Namespace,
Name: testKey2.Name,
},
},
},
wantCacheKeys: []authncache.Key{},
},
{
name: "successful cleanup",
initialCache: map[authncache.Key]authncache.Value{
testKey1: nil,
testKey2: nil,
testKeyNonwebhook: nil,
},
webhooks: []runtime.Object{
&authv1alpha.WebhookAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Namespace: testKey1.Namespace,
Name: testKey1.Name,
},
},
},
wantLogs: []string{
`webhookcachecleaner-controller "level"=0 "msg"="deleting webhook authenticator from cache" "webhook"={"name":"test-name-two","namespace":"test-namespace"}`,
},
wantCacheKeys: []authncache.Key{testKey1, testKeyNonwebhook},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fakeClient := pinnipedfake.NewSimpleClientset(tt.webhooks...)
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
cache := authncache.New()
for k, v := range tt.initialCache {
cache.Store(k, v)
}
testLog := testlogger.New(t)
controller := New(cache, informers.Authentication().V1alpha1().WebhookAuthenticators(), testLog)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
informers.Start(ctx.Done())
controllerlib.TestRunSynchronously(t, controller)
syncCtx := controllerlib.Context{
Context: ctx,
Key: controllerlib.Key{
Namespace: "test-namespace",
Name: "test-name-one",
},
}
if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantLogs, testLog.Lines())
require.ElementsMatch(t, tt.wantCacheKeys, cache.Keys())
})
}
}
@@ -5,7 +5,6 @@
package webhookcachefiller
import (
"encoding/base64"
"fmt"
"io/ioutil"
"os"
@@ -23,6 +22,7 @@ import (
auth1alpha1 "go.pinniped.dev/generated/1.19/apis/concierge/authentication/v1alpha1"
authinformers "go.pinniped.dev/generated/1.19/client/concierge/informers/externalversions/authentication/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllerlib"
)
@@ -92,7 +92,7 @@ func newWebhookAuthenticator(
defer func() { _ = os.Remove(temp.Name()) }()
cluster := &clientcmdapi.Cluster{Server: spec.Endpoint}
cluster.CertificateAuthorityData, err = getCABundle(spec.TLS)
cluster.CertificateAuthorityData, err = pinnipedauthenticator.CABundle(spec.TLS)
if err != nil {
return nil, fmt.Errorf("invalid TLS configuration: %w", err)
}
@@ -121,10 +121,3 @@ func newWebhookAuthenticator(
return webhook.New(temp.Name(), version, implicitAuds, customDial)
}
func getCABundle(spec *auth1alpha1.TLSSpec) ([]byte, error) {
if spec == nil {
return nil, nil
}
return base64.StdEncoding.DecodeString(spec.CertificateAuthorityData)
}
@@ -25,7 +25,8 @@ import (
"go.pinniped.dev/internal/config/concierge"
"go.pinniped.dev/internal/controller/apicerts"
"go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controller/authenticator/webhookcachecleaner"
"go.pinniped.dev/internal/controller/authenticator/cachecleaner"
"go.pinniped.dev/internal/controller/authenticator/jwtcachefiller"
"go.pinniped.dev/internal/controller/authenticator/webhookcachefiller"
"go.pinniped.dev/internal/controller/issuerconfig"
"go.pinniped.dev/internal/controller/kubecertagent"
@@ -238,9 +239,18 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) {
singletonWorker,
).
WithController(
webhookcachecleaner.New(
jwtcachefiller.New(
c.AuthenticatorCache,
informers.installationNamespacePinniped.Authentication().V1alpha1().JWTAuthenticators(),
klogr.New(),
),
singletonWorker,
).
WithController(
cachecleaner.New(
c.AuthenticatorCache,
informers.installationNamespacePinniped.Authentication().V1alpha1().WebhookAuthenticators(),
informers.installationNamespacePinniped.Authentication().V1alpha1().JWTAuthenticators(),
klogr.New(),
),
singletonWorker,
@@ -0,0 +1,21 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package mocktokenauthenticatorcloser
import (
"k8s.io/apiserver/pkg/authentication/authenticator"
pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator"
)
//go:generate go run -v github.com/golang/mock/mockgen -destination=mocktokenauthenticatorcloser.go -package=mocktokenauthenticatorcloser -copyright_file=../../../hack/header.txt . TokenAuthenticatorCloser
// TokenAuthenticatorCloser is a type that can authenticate tokens and be closed idempotently.
//
// This type is slightly different from io.Closer, because io.Closer can return an error and is not
// necessarily idempotent.
type TokenAuthenticatorCloser interface {
authenticator.Token
pinnipedauthenticator.Closer
}
@@ -0,0 +1,67 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Code generated by MockGen. DO NOT EDIT.
// Source: go.pinniped.dev/internal/mocks/mocktokenauthenticatorcloser (interfaces: TokenAuthenticatorCloser)
// Package mocktokenauthenticatorcloser is a generated GoMock package.
package mocktokenauthenticatorcloser
import (
context "context"
gomock "github.com/golang/mock/gomock"
authenticator "k8s.io/apiserver/pkg/authentication/authenticator"
reflect "reflect"
)
// MockTokenAuthenticatorCloser is a mock of TokenAuthenticatorCloser interface
type MockTokenAuthenticatorCloser struct {
ctrl *gomock.Controller
recorder *MockTokenAuthenticatorCloserMockRecorder
}
// MockTokenAuthenticatorCloserMockRecorder is the mock recorder for MockTokenAuthenticatorCloser
type MockTokenAuthenticatorCloserMockRecorder struct {
mock *MockTokenAuthenticatorCloser
}
// NewMockTokenAuthenticatorCloser creates a new mock instance
func NewMockTokenAuthenticatorCloser(ctrl *gomock.Controller) *MockTokenAuthenticatorCloser {
mock := &MockTokenAuthenticatorCloser{ctrl: ctrl}
mock.recorder = &MockTokenAuthenticatorCloserMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockTokenAuthenticatorCloser) EXPECT() *MockTokenAuthenticatorCloserMockRecorder {
return m.recorder
}
// AuthenticateToken mocks base method
func (m *MockTokenAuthenticatorCloser) AuthenticateToken(arg0 context.Context, arg1 string) (*authenticator.Response, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AuthenticateToken", arg0, arg1)
ret0, _ := ret[0].(*authenticator.Response)
ret1, _ := ret[1].(bool)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// AuthenticateToken indicates an expected call of AuthenticateToken
func (mr *MockTokenAuthenticatorCloserMockRecorder) AuthenticateToken(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateToken", reflect.TypeOf((*MockTokenAuthenticatorCloser)(nil).AuthenticateToken), arg0, arg1)
}
// Close mocks base method
func (m *MockTokenAuthenticatorCloser) Close() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Close")
}
// Close indicates an expected call of Close
func (mr *MockTokenAuthenticatorCloserMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockTokenAuthenticatorCloser)(nil).Close))
}
+1 -1
View File
@@ -79,7 +79,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
user, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest)
if err != nil {
traceFailureWithError(t, "webhook authentication", err)
traceFailureWithError(t, "token authentication", err)
return failureResponse(), nil
}
if user == nil || user.GetName() == "" {
@@ -145,7 +145,7 @@ func TestCreate(t *testing.T) {
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"failure" failureType:webhook authentication,msg:some webhook error`)
requireOneLogStatement(r, logger, `"failure" failureType:token authentication,msg:some webhook error`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsername", func() {