260 lines
10 KiB
Go
260 lines
10 KiB
Go
|
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
|
||
|
// SPDX-License-Identifier: Apache-2.0
|
||
|
|
||
|
package impersonator
|
||
|
|
||
|
import (
|
||
|
"encoding/base64"
|
||
|
"encoding/json"
|
||
|
"fmt"
|
||
|
"net/http"
|
||
|
"net/http/httptest"
|
||
|
"net/url"
|
||
|
"testing"
|
||
|
|
||
|
"github.com/golang/mock/gomock"
|
||
|
"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/user"
|
||
|
"k8s.io/client-go/rest"
|
||
|
"k8s.io/client-go/tools/clientcmd/api"
|
||
|
|
||
|
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
|
||
|
"go.pinniped.dev/internal/controller/authenticator/authncache"
|
||
|
"go.pinniped.dev/internal/mocks/mocktokenauthenticator"
|
||
|
"go.pinniped.dev/internal/testutil"
|
||
|
"go.pinniped.dev/internal/testutil/testlogger"
|
||
|
)
|
||
|
|
||
|
func TestImpersonator(t *testing.T) {
|
||
|
validURL, _ := url.Parse("http://pinniped.dev/blah")
|
||
|
testServerCA, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
|
// Expect that the request is authenticated based on the kubeconfig credential.
|
||
|
if r.Header.Get("Authorization") != "Bearer some-service-account-token" {
|
||
|
http.Error(w, "expected to see service account token", http.StatusForbidden)
|
||
|
return
|
||
|
}
|
||
|
// Fail if we see the malicious header passed through the proxy (it's not on the allowlist).
|
||
|
if r.Header.Get("Malicious-Header") != "" {
|
||
|
http.Error(w, "didn't expect to see malicious header", http.StatusForbidden)
|
||
|
return
|
||
|
}
|
||
|
// Expect to see the user agent header passed through.
|
||
|
if r.Header.Get("User-Agent") != "test-user-agent" {
|
||
|
http.Error(w, "got unexpected user agent header", http.StatusBadRequest)
|
||
|
return
|
||
|
}
|
||
|
_, _ = w.Write([]byte("successful proxied response"))
|
||
|
})
|
||
|
testServerKubeconfig := rest.Config{
|
||
|
Host: testServerURL,
|
||
|
BearerToken: "some-service-account-token",
|
||
|
TLSClientConfig: rest.TLSClientConfig{CAData: []byte(testServerCA)},
|
||
|
}
|
||
|
|
||
|
tests := []struct {
|
||
|
name string
|
||
|
getKubeconfig func() (*rest.Config, error)
|
||
|
wantCreationErr string
|
||
|
request *http.Request
|
||
|
wantHTTPBody string
|
||
|
wantHTTPStatus int
|
||
|
wantLogs []string
|
||
|
expectMockToken func(*testing.T, *mocktokenauthenticator.MockTokenMockRecorder)
|
||
|
}{
|
||
|
{
|
||
|
name: "fail to get in-cluster config",
|
||
|
getKubeconfig: func() (*rest.Config, error) {
|
||
|
return nil, fmt.Errorf("some kubernetes error")
|
||
|
},
|
||
|
wantCreationErr: "could not get in-cluster config: some kubernetes error",
|
||
|
},
|
||
|
{
|
||
|
name: "invalid kubeconfig host",
|
||
|
getKubeconfig: func() (*rest.Config, error) {
|
||
|
return &rest.Config{Host: ":"}, nil
|
||
|
},
|
||
|
wantCreationErr: "could not parse host URL from in-cluster config: parse \":\": missing protocol scheme",
|
||
|
},
|
||
|
{
|
||
|
name: "invalid transport config",
|
||
|
getKubeconfig: func() (*rest.Config, error) {
|
||
|
return &rest.Config{
|
||
|
Host: "pinniped.dev/blah",
|
||
|
ExecProvider: &api.ExecConfig{},
|
||
|
AuthProvider: &api.AuthProviderConfig{},
|
||
|
}, nil
|
||
|
},
|
||
|
wantCreationErr: "could not get in-cluster transport config: execProvider and authProvider cannot be used in combination",
|
||
|
},
|
||
|
{
|
||
|
name: "fail to get transport from config",
|
||
|
getKubeconfig: func() (*rest.Config, error) {
|
||
|
return &rest.Config{
|
||
|
Host: "pinniped.dev/blah",
|
||
|
BearerToken: "test-bearer-token",
|
||
|
Transport: http.DefaultTransport,
|
||
|
TLSClientConfig: rest.TLSClientConfig{Insecure: true},
|
||
|
}, nil
|
||
|
},
|
||
|
wantCreationErr: "could not get in-cluster transport: using a custom transport with TLS certificate options or the insecure flag is not allowed",
|
||
|
},
|
||
|
{
|
||
|
name: "missing authorization header",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
wantHTTPBody: "invalid token encoding\n",
|
||
|
wantHTTPStatus: http.StatusBadRequest,
|
||
|
wantLogs: []string{"\"error\"=\"missing authorization header\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
|
||
|
},
|
||
|
{
|
||
|
name: "authorization header missing bearer prefix",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{"Authorization": {makeTestTokenRequest("foo", "authenticator-one", "test-token")}},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
wantHTTPBody: "invalid token encoding\n",
|
||
|
wantHTTPStatus: http.StatusBadRequest,
|
||
|
wantLogs: []string{"\"error\"=\"authorization header must be of type Bearer\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
|
||
|
},
|
||
|
{
|
||
|
name: "token is not base64 encoded",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{"Authorization": {"Bearer !!!"}},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
wantHTTPBody: "invalid token encoding\n",
|
||
|
wantHTTPStatus: http.StatusBadRequest,
|
||
|
wantLogs: []string{"\"error\"=\"invalid base64 in encoded bearer token: illegal base64 data at input byte 0\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
|
||
|
},
|
||
|
{
|
||
|
name: "base64 encoded token is not valid json",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{"Authorization": {"Bearer abc"}},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
wantHTTPBody: "invalid token encoding\n",
|
||
|
wantHTTPStatus: http.StatusBadRequest,
|
||
|
wantLogs: []string{"\"error\"=\"invalid TokenCredentialRequest encoded in bearer token: invalid character 'i' looking for beginning of value\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
|
||
|
},
|
||
|
{
|
||
|
name: "token could not be authenticated",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{"Authorization": {"Bearer " + makeTestTokenRequest("", "", "")}},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
wantHTTPBody: "invalid token\n",
|
||
|
wantHTTPStatus: http.StatusUnauthorized,
|
||
|
wantLogs: []string{"\"error\"=\"no such authenticator\" \"msg\"=\"received invalid token\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"\"} \"authenticatorNamespace\"=\"\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
|
||
|
},
|
||
|
{
|
||
|
name: "token authenticates as nil",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{"Authorization": {"Bearer " + makeTestTokenRequest("foo", "authenticator-one", "test-token")}},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
|
||
|
recorder.AuthenticateToken(gomock.Any(), "test-token").Return(nil, false, nil)
|
||
|
},
|
||
|
wantHTTPBody: "not authenticated\n",
|
||
|
wantHTTPStatus: http.StatusUnauthorized,
|
||
|
wantLogs: []string{"\"level\"=0 \"msg\"=\"received token that did not authenticate\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"authenticator-one\"} \"authenticatorNamespace\"=\"foo\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
|
||
|
},
|
||
|
// happy path
|
||
|
{
|
||
|
name: "token validates",
|
||
|
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
|
||
|
request: &http.Request{
|
||
|
Method: "GET",
|
||
|
Header: map[string][]string{
|
||
|
"Authorization": {"Bearer " + makeTestTokenRequest("foo", "authenticator-one", "test-token")},
|
||
|
"Malicious-Header": {"test-header-value-1"},
|
||
|
"User-Agent": {"test-user-agent"},
|
||
|
},
|
||
|
URL: validURL,
|
||
|
},
|
||
|
expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
|
||
|
userInfo := user.DefaultInfo{Name: "test-user", Groups: []string{"test-group-1", "test-group-2"}}
|
||
|
response := &authenticator.Response{User: &userInfo}
|
||
|
recorder.AuthenticateToken(gomock.Any(), "test-token").Return(response, true, nil)
|
||
|
},
|
||
|
wantHTTPBody: "successful proxied response",
|
||
|
wantHTTPStatus: http.StatusOK,
|
||
|
wantLogs: []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"authenticator-one\"} \"authenticatorNamespace\"=\"foo\" \"groups\"=[\"test-group-1\",\"test-group-2\"] \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"user\"=\"test-user\""},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
for _, tt := range tests {
|
||
|
tt := tt
|
||
|
testLog := testlogger.New(t)
|
||
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
// stole this from cache_test, hopefully it is sufficient
|
||
|
cacheWithMockAuthenticator := authncache.New()
|
||
|
ctrl := gomock.NewController(t)
|
||
|
defer ctrl.Finish()
|
||
|
key := authncache.Key{Namespace: "foo", Name: "authenticator-one"}
|
||
|
mockToken := mocktokenauthenticator.NewMockToken(ctrl)
|
||
|
cacheWithMockAuthenticator.Store(key, mockToken)
|
||
|
|
||
|
if tt.expectMockToken != nil {
|
||
|
tt.expectMockToken(t, mockToken.EXPECT())
|
||
|
}
|
||
|
|
||
|
proxy, err := newInternal(cacheWithMockAuthenticator, testLog, tt.getKubeconfig)
|
||
|
if tt.wantCreationErr != "" {
|
||
|
require.EqualError(t, err, tt.wantCreationErr)
|
||
|
return
|
||
|
}
|
||
|
require.NoError(t, err)
|
||
|
require.NotNil(t, proxy)
|
||
|
w := httptest.NewRecorder()
|
||
|
proxy.ServeHTTP(w, tt.request)
|
||
|
if tt.wantHTTPStatus != 0 {
|
||
|
require.Equal(t, tt.wantHTTPStatus, w.Code)
|
||
|
}
|
||
|
if tt.wantHTTPBody != "" {
|
||
|
require.Equal(t, tt.wantHTTPBody, w.Body.String())
|
||
|
}
|
||
|
if tt.wantLogs != nil {
|
||
|
require.Equal(t, tt.wantLogs, testLog.Lines())
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func makeTestTokenRequest(namespace string, name string, token string) string {
|
||
|
reqJSON, err := json.Marshal(&loginv1alpha1.TokenCredentialRequest{
|
||
|
ObjectMeta: metav1.ObjectMeta{
|
||
|
Namespace: namespace,
|
||
|
},
|
||
|
TypeMeta: metav1.TypeMeta{
|
||
|
Kind: "TokenCredentialRequest",
|
||
|
APIVersion: loginv1alpha1.GroupName + "/v1alpha1",
|
||
|
},
|
||
|
Spec: loginv1alpha1.TokenCredentialRequestSpec{
|
||
|
Token: token,
|
||
|
Authenticator: corev1.TypedLocalObjectReference{Name: name},
|
||
|
},
|
||
|
})
|
||
|
if err != nil {
|
||
|
panic(err)
|
||
|
}
|
||
|
return base64.RawURLEncoding.EncodeToString(reqJSON)
|
||
|
}
|