Add support for multiple IDPs selected using IdentityProvider field.

This also has fallback compatibility support if no IDP is specified and there is exactly one IDP in the cache.

Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
Matt Moyer 2020-09-21 11:37:54 -05:00
parent fbe0551426
commit 6cdd4a9506
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D
13 changed files with 519 additions and 493 deletions

View File

@ -12,7 +12,6 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/client-go/pkg/version" "k8s.io/client-go/pkg/version"
@ -54,7 +53,7 @@ type Config struct {
} }
type ExtraConfig struct { type ExtraConfig struct {
TokenAuthenticator authenticator.Token Authenticator credentialrequest.TokenCredentialRequestAuthenticator
Issuer credentialrequest.CertIssuer Issuer credentialrequest.CertIssuer
StartControllersPostStartHook func(ctx context.Context) StartControllersPostStartHook func(ctx context.Context)
} }
@ -98,7 +97,7 @@ func (c completedConfig) New() (*PinnipedServer, error) {
} }
gvr := loginv1alpha1.SchemeGroupVersion.WithResource("tokencredentialrequests") gvr := loginv1alpha1.SchemeGroupVersion.WithResource("tokencredentialrequests")
storage := credentialrequest.NewREST(c.ExtraConfig.TokenAuthenticator, c.ExtraConfig.Issuer) storage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer)
if err := s.GenericAPIServer.InstallAPIGroup(&genericapiserver.APIGroupInfo{ if err := s.GenericAPIServer.InstallAPIGroup(&genericapiserver.APIGroupInfo{
PrioritizedVersions: []schema.GroupVersion{gvr.GroupVersion()}, PrioritizedVersions: []schema.GroupVersion{gvr.GroupVersion()},
VersionedResourcesStorageMap: map[string]map[string]rest.Storage{gvr.Version: {gvr.Resource: storage}}, VersionedResourcesStorageMap: map[string]map[string]rest.Storage{gvr.Version: {gvr.Resource: storage}},

View File

@ -10,15 +10,19 @@ import (
"sync" "sync"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"go.pinniped.dev/internal/controllerlib" loginapi "go.pinniped.dev/generated/1.19/apis/login"
) )
var ( var (
// ErrNoIDPs is returned by Cache.AuthenticateToken() when there are no IDPs configured. // ErrNoSuchIDP is returned by Cache.AuthenticateTokenCredentialRequest() when the requested IDP is not configured.
ErrNoSuchIDP = fmt.Errorf("no such identity provider")
// ErrNoIDPs is returned by Cache.AuthenticateTokenCredentialRequest() when there are no IDPs configured.
ErrNoIDPs = fmt.Errorf("no identity providers are loaded") ErrNoIDPs = fmt.Errorf("no identity providers are loaded")
// ErrIndeterminateIDP is returned by Cache.AuthenticateToken() when the correct IDP cannot be determined. // ErrIndeterminateIDP is returned by Cache.AuthenticateTokenCredentialRequest() when the correct IDP cannot be determined.
ErrIndeterminateIDP = fmt.Errorf("could not uniquely match against an identity provider") ErrIndeterminateIDP = fmt.Errorf("could not uniquely match against an identity provider")
) )
@ -28,48 +32,101 @@ type Cache struct {
cache sync.Map cache sync.Map
} }
type Key struct {
APIGroup string
Kind string
Namespace string
Name string
}
type Value interface {
authenticator.Token
}
// New returns an empty cache. // New returns an empty cache.
func New() *Cache { func New() *Cache {
return &Cache{} return &Cache{}
} }
// Get an identity provider by key.
func (c *Cache) Get(key Key) Value {
res, _ := c.cache.Load(key)
if res == nil {
return nil
}
return res.(Value)
}
// Store an identity provider into the cache. // Store an identity provider into the cache.
func (c *Cache) Store(key controllerlib.Key, value authenticator.Token) { func (c *Cache) Store(key Key, value Value) {
c.cache.Store(key, value) c.cache.Store(key, value)
} }
// Delete an identity provider from the cache. // Delete an identity provider from the cache.
func (c *Cache) Delete(key controllerlib.Key) { func (c *Cache) Delete(key Key) {
c.cache.Delete(key) c.cache.Delete(key)
} }
// Keys currently stored in the cache. // Keys currently stored in the cache.
func (c *Cache) Keys() []controllerlib.Key { func (c *Cache) Keys() []Key {
var result []controllerlib.Key var result []Key
c.cache.Range(func(key, _ interface{}) bool { c.cache.Range(func(key, _ interface{}) bool {
result = append(result, key.(controllerlib.Key)) result = append(result, key.(Key))
return true return true
}) })
return result return result
} }
// AuthenticateToken validates the provided token against the currently loaded identity providers. func (c *Cache) AuthenticateTokenCredentialRequest(ctx context.Context, req *loginapi.TokenCredentialRequest) (user.Info, error) {
func (c *Cache) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) { // Map the incoming request to a cache key.
var matchingIDPs []authenticator.Token key := Key{
c.cache.Range(func(key, value interface{}) bool { Namespace: req.Namespace,
matchingIDPs = append(matchingIDPs, value.(authenticator.Token)) Name: req.Spec.IdentityProvider.Name,
return true Kind: req.Spec.IdentityProvider.Kind,
}) }
if req.Spec.IdentityProvider.APIGroup != nil {
// Return an error if there are no known IDPs. key.APIGroup = *req.Spec.IdentityProvider.APIGroup
if len(matchingIDPs) == 0 {
return nil, false, ErrNoIDPs
} }
// For now, allow there to be only exactly one IDP (until we specify a good mechanism for selecting one). // If the IDP is unspecified (legacy requests), choose the single loaded IDP or fail if there is not exactly
if len(matchingIDPs) != 1 { // one IDP configured.
return nil, false, ErrIndeterminateIDP if key.Name == "" || key.Kind == "" || key.APIGroup == "" {
keys := c.Keys()
if len(keys) == 0 {
return nil, ErrNoIDPs
}
if len(keys) > 1 {
return nil, ErrIndeterminateIDP
}
key = keys[0]
} }
return matchingIDPs[0].AuthenticateToken(ctx, token) val := c.Get(key)
if val == nil {
return nil, ErrNoSuchIDP
}
// The incoming context could have an audience. Since we do not want to handle audiences right now, do not pass it
// through directly to the authentication webhook.
ctx = valuelessContext{ctx}
// Call the selected IDP.
resp, authenticated, err := val.AuthenticateToken(ctx, req.Spec.Token)
if err != nil {
return nil, err
}
if !authenticated {
return nil, nil
}
// Return the user.Info from the response (if it is non-nil).
var respUser user.Info
if resp != nil {
respUser = resp.User
}
return respUser, nil
} }
type valuelessContext struct{ context.Context }
func (valuelessContext) Value(interface{}) interface{} { return nil }

View File

@ -5,95 +5,212 @@ package idpcache
import ( import (
"context" "context"
"fmt"
"testing" "testing"
"time"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"go.pinniped.dev/internal/controllerlib" idpv1alpha "go.pinniped.dev/generated/1.19/apis/idp/v1alpha1"
loginapi "go.pinniped.dev/generated/1.19/apis/login"
"go.pinniped.dev/internal/mocks/mocktokenauthenticator" "go.pinniped.dev/internal/mocks/mocktokenauthenticator"
) )
func TestCache(t *testing.T) { func TestCache(t *testing.T) {
t.Parallel() t.Parallel()
ctx, cancel := context.WithCancel(context.Background()) ctrl := gomock.NewController(t)
defer cancel() defer ctrl.Finish()
tests := []struct { cache := New()
name string require.NotNil(t, cache)
mockAuthenticators map[controllerlib.Key]func(*mocktokenauthenticator.MockToken)
wantResponse *authenticator.Response key1 := Key{Namespace: "foo", Name: "idp-one"}
wantAuthenticated bool mockToken1 := mocktokenauthenticator.NewMockToken(ctrl)
wantErr string cache.Store(key1, mockToken1)
}{ require.Equal(t, mockToken1, cache.Get(key1))
{ require.Equal(t, 1, len(cache.Keys()))
name: "no IDPs",
wantErr: "no identity providers are loaded", key2 := Key{Namespace: "foo", Name: "idp-two"}
}, mockToken2 := mocktokenauthenticator.NewMockToken(ctrl)
{ cache.Store(key2, mockToken2)
name: "multiple IDPs", require.Equal(t, mockToken2, cache.Get(key2))
mockAuthenticators: map[controllerlib.Key]func(mockToken *mocktokenauthenticator.MockToken){ require.Equal(t, 2, len(cache.Keys()))
controllerlib.Key{Namespace: "foo", Name: "idp-one"}: nil,
controllerlib.Key{Namespace: "foo", Name: "idp-two"}: nil, for _, key := range cache.Keys() {
}, cache.Delete(key)
wantErr: "could not uniquely match against an identity provider",
},
{
name: "success",
mockAuthenticators: map[controllerlib.Key]func(mockToken *mocktokenauthenticator.MockToken){
controllerlib.Key{
Namespace: "foo",
Name: "idp-one",
}: func(mockToken *mocktokenauthenticator.MockToken) {
mockToken.EXPECT().AuthenticateToken(ctx, "test-token").Return(
&authenticator.Response{User: &user.DefaultInfo{Name: "test-user"}},
true,
nil,
)
},
},
wantResponse: &authenticator.Response{User: &user.DefaultInfo{Name: "test-user"}},
wantAuthenticated: true,
},
} }
for _, tt := range tests { require.Zero(t, len(cache.Keys()))
tt := tt }
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
func TestAuthenticateTokenCredentialRequest(t *testing.T) {
t.Parallel()
t.Run("missing IDP selector", func(t *testing.T) {
t.Run("no IDPs", func(t *testing.T) {
c := New()
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), &loginapi.TokenCredentialRequest{})
require.EqualError(t, err, "no identity providers are loaded")
require.Nil(t, res)
})
t.Run("multiple IDPs", func(t *testing.T) {
c := New()
c.Store(Key{Name: "idp-one"}, nil)
c.Store(Key{Name: "idp-two"}, nil)
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), &loginapi.TokenCredentialRequest{})
require.EqualError(t, err, "could not uniquely match against an identity provider")
require.Nil(t, res)
})
t.Run("single IDP", func(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
cache := New() c := New()
require.NotNil(t, cache) mockToken := mocktokenauthenticator.NewMockToken(ctrl)
require.Implements(t, (*authenticator.Token)(nil), cache) mockToken.EXPECT().AuthenticateToken(gomock.Any(), "test-token").
Return(&authenticator.Response{User: &user.DefaultInfo{Name: "test-user"}}, true, nil)
c.Store(Key{Name: "idp-one"}, mockToken)
for key, mockFunc := range tt.mockAuthenticators { res, err := c.AuthenticateTokenCredentialRequest(context.Background(), &loginapi.TokenCredentialRequest{
mockToken := mocktokenauthenticator.NewMockToken(ctrl) Spec: loginapi.TokenCredentialRequestSpec{Token: "test-token"},
if mockFunc != nil { })
mockFunc(mockToken)
}
cache.Store(key, mockToken)
}
require.Equal(t, len(tt.mockAuthenticators), len(cache.Keys()))
resp, authenticated, err := cache.AuthenticateToken(ctx, "test-token")
require.Equal(t, tt.wantResponse, resp)
require.Equal(t, tt.wantAuthenticated, authenticated)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "test-user", res.GetName())
for _, key := range cache.Keys() {
cache.Delete(key)
}
require.Zero(t, len(cache.Keys()))
}) })
})
validRequest := loginapi.TokenCredentialRequest{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
Spec: loginapi.TokenCredentialRequestSpec{
IdentityProvider: corev1.TypedLocalObjectReference{
APIGroup: &idpv1alpha.SchemeGroupVersion.Group,
Kind: "WebhookIdentityProvider",
Name: "test-name",
},
Token: "test-token",
},
Status: loginapi.TokenCredentialRequestStatus{},
} }
validRequestKey := Key{
APIGroup: *validRequest.Spec.IdentityProvider.APIGroup,
Kind: validRequest.Spec.IdentityProvider.Kind,
Namespace: validRequest.Namespace,
Name: validRequest.Spec.IdentityProvider.Name,
}
mockCache := func(t *testing.T, res *authenticator.Response, authenticated bool, err error) *Cache {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
m := mocktokenauthenticator.NewMockToken(ctrl)
m.EXPECT().AuthenticateToken(audienceFreeContext{}, validRequest.Spec.Token).Return(res, authenticated, err)
c := New()
c.Store(validRequestKey, m)
return c
}
t.Run("no such IDP", func(t *testing.T) {
c := New()
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), validRequest.DeepCopy())
require.EqualError(t, err, "no such identity provider")
require.Nil(t, res)
})
t.Run("authenticator returns error", func(t *testing.T) {
c := mockCache(t, nil, false, fmt.Errorf("some authenticator error"))
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), validRequest.DeepCopy())
require.EqualError(t, err, "some authenticator error")
require.Nil(t, res)
})
t.Run("authenticator returns unauthenticated without error", func(t *testing.T) {
c := mockCache(t, &authenticator.Response{}, false, nil)
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), validRequest.DeepCopy())
require.NoError(t, err)
require.Nil(t, res)
})
t.Run("authenticator returns nil response without error", func(t *testing.T) {
c := mockCache(t, nil, true, nil)
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), validRequest.DeepCopy())
require.NoError(t, err)
require.Nil(t, res)
})
t.Run("authenticator returns response with nil user", func(t *testing.T) {
c := mockCache(t, &authenticator.Response{}, true, nil)
res, err := c.AuthenticateTokenCredentialRequest(context.Background(), validRequest.DeepCopy())
require.NoError(t, err)
require.Nil(t, res)
})
t.Run("context is cancelled", func(t *testing.T) {
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
m := mocktokenauthenticator.NewMockToken(ctrl)
m.EXPECT().AuthenticateToken(gomock.Any(), validRequest.Spec.Token).DoAndReturn(
func(ctx context.Context, token string) (*authenticator.Response, bool, error) {
select {
case <-time.After(2 * time.Second):
require.Fail(t, "expected to be cancelled")
return nil, true, nil
case <-ctx.Done():
return nil, false, ctx.Err()
}
},
)
c := New()
c.Store(validRequestKey, m)
ctx, cancel := context.WithCancel(context.Background())
errchan := make(chan error)
go func() {
_, err := c.AuthenticateTokenCredentialRequest(ctx, validRequest.DeepCopy())
errchan <- err
}()
cancel()
require.EqualError(t, <-errchan, "context canceled")
})
t.Run("authenticator returns success", func(t *testing.T) {
userInfo := user.DefaultInfo{
Name: "test-user",
UID: "test-uid",
Groups: []string{"test-group-1", "test-group-2"},
Extra: map[string][]string{"extra-key-1": {"extra-value-1", "extra-value-2"}},
}
c := mockCache(t, &authenticator.Response{User: &userInfo}, true, nil)
audienceCtx := authenticator.WithAudiences(context.Background(), authenticator.Audiences{"test-audience-1"})
res, err := c.AuthenticateTokenCredentialRequest(audienceCtx, validRequest.DeepCopy())
require.NoError(t, err)
require.NotNil(t, res)
require.Equal(t, "test-user", res.GetName())
require.Equal(t, "test-uid", res.GetUID())
require.Equal(t, []string{"test-group-1", "test-group-2"}, res.GetGroups())
require.Equal(t, map[string][]string{"extra-key-1": {"extra-value-1", "extra-value-2"}}, res.GetExtra())
})
}
type audienceFreeContext struct{}
func (audienceFreeContext) Matches(in interface{}) bool {
ctx, isCtx := in.(context.Context)
if !isCtx {
return false
}
_, hasAudiences := authenticator.AudiencesFrom(ctx)
return !hasAudiences
}
func (audienceFreeContext) String() string {
return "is a context without authenticator audiences"
} }

View File

@ -59,7 +59,10 @@ func (c *controller) Sync(ctx controllerlib.Context) error {
// Delete any entries from the cache which are no longer in the cluster. // Delete any entries from the cache which are no longer in the cluster.
for _, key := range c.cache.Keys() { for _, key := range c.cache.Keys() {
if _, exists := webhooksByKey[key]; !exists { if key.APIGroup != idpv1alpha1.SchemeGroupVersion.Group || key.Kind != "WebhookIdentityProvider" {
continue
}
if _, exists := webhooksByKey[controllerlib.Key{Namespace: key.Namespace, Name: key.Name}]; !exists {
c.log.WithValues("idp", klog.KRef(key.Namespace, key.Name)).Info("deleting webhook IDP from cache") c.log.WithValues("idp", klog.KRef(key.Namespace, key.Name)).Info("deleting webhook IDP from cache")
c.cache.Delete(key) c.cache.Delete(key)
} }

View File

@ -11,7 +11,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/authenticator"
idpv1alpha "go.pinniped.dev/generated/1.19/apis/idp/v1alpha1" idpv1alpha "go.pinniped.dev/generated/1.19/apis/idp/v1alpha1"
pinnipedfake "go.pinniped.dev/generated/1.19/client/clientset/versioned/fake" pinnipedfake "go.pinniped.dev/generated/1.19/client/clientset/versioned/fake"
@ -24,22 +23,36 @@ import (
func TestController(t *testing.T) { func TestController(t *testing.T) {
t.Parallel() t.Parallel()
testKey1 := controllerlib.Key{Namespace: "test-namespace", Name: "test-name-one"} testKey1 := idpcache.Key{
testKey2 := controllerlib.Key{Namespace: "test-namespace", Name: "test-name-two"} APIGroup: "idp.pinniped.dev",
Kind: "WebhookIdentityProvider",
Namespace: "test-namespace",
Name: "test-name-one",
}
testKey2 := idpcache.Key{
APIGroup: "idp.pinniped.dev",
Kind: "WebhookIdentityProvider",
Namespace: "test-namespace",
Name: "test-name-two",
}
testKeyNonwebhook := idpcache.Key{
APIGroup: "idp.pinniped.dev",
Kind: "SomeOtherIdentityProvider",
Namespace: "test-namespace",
Name: "test-name-one",
}
tests := []struct { tests := []struct {
name string name string
syncKey controllerlib.Key
webhookIDPs []runtime.Object webhookIDPs []runtime.Object
initialCache map[controllerlib.Key]authenticator.Token initialCache map[idpcache.Key]idpcache.Value
wantErr string wantErr string
wantLogs []string wantLogs []string
wantCacheKeys []controllerlib.Key wantCacheKeys []idpcache.Key
}{ }{
{ {
name: "no change", name: "no change",
syncKey: testKey1, initialCache: map[idpcache.Key]idpcache.Value{testKey1: nil},
initialCache: map[controllerlib.Key]authenticator.Token{testKey1: nil},
webhookIDPs: []runtime.Object{ webhookIDPs: []runtime.Object{
&idpv1alpha.WebhookIdentityProvider{ &idpv1alpha.WebhookIdentityProvider{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
@ -48,11 +61,10 @@ func TestController(t *testing.T) {
}, },
}, },
}, },
wantCacheKeys: []controllerlib.Key{testKey1}, wantCacheKeys: []idpcache.Key{testKey1},
}, },
{ {
name: "IDPs not yet added", name: "IDPs not yet added",
syncKey: testKey1,
initialCache: nil, initialCache: nil,
webhookIDPs: []runtime.Object{ webhookIDPs: []runtime.Object{
&idpv1alpha.WebhookIdentityProvider{ &idpv1alpha.WebhookIdentityProvider{
@ -68,14 +80,14 @@ func TestController(t *testing.T) {
}, },
}, },
}, },
wantCacheKeys: []controllerlib.Key{}, wantCacheKeys: []idpcache.Key{},
}, },
{ {
name: "successful cleanup", name: "successful cleanup",
syncKey: testKey1, initialCache: map[idpcache.Key]idpcache.Value{
initialCache: map[controllerlib.Key]authenticator.Token{ testKey1: nil,
testKey1: nil, testKey2: nil,
testKey2: nil, testKeyNonwebhook: nil,
}, },
webhookIDPs: []runtime.Object{ webhookIDPs: []runtime.Object{
&idpv1alpha.WebhookIdentityProvider{ &idpv1alpha.WebhookIdentityProvider{
@ -88,7 +100,7 @@ func TestController(t *testing.T) {
wantLogs: []string{ wantLogs: []string{
`webhookcachecleaner-controller "level"=0 "msg"="deleting webhook IDP from cache" "idp"={"name":"test-name-two","namespace":"test-namespace"}`, `webhookcachecleaner-controller "level"=0 "msg"="deleting webhook IDP from cache" "idp"={"name":"test-name-two","namespace":"test-namespace"}`,
}, },
wantCacheKeys: []controllerlib.Key{testKey1}, wantCacheKeys: []idpcache.Key{testKey1, testKeyNonwebhook},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -112,7 +124,13 @@ func TestController(t *testing.T) {
informers.Start(ctx.Done()) informers.Start(ctx.Done())
controllerlib.TestRunSynchronously(t, controller) controllerlib.TestRunSynchronously(t, controller)
syncCtx := controllerlib.Context{Context: ctx, Key: tt.syncKey} syncCtx := controllerlib.Context{
Context: ctx,
Key: controllerlib.Key{
Namespace: "test-namespace",
Name: "test-name-one",
},
}
if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" { if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr) require.EqualError(t, err, tt.wantErr)

View File

@ -68,7 +68,12 @@ func (c *controller) Sync(ctx controllerlib.Context) error {
return fmt.Errorf("failed to build webhook config: %w", err) return fmt.Errorf("failed to build webhook config: %w", err)
} }
c.cache.Store(ctx.Key, webhookAuthenticator) c.cache.Store(idpcache.Key{
APIGroup: idpv1alpha1.GroupName,
Kind: "WebhookIdentityProvider",
Namespace: ctx.Key.Namespace,
Name: ctx.Key.Name,
}, webhookAuthenticator)
c.log.WithValues("idp", klog.KObj(obj), "endpoint", obj.Spec.Endpoint).Info("added new webhook IDP") c.log.WithValues("idp", klog.KObj(obj), "endpoint", obj.Spec.Endpoint).Info("added new webhook IDP")
return nil return nil
} }

View File

@ -0,0 +1,96 @@
// 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/registry/credentialrequest (interfaces: CertIssuer,TokenCredentialRequestAuthenticator)
// Package credentialrequestmocks is a generated GoMock package.
package credentialrequestmocks
import (
context "context"
pkix "crypto/x509/pkix"
gomock "github.com/golang/mock/gomock"
login "go.pinniped.dev/generated/1.19/apis/login"
user "k8s.io/apiserver/pkg/authentication/user"
reflect "reflect"
time "time"
)
// MockCertIssuer is a mock of CertIssuer interface
type MockCertIssuer struct {
ctrl *gomock.Controller
recorder *MockCertIssuerMockRecorder
}
// MockCertIssuerMockRecorder is the mock recorder for MockCertIssuer
type MockCertIssuerMockRecorder struct {
mock *MockCertIssuer
}
// NewMockCertIssuer creates a new mock instance
func NewMockCertIssuer(ctrl *gomock.Controller) *MockCertIssuer {
mock := &MockCertIssuer{ctrl: ctrl}
mock.recorder = &MockCertIssuerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockCertIssuer) EXPECT() *MockCertIssuerMockRecorder {
return m.recorder
}
// IssuePEM mocks base method
func (m *MockCertIssuer) IssuePEM(arg0 pkix.Name, arg1 []string, arg2 time.Duration) ([]byte, []byte, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IssuePEM", arg0, arg1, arg2)
ret0, _ := ret[0].([]byte)
ret1, _ := ret[1].([]byte)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// IssuePEM indicates an expected call of IssuePEM
func (mr *MockCertIssuerMockRecorder) IssuePEM(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssuePEM", reflect.TypeOf((*MockCertIssuer)(nil).IssuePEM), arg0, arg1, arg2)
}
// MockTokenCredentialRequestAuthenticator is a mock of TokenCredentialRequestAuthenticator interface
type MockTokenCredentialRequestAuthenticator struct {
ctrl *gomock.Controller
recorder *MockTokenCredentialRequestAuthenticatorMockRecorder
}
// MockTokenCredentialRequestAuthenticatorMockRecorder is the mock recorder for MockTokenCredentialRequestAuthenticator
type MockTokenCredentialRequestAuthenticatorMockRecorder struct {
mock *MockTokenCredentialRequestAuthenticator
}
// NewMockTokenCredentialRequestAuthenticator creates a new mock instance
func NewMockTokenCredentialRequestAuthenticator(ctrl *gomock.Controller) *MockTokenCredentialRequestAuthenticator {
mock := &MockTokenCredentialRequestAuthenticator{ctrl: ctrl}
mock.recorder = &MockTokenCredentialRequestAuthenticatorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockTokenCredentialRequestAuthenticator) EXPECT() *MockTokenCredentialRequestAuthenticatorMockRecorder {
return m.recorder
}
// AuthenticateTokenCredentialRequest mocks base method
func (m *MockTokenCredentialRequestAuthenticator) AuthenticateTokenCredentialRequest(arg0 context.Context, arg1 *login.TokenCredentialRequest) (user.Info, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AuthenticateTokenCredentialRequest", arg0, arg1)
ret0, _ := ret[0].(user.Info)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// AuthenticateTokenCredentialRequest indicates an expected call of AuthenticateTokenCredentialRequest
func (mr *MockTokenCredentialRequestAuthenticatorMockRecorder) AuthenticateTokenCredentialRequest(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateTokenCredentialRequest", reflect.TypeOf((*MockTokenCredentialRequestAuthenticator)(nil).AuthenticateTokenCredentialRequest), arg0, arg1)
}

View File

@ -0,0 +1,6 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package credentialrequestmocks
//go:generate go run -v github.com/golang/mock/mockgen -destination=credentialrequestmocks.go -package=credentialrequestmocks -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/registry/credentialrequest CertIssuer,TokenCredentialRequestAuthenticator

View File

@ -1,6 +0,0 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package mockcertissuer
//go:generate go run -v github.com/golang/mock/mockgen -destination=mockcertissuer.go -package=mockcertissuer -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/registry/credentialrequest CertIssuer

View File

@ -1,56 +0,0 @@
// 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/registry/credentialrequest (interfaces: CertIssuer)
// Package mockcertissuer is a generated GoMock package.
package mockcertissuer
import (
pkix "crypto/x509/pkix"
reflect "reflect"
time "time"
gomock "github.com/golang/mock/gomock"
)
// MockCertIssuer is a mock of CertIssuer interface
type MockCertIssuer struct {
ctrl *gomock.Controller
recorder *MockCertIssuerMockRecorder
}
// MockCertIssuerMockRecorder is the mock recorder for MockCertIssuer
type MockCertIssuerMockRecorder struct {
mock *MockCertIssuer
}
// NewMockCertIssuer creates a new mock instance
func NewMockCertIssuer(ctrl *gomock.Controller) *MockCertIssuer {
mock := &MockCertIssuer{ctrl: ctrl}
mock.recorder = &MockCertIssuerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockCertIssuer) EXPECT() *MockCertIssuerMockRecorder {
return m.recorder
}
// IssuePEM mocks base method
func (m *MockCertIssuer) IssuePEM(arg0 pkix.Name, arg1 []string, arg2 time.Duration) ([]byte, []byte, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IssuePEM", arg0, arg1, arg2)
ret0, _ := ret[0].([]byte)
ret1, _ := ret[1].([]byte)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// IssuePEM indicates an expected call of IssuePEM
func (mr *MockCertIssuerMockRecorder) IssuePEM(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssuePEM", reflect.TypeOf((*MockCertIssuer)(nil).IssuePEM), arg0, arg1, arg2)
}

View File

@ -14,7 +14,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
"k8s.io/utils/trace" "k8s.io/utils/trace"
@ -35,16 +35,20 @@ type CertIssuer interface {
IssuePEM(subject pkix.Name, dnsNames []string, ttl time.Duration) ([]byte, []byte, error) IssuePEM(subject pkix.Name, dnsNames []string, ttl time.Duration) ([]byte, []byte, error)
} }
func NewREST(tokenAuthenticator authenticator.Token, issuer CertIssuer) *REST { type TokenCredentialRequestAuthenticator interface {
AuthenticateTokenCredentialRequest(ctx context.Context, req *loginapi.TokenCredentialRequest) (user.Info, error)
}
func NewREST(authenticator TokenCredentialRequestAuthenticator, issuer CertIssuer) *REST {
return &REST{ return &REST{
tokenAuthenticator: tokenAuthenticator, authenticator: authenticator,
issuer: issuer, issuer: issuer,
} }
} }
type REST struct { type REST struct {
tokenAuthenticator authenticator.Token authenticator TokenCredentialRequestAuthenticator
issuer CertIssuer issuer CertIssuer
} }
func (*REST) New() runtime.Object { func (*REST) New() runtime.Object {
@ -67,35 +71,20 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
return nil, err return nil, err
} }
// The incoming context could have an audience. Since we do not want to handle audiences right now, do not pass it user, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest)
// through directly to the authentication webhook. Instead only propagate cancellation of the parent context.
cancelCtx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-ctx.Done():
cancel()
case <-cancelCtx.Done():
}
}()
authResponse, authenticated, err := r.tokenAuthenticator.AuthenticateToken(cancelCtx, credentialRequest.Spec.Token)
if err != nil { if err != nil {
traceFailureWithError(t, "webhook authentication", err) traceFailureWithError(t, "webhook authentication", err)
return failureResponse(), nil return failureResponse(), nil
} }
if !authenticated || authResponse == nil || authResponse.User == nil || authResponse.User.GetName() == "" { if user == nil || user.GetName() == "" {
traceSuccess(t, authResponse, authenticated, false) traceSuccess(t, user, false)
return failureResponse(), nil return failureResponse(), nil
} }
username := authResponse.User.GetName()
groups := authResponse.User.GetGroups()
certPEM, keyPEM, err := r.issuer.IssuePEM( certPEM, keyPEM, err := r.issuer.IssuePEM(
pkix.Name{ pkix.Name{
CommonName: username, CommonName: user.GetName(),
Organization: groups, Organization: user.GetGroups(),
}, },
[]string{}, []string{},
clientCertificateTTL, clientCertificateTTL,
@ -105,7 +94,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
return failureResponse(), nil return failureResponse(), nil
} }
traceSuccess(t, authResponse, authenticated, true) traceSuccess(t, user, true)
return &loginapi.TokenCredentialRequest{ return &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{ Status: loginapi.TokenCredentialRequestStatus{
@ -121,8 +110,8 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
func validateRequest(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions, t *trace.Trace) (*loginapi.TokenCredentialRequest, error) { func validateRequest(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions, t *trace.Trace) (*loginapi.TokenCredentialRequest, error) {
credentialRequest, ok := obj.(*loginapi.TokenCredentialRequest) credentialRequest, ok := obj.(*loginapi.TokenCredentialRequest)
if !ok { if !ok {
traceValidationFailure(t, "not a CredentialRequest") traceValidationFailure(t, "not a TokenCredentialRequest")
return nil, apierrors.NewBadRequest(fmt.Sprintf("not a CredentialRequest: %#v", obj)) return nil, apierrors.NewBadRequest(fmt.Sprintf("not a TokenCredentialRequest: %#v", obj))
} }
if len(credentialRequest.Spec.Token) == 0 { if len(credentialRequest.Spec.Token) == 0 {
@ -157,15 +146,14 @@ func validateRequest(ctx context.Context, obj runtime.Object, createValidation r
return credentialRequest, nil return credentialRequest, nil
} }
func traceSuccess(t *trace.Trace, response *authenticator.Response, webhookAuthenticated bool, pinnipedAuthenticated bool) { func traceSuccess(t *trace.Trace, userInfo user.Info, authenticated bool) {
userID := "<none>" userID := "<none>"
if response != nil && response.User != nil { if userInfo != nil {
userID = response.User.GetUID() userID = userInfo.GetUID()
} }
t.Step("success", t.Step("success",
trace.Field{Key: "userID", Value: userID}, trace.Field{Key: "userID", Value: userID},
trace.Field{Key: "idpAuthenticated", Value: webhookAuthenticated}, trace.Field{Key: "authenticated", Value: authenticated},
trace.Field{Key: "pinnipedAuthenticated", Value: pinnipedAuthenticated},
) )
} }

View File

@ -17,45 +17,21 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
"k8s.io/klog/v2" "k8s.io/klog/v2"
loginapi "go.pinniped.dev/generated/1.19/apis/login" loginapi "go.pinniped.dev/generated/1.19/apis/login"
"go.pinniped.dev/internal/mocks/mockcertissuer" "go.pinniped.dev/internal/mocks/credentialrequestmocks"
"go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil"
) )
type contextKey struct{} func TestNew(t *testing.T) {
r := NewREST(nil, nil)
type FakeToken struct { require.NotNil(t, r)
calledWithToken string require.True(t, r.NamespaceScoped())
calledWithContext context.Context require.IsType(t, &loginapi.TokenCredentialRequest{}, r.New())
timeout time.Duration
reachedTimeout bool
cancelled bool
webhookStartedRunningNotificationChan chan bool
returnResponse *authenticator.Response
returnUnauthenticated bool
returnErr error
}
func (f *FakeToken) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
f.calledWithToken = token
f.calledWithContext = ctx
if f.webhookStartedRunningNotificationChan != nil {
f.webhookStartedRunningNotificationChan <- true
}
afterCh := time.After(f.timeout)
select {
case <-afterCh:
f.reachedTimeout = true
case <-ctx.Done():
f.cancelled = true
}
return f.returnResponse, !f.returnUnauthenticated, f.returnErr
} }
func TestCreate(t *testing.T) { func TestCreate(t *testing.T) {
@ -77,18 +53,17 @@ func TestCreate(t *testing.T) {
}) })
it("CreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken", func() { it("CreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "test-user",
UID: "test-user-uid",
Groups: []string{"test-group-1", "test-group-2"},
},
},
returnUnauthenticated: false,
}
issuer := mockcertissuer.NewMockCertIssuer(ctrl) requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{
Name: "test-user",
UID: "test-user-uid",
Groups: []string{"test-group-1", "test-group-2"},
}, nil)
issuer := credentialrequestmocks.NewMockCertIssuer(ctrl)
issuer.EXPECT().IssuePEM( issuer.EXPECT().IssuePEM(
pkix.Name{ pkix.Name{
CommonName: "test-user", CommonName: "test-user",
@ -97,10 +72,9 @@ func TestCreate(t *testing.T) {
1*time.Hour, 1*time.Hour,
).Return([]byte("test-cert"), []byte("test-key"), nil) ).Return([]byte("test-cert"), []byte("test-key"), nil)
storage := NewREST(&webhook, issuer) storage := NewREST(requestAuthenticator, issuer)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken)) response, err := callCreate(context.Background(), storage, req)
r.NoError(err) r.NoError(err)
r.IsType(&loginapi.TokenCredentialRequest{}, response) r.IsType(&loginapi.TokenCredentialRequest{}, response)
@ -119,203 +93,89 @@ func TestCreate(t *testing.T) {
}, },
}, },
}) })
r.Equal(requestToken, webhook.calledWithToken) requireOneLogStatement(r, logger, `"success" userID:test-user-uid,authenticated:true`)
requireOneLogStatement(r, logger, `"success" userID:test-user-uid,idpAuthenticated:true`)
})
it("CreateSucceedsWhenGivenANewLoginAPITokenAndTheWebhookAuthenticatesTheToken", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "test-user",
UID: "test-user-uid",
Groups: []string{"test-group-1", "test-group-2"},
},
},
returnUnauthenticated: false,
}
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
issuer.EXPECT().IssuePEM(
pkix.Name{
CommonName: "test-user",
Organization: []string{"test-group-1", "test-group-2"}},
[]string{},
1*time.Hour,
).Return([]byte("test-cert"), []byte("test-key"), nil)
storage := NewREST(&webhook, issuer)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, &loginapi.TokenCredentialRequest{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: "request name",
},
Spec: loginapi.TokenCredentialRequestSpec{
Token: requestToken,
},
})
r.NoError(err)
r.IsType(&loginapi.TokenCredentialRequest{}, response)
expires := response.(*loginapi.TokenCredentialRequest).Status.Credential.ExpirationTimestamp
r.NotNil(expires)
r.InDelta(time.Now().Add(1*time.Hour).Unix(), expires.Unix(), 5)
response.(*loginapi.TokenCredentialRequest).Status.Credential.ExpirationTimestamp = metav1.Time{}
r.Equal(response, &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{
Credential: &loginapi.ClusterCredential{
ExpirationTimestamp: metav1.Time{},
ClientCertificateData: "test-cert",
ClientKeyData: "test-key",
},
},
})
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"success" userID:test-user-uid,idpAuthenticated:true`)
}) })
it("CreateFailsWithValidTokenWhenCertIssuerFails", func() { it("CreateFailsWithValidTokenWhenCertIssuerFails", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
},
},
returnUnauthenticated: false,
}
issuer := mockcertissuer.NewMockCertIssuer(ctrl) requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
}, nil)
issuer := credentialrequestmocks.NewMockCertIssuer(ctrl)
issuer.EXPECT(). issuer.EXPECT().
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()). IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil, fmt.Errorf("some certificate authority error")) Return(nil, nil, fmt.Errorf("some certificate authority error"))
storage := NewREST(&webhook, issuer) storage := NewREST(requestAuthenticator, issuer)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken)) response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"failure" failureType:cert issuer,msg:some certificate authority error`) requireOneLogStatement(r, logger, `"failure" failureType:cert issuer,msg:some certificate authority error`)
}) })
it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsUnauthenticatedWithUserId", func() { it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsNilUser", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{UID: "test-user-uid"},
},
returnUnauthenticated: true,
}
storage := NewREST(&webhook, nil)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken)) requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).Return(nil, nil)
storage := NewREST(requestAuthenticator, nil)
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
r.Equal(requestToken, webhook.calledWithToken) requireOneLogStatement(r, logger, `"success" userID:<none>,authenticated:false`)
requireOneLogStatement(r, logger, `"success" userID:test-user-uid,idpAuthenticated:false,pinnipedAuthenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsUnauthenticatedWithNilUser", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{User: nil},
returnUnauthenticated: true,
}
storage := NewREST(&webhook, nil)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken))
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"success" userID:<none>,idpAuthenticated:false,pinnipedAuthenticated:false`)
}) })
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails", func() { it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnErr: errors.New("some webhook error"),
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest()) requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(nil, errors.New("some webhook error"))
storage := NewREST(requestAuthenticator, nil)
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"failure" failureType:webhook authentication,msg:some webhook error`) requireOneLogStatement(r, logger, `"failure" failureType:webhook authentication,msg:some webhook error`)
}) })
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsNilResponseWithAuthenticatedFalse", func() {
webhook := FakeToken{
returnResponse: nil,
returnUnauthenticated: false,
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest())
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:<none>,idpAuthenticated:true,pinnipedAuthenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookDoesNotReturnAnyUserInfo", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{},
returnUnauthenticated: false,
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest())
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:<none>,idpAuthenticated:true,pinnipedAuthenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsername", func() { it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsername", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "",
},
},
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest()) requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{Name: ""}, nil)
storage := NewREST(requestAuthenticator, nil)
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:,idpAuthenticated:true,pinnipedAuthenticated:false`) requireOneLogStatement(r, logger, `"success" userID:,authenticated:false`)
})
it("CreateDoesNotPassAdditionalContextInfoToTheWebhook", func() {
webhook := FakeToken{
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
_, err := callCreate(ctx, storage, validCredentialRequest())
r.NoError(err)
r.Nil(webhook.calledWithContext.Value("context-key"))
}) })
it("CreateFailsWhenGivenTheWrongInputType", func() { it("CreateFailsWhenGivenTheWrongInputType", func() {
notACredentialRequest := runtime.Unknown{} notACredentialRequest := runtime.Unknown{}
response, err := NewREST(&FakeToken{}, nil).Create( response, err := NewREST(nil, nil).Create(
genericapirequest.NewContext(), genericapirequest.NewContext(),
&notACredentialRequest, &notACredentialRequest,
rest.ValidateAllObjectFunc, rest.ValidateAllObjectFunc,
&metav1.CreateOptions{}) &metav1.CreateOptions{})
requireAPIError(t, response, err, apierrors.IsBadRequest, "not a CredentialRequest") requireAPIError(t, response, err, apierrors.IsBadRequest, "not a TokenCredentialRequest")
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:not a CredentialRequest`) requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:not a TokenCredentialRequest`)
}) })
it("CreateFailsWhenTokenValueIsEmptyInRequest", func() { it("CreateFailsWhenTokenValueIsEmptyInRequest", func() {
storage := NewREST(&FakeToken{}, nil) storage := NewREST(nil, nil)
response, err := callCreate(context.Background(), storage, credentialRequest(loginapi.TokenCredentialRequestSpec{ response, err := callCreate(context.Background(), storage, credentialRequest(loginapi.TokenCredentialRequestSpec{
Token: "", Token: "",
})) }))
@ -326,7 +186,7 @@ func TestCreate(t *testing.T) {
}) })
it("CreateFailsWhenValidationFails", func() { it("CreateFailsWhenValidationFails", func() {
storage := NewREST(&FakeToken{}, nil) storage := NewREST(nil, nil)
response, err := storage.Create( response, err := storage.Create(
context.Background(), context.Background(),
validCredentialRequest(), validCredentialRequest(),
@ -340,14 +200,16 @@ func TestCreate(t *testing.T) {
}) })
it("CreateDoesNotAllowValidationFunctionToMutateRequest", func() { it("CreateDoesNotAllowValidationFunctionToMutateRequest", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnResponse: webhookSuccessResponse(),
} requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
storage := NewREST(&webhook, successfulIssuer(ctrl)) requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()).
requestToken := "a token" Return(&user.DefaultInfo{Name: "test-user"}, nil)
storage := NewREST(requestAuthenticator, successfulIssuer(ctrl))
response, err := storage.Create( response, err := storage.Create(
context.Background(), context.Background(),
validCredentialRequestWithToken(requestToken), req,
func(ctx context.Context, obj runtime.Object) error { func(ctx context.Context, obj runtime.Object) error {
credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest) credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest)
credentialRequest.Spec.Token = "foobaz" credentialRequest.Spec.Token = "foobaz"
@ -356,20 +218,21 @@ func TestCreate(t *testing.T) {
&metav1.CreateOptions{}) &metav1.CreateOptions{})
r.NoError(err) r.NoError(err)
r.NotEmpty(response) r.NotEmpty(response)
r.Equal(requestToken, webhook.calledWithToken) // i.e. not called with foobaz
}) })
it("CreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken", func() { it("CreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken", func() {
webhook := FakeToken{ req := validCredentialRequest()
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl)) requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()).
Return(&user.DefaultInfo{Name: "test-user"}, nil)
storage := NewREST(requestAuthenticator, successfulIssuer(ctrl))
validationFunctionWasCalled := false validationFunctionWasCalled := false
var validationFunctionSawTokenValue string var validationFunctionSawTokenValue string
response, err := storage.Create( response, err := storage.Create(
context.Background(), context.Background(),
validCredentialRequest(), req,
func(ctx context.Context, obj runtime.Object) error { func(ctx context.Context, obj runtime.Object) error {
credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest) credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest)
validationFunctionWasCalled = true validationFunctionWasCalled = true
@ -384,7 +247,7 @@ func TestCreate(t *testing.T) {
}) })
it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() { it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() {
response, err := NewREST(&FakeToken{}, nil).Create( response, err := NewREST(nil, nil).Create(
genericapirequest.NewContext(), genericapirequest.NewContext(),
validCredentialRequest(), validCredentialRequest(),
rest.ValidateAllObjectFunc, rest.ValidateAllObjectFunc,
@ -396,60 +259,6 @@ func TestCreate(t *testing.T) {
`.pinniped.dev "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`) `.pinniped.dev "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:dryRun not supported`) requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:dryRun not supported`)
}) })
it("CreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself", func() {
webhookStartedRunningNotificationChan := make(chan bool)
webhook := FakeToken{
timeout: time.Second * 2,
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
ctx, cancel := context.WithCancel(context.Background())
c := make(chan bool)
go func() {
_, err := callCreate(ctx, storage, validCredentialRequest())
c <- true
r.NoError(err)
}()
r.False(webhook.cancelled)
r.False(webhook.reachedTimeout)
<-webhookStartedRunningNotificationChan // wait long enough to make sure that the webhook has started
cancel() // cancel the context that was passed to storage.Create() above
<-c // wait for the above call to storage.Create() to be finished
r.True(webhook.cancelled)
r.False(webhook.reachedTimeout)
r.Equal(context.Canceled, webhook.calledWithContext.Err()) // the inner context is cancelled
})
it("CreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself", func() {
webhookStartedRunningNotificationChan := make(chan bool)
webhook := FakeToken{
timeout: 0,
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c := make(chan bool)
go func() {
_, err := callCreate(ctx, storage, validCredentialRequest())
c <- true
r.NoError(err)
}()
r.False(webhook.cancelled)
r.False(webhook.reachedTimeout)
<-webhookStartedRunningNotificationChan // wait long enough to make sure that the webhook has started
<-c // wait for the above call to storage.Create() to be finished
r.False(webhook.cancelled)
r.True(webhook.reachedTimeout)
r.Equal(context.Canceled, webhook.calledWithContext.Err()) // the inner context is cancelled (in this case by the "defer")
})
}, spec.Sequential()) }, spec.Sequential())
} }
@ -488,15 +297,6 @@ func credentialRequest(spec loginapi.TokenCredentialRequestSpec) *loginapi.Token
} }
} }
func webhookSuccessResponse() *authenticator.Response {
return &authenticator.Response{User: &user.DefaultInfo{
Name: "some-user",
UID: "",
Groups: []string{},
Extra: nil,
}}
}
func requireAPIError(t *testing.T, response runtime.Object, err error, expectedErrorTypeChecker func(err error) bool, expectedErrorMessage string) { func requireAPIError(t *testing.T, response runtime.Object, err error, expectedErrorTypeChecker func(err error) bool, expectedErrorMessage string) {
t.Helper() t.Helper()
require.Nil(t, response) require.Nil(t, response)
@ -518,7 +318,7 @@ func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err
} }
func successfulIssuer(ctrl *gomock.Controller) CertIssuer { func successfulIssuer(ctrl *gomock.Controller) CertIssuer {
issuer := mockcertissuer.NewMockCertIssuer(ctrl) issuer := credentialrequestmocks.NewMockCertIssuer(ctrl)
issuer.EXPECT(). issuer.EXPECT().
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()). IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
Return([]byte("test-cert"), []byte("test-key"), nil) Return([]byte("test-cert"), []byte("test-key"), nil)

View File

@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/authentication/authenticator"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
genericoptions "k8s.io/apiserver/pkg/server/options" genericoptions "k8s.io/apiserver/pkg/server/options"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@ -246,8 +245,8 @@ func getClusterCASigner(
// Create a configuration for the aggregated API server. // Create a configuration for the aggregated API server.
func getAggregatedAPIServerConfig( func getAggregatedAPIServerConfig(
dynamicCertProvider provider.DynamicTLSServingCertProvider, dynamicCertProvider provider.DynamicTLSServingCertProvider,
tokenAuthenticator authenticator.Token, authenticator credentialrequest.TokenCredentialRequestAuthenticator,
ca credentialrequest.CertIssuer, issuer credentialrequest.CertIssuer,
startControllersPostStartHook func(context.Context), startControllersPostStartHook func(context.Context),
) (*apiserver.Config, error) { ) (*apiserver.Config, error) {
recommendedOptions := genericoptions.NewRecommendedOptions( recommendedOptions := genericoptions.NewRecommendedOptions(
@ -275,8 +274,8 @@ func getAggregatedAPIServerConfig(
apiServerConfig := &apiserver.Config{ apiServerConfig := &apiserver.Config{
GenericConfig: serverConfig, GenericConfig: serverConfig,
ExtraConfig: apiserver.ExtraConfig{ ExtraConfig: apiserver.ExtraConfig{
TokenAuthenticator: tokenAuthenticator, Authenticator: authenticator,
Issuer: ca, Issuer: issuer,
StartControllersPostStartHook: startControllersPostStartHook, StartControllersPostStartHook: startControllersPostStartHook,
}, },
} }