diff --git a/Dockerfile b/Dockerfile index 09b81b05..e4371745 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -FROM golang:1.16.3 as build-env +FROM golang:1.16.4 as build-env WORKDIR /work COPY . . diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 0a1e2fe0..8674573f 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -20,10 +20,12 @@ import ( "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/transport" "k8s.io/klog/v2/klogr" "go.pinniped.dev/internal/execcredcache" "go.pinniped.dev/internal/groupsuffix" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" @@ -36,13 +38,15 @@ func init() { } type oidcLoginCommandDeps struct { + lookupEnv func(string) (string, bool) login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error) exchangeToken func(context.Context, *conciergeclient.Client, string) (*clientauthv1beta1.ExecCredential, error) } func oidcLoginCommandRealDeps() oidcLoginCommandDeps { return oidcLoginCommandDeps{ - login: oidcclient.Login, + lookupEnv: os.LookupEnv, + login: oidcclient.Login, exchangeToken: func(ctx context.Context, client *conciergeclient.Client, token string) (*clientauthv1beta1.ExecCredential, error) { return client.ExchangeToken(ctx, token) }, @@ -113,7 +117,12 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { return cmd } -func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLoginFlags) error { +func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLoginFlags) error { //nolint:funlen + pLogger, err := SetLogLevel(deps.lookupEnv) + if err != nil { + plog.WarningErr("Received error while setting log level", err) + } + // Initialize the session cache. var sessionOptions []filesession.Option @@ -129,6 +138,7 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin // Initialize the login handler. opts := []oidcclient.Option{ oidcclient.WithContext(cmd.Context()), + oidcclient.WithLogger(klogr.New()), oidcclient.WithScopes(flags.scopes), oidcclient.WithSessionCache(sessionCache), } @@ -187,7 +197,6 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin } opts = append(opts, oidcclient.WithClient(client)) } - // Look up cached credentials based on a hash of all the CLI arguments and the cluster info. cacheKey := struct { Args []string `json:"args"` @@ -200,10 +209,12 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin if flags.credentialCachePath != "" { credCache = execcredcache.New(flags.credentialCachePath) if cred := credCache.Get(cacheKey); cred != nil { + pLogger.Debug("using cached cluster credential.") return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) } } + pLogger.Debug("Performing OIDC login", "issuer", flags.issuer, "client id", flags.clientID) // Do the basic login to get an OIDC token. token, err := deps.login(flags.issuer, flags.clientID, opts...) if err != nil { @@ -213,6 +224,7 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin // If the concierge was configured, exchange the credential for a separate short-lived, cluster-specific credential. if concierge != nil { + pLogger.Debug("Exchanging token for cluster credential", "endpoint", flags.conciergeEndpoint, "authenticator type", flags.conciergeAuthenticatorType, "authenticator name", flags.conciergeAuthenticatorName) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -220,10 +232,14 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin if err != nil { return fmt.Errorf("could not complete Concierge credential exchange: %w", err) } + pLogger.Debug("Successfully exchanged token for cluster credential.") + } else { + pLogger.Debug("No concierge configured, skipping token credential exchange") } // If there was a credential cache, save the resulting credential for future use. if credCache != nil { + pLogger.Debug("caching cluster credential for future use.") credCache.Put(cacheKey, cred) } return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) @@ -245,7 +261,7 @@ func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, er } pool.AppendCertsFromPEM(pem) } - return &http.Client{ + client := &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{ @@ -253,7 +269,10 @@ func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, er MinVersion: tls.VersionTLS12, }, }, - }, nil + } + + client.Transport = transport.DebugWrappers(client.Transport) + return client, nil } func tokenCredential(token *oidctypes.Token) *clientauthv1beta1.ExecCredential { @@ -272,6 +291,18 @@ func tokenCredential(token *oidctypes.Token) *clientauthv1beta1.ExecCredential { return &cred } +func SetLogLevel(lookupEnv func(string) (string, bool)) (*plog.PLogger, error) { + debug, _ := lookupEnv("PINNIPED_DEBUG") + if debug == "true" { + err := plog.ValidateAndSetLogLevelGlobally(plog.LevelDebug) + if err != nil { + return nil, err + } + } + logger := plog.New("Pinniped login: ") + return &logger, nil +} + // mustGetConfigDir returns a directory that follows the XDG base directory convention: // $XDG_CONFIG_HOME defines the base directory relative to which user specific configuration files should // be stored. If $XDG_CONFIG_HOME is either not set or empty, a default equal to $HOME/.config should be used. diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 5f18fa05..b31d7021 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -16,10 +16,12 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/klog/v2" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/testlogger" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -41,10 +43,12 @@ func TestLoginOIDCCommand(t *testing.T) { args []string loginErr error conciergeErr error + env map[string]string wantError bool wantStdout string wantStderr string wantOptionsCount int + wantLogs []string }{ { name: "help flag passed", @@ -155,7 +159,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--upstream-identity-provider-type", "oidc", "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, - wantOptionsCount: 3, + wantOptionsCount: 4, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, { @@ -166,7 +170,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--upstream-identity-provider-type", "ldap", "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, - wantOptionsCount: 4, + wantOptionsCount: 5, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, { @@ -177,7 +181,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, loginErr: fmt.Errorf("some login error"), - wantOptionsCount: 3, + wantOptionsCount: 4, wantError: true, wantStderr: here.Doc(` Error: could not complete Pinniped login: some login error @@ -195,7 +199,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, conciergeErr: fmt.Errorf("some concierge error"), - wantOptionsCount: 3, + wantOptionsCount: 4, wantError: true, wantStderr: here.Doc(` Error: could not complete Concierge credential exchange: some concierge error @@ -208,8 +212,13 @@ func TestLoginOIDCCommand(t *testing.T) { "--issuer", "test-issuer", "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, - wantOptionsCount: 3, + env: map[string]string{"PINNIPED_DEBUG": "true"}, + wantOptionsCount: 4, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", + wantLogs: []string{ + "\"level\"=0 \"msg\"=\"Pinniped login: Performing OIDC login\" \"client id\"=\"test-client-id\" \"issuer\"=\"test-issuer\"", + "\"level\"=0 \"msg\"=\"Pinniped login: No concierge configured, skipping token credential exchange\"", + }, }, { name: "success with all options", @@ -232,17 +241,30 @@ func TestLoginOIDCCommand(t *testing.T) { "--upstream-identity-provider-name", "some-upstream-name", "--upstream-identity-provider-type", "ldap", }, - wantOptionsCount: 9, + env: map[string]string{"PINNIPED_DEBUG": "true"}, + wantOptionsCount: 10, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n", + wantLogs: []string{ + "\"level\"=0 \"msg\"=\"Pinniped login: Performing OIDC login\" \"client id\"=\"test-client-id\" \"issuer\"=\"test-issuer\"", + "\"level\"=0 \"msg\"=\"Pinniped login: Exchanging token for cluster credential\" \"authenticator name\"=\"test-authenticator\" \"authenticator type\"=\"webhook\" \"endpoint\"=\"https://127.0.0.1:1234/\"", + "\"level\"=0 \"msg\"=\"Pinniped login: Successfully exchanged token for cluster credential.\"", + "\"level\"=0 \"msg\"=\"Pinniped login: caching cluster credential for future use.\"", + }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + testLogger := testlogger.New(t) + klog.SetLogger(testLogger) var ( gotOptions []oidcclient.Option ) cmd := oidcLoginCommand(oidcLoginCommandDeps{ + lookupEnv: func(s string) (string, bool) { + v, ok := tt.env[s] + return v, ok + }, login: func(issuer string, clientID string, opts ...oidcclient.Option) (*oidctypes.Token, error) { require.Equal(t, "test-issuer", issuer) require.Equal(t, "test-client-id", clientID) @@ -288,6 +310,8 @@ func TestLoginOIDCCommand(t *testing.T) { require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") require.Len(t, gotOptions, tt.wantOptionsCount) + + require.Equal(t, tt.wantLogs, testLogger.Lines()) }) } } diff --git a/cmd/pinniped/cmd/login_static.go b/cmd/pinniped/cmd/login_static.go index 4b9ac2fd..3642ffe1 100644 --- a/cmd/pinniped/cmd/login_static.go +++ b/cmd/pinniped/cmd/login_static.go @@ -17,6 +17,7 @@ import ( "go.pinniped.dev/internal/execcredcache" "go.pinniped.dev/internal/groupsuffix" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient/oidctypes" ) @@ -83,6 +84,11 @@ func staticLoginCommand(deps staticLoginDeps) *cobra.Command { } func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams) error { + pLogger, err := SetLogLevel(deps.lookupEnv) + if err != nil { + plog.WarningErr("Received error while setting log level", err) + } + if flags.staticToken == "" && flags.staticTokenEnvName == "" { return fmt.Errorf("one of --token or --token-env must be set") } @@ -131,12 +137,14 @@ func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams if flags.credentialCachePath != "" { credCache = execcredcache.New(flags.credentialCachePath) if cred := credCache.Get(cacheKey); cred != nil { + pLogger.Debug("using cached cluster credential.") return json.NewEncoder(out).Encode(cred) } } // If the concierge was configured, exchange the credential for a separate short-lived, cluster-specific credential. if concierge != nil { + pLogger.Debug("exchanging static token for cluster credential", "endpoint", flags.conciergeEndpoint, "authenticator type", flags.conciergeAuthenticatorType, "authenticator name", flags.conciergeAuthenticatorName) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -145,6 +153,7 @@ func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams if err != nil { return fmt.Errorf("could not complete Concierge credential exchange: %w", err) } + pLogger.Debug("exchanged static token for cluster credential") } // If there was a credential cache, save the resulting credential for future use. We only save to the cache if diff --git a/cmd/pinniped/cmd/login_static_test.go b/cmd/pinniped/cmd/login_static_test.go index c2e6d3ea..7db88e32 100644 --- a/cmd/pinniped/cmd/login_static_test.go +++ b/cmd/pinniped/cmd/login_static_test.go @@ -12,6 +12,10 @@ import ( "testing" "time" + "k8s.io/klog/v2" + + "go.pinniped.dev/internal/testutil/testlogger" + "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" @@ -41,6 +45,7 @@ func TestLoginStaticCommand(t *testing.T) { wantStdout string wantStderr string wantOptionsCount int + wantLogs []string }{ { name: "help flag passed", @@ -126,10 +131,12 @@ func TestLoginStaticCommand(t *testing.T) { "--concierge-authenticator-name", "test-authenticator", }, conciergeErr: fmt.Errorf("some concierge error"), + env: map[string]string{"PINNIPED_DEBUG": "true"}, wantError: true, wantStderr: here.Doc(` Error: could not complete Concierge credential exchange: some concierge error `), + wantLogs: []string{"\"level\"=0 \"msg\"=\"Pinniped login: exchanging static token for cluster credential\" \"authenticator name\"=\"test-authenticator\" \"authenticator type\"=\"webhook\" \"endpoint\"=\"https://127.0.0.1/\""}, }, { name: "invalid API group suffix", @@ -151,12 +158,15 @@ func TestLoginStaticCommand(t *testing.T) { args: []string{ "--token", "test-token", }, + env: map[string]string{"PINNIPED_DEBUG": "true"}, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"test-token"}}` + "\n", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + testLogger := testlogger.New(t) + klog.SetLogger(testLogger) cmd := staticLoginCommand(staticLoginDeps{ lookupEnv: func(s string) (string, bool) { v, ok := tt.env[s] @@ -192,6 +202,8 @@ func TestLoginStaticCommand(t *testing.T) { } require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + + require.Equal(t, tt.wantLogs, testLogger.Lines()) }) } } diff --git a/deploy/concierge/deployment.yaml b/deploy/concierge/deployment.yaml index 223b7fa8..525257a7 100644 --- a/deploy/concierge/deployment.yaml +++ b/deploy/concierge/deployment.yaml @@ -22,6 +22,13 @@ metadata: labels: #@ labels() --- apiVersion: v1 +kind: ServiceAccount +metadata: + name: #@ defaultResourceNameWithSuffix("kube-cert-agent") + namespace: #@ namespace() + labels: #@ labels() +--- +apiVersion: v1 kind: ConfigMap metadata: name: #@ defaultResourceNameWithSuffix("config") @@ -47,6 +54,7 @@ data: impersonationTLSCertificateSecret: (@= defaultResourceNameWithSuffix("impersonation-proxy-tls-serving-certificate") @) impersonationCACertificateSecret: (@= defaultResourceNameWithSuffix("impersonation-proxy-ca-certificate") @) impersonationSignerSecret: (@= defaultResourceNameWithSuffix("impersonation-proxy-signer-ca-certificate") @) + agentServiceAccount: (@= defaultResourceNameWithSuffix("kube-cert-agent") @) labels: (@= json.encode(labels()).rstrip() @) kubeCertAgent: namePrefix: (@= defaultResourceNameWithSuffix("kube-cert-agent-") @) diff --git a/deploy/concierge/rbac.yaml b/deploy/concierge/rbac.yaml index 74e5653e..12350714 100644 --- a/deploy/concierge/rbac.yaml +++ b/deploy/concierge/rbac.yaml @@ -24,9 +24,6 @@ rules: - apiGroups: [ flowcontrol.apiserver.k8s.io ] resources: [ flowschemas, prioritylevelconfigurations ] verbs: [ get, list, watch ] - - apiGroups: [ policy ] - resources: [ podsecuritypolicies ] - verbs: [ use ] - apiGroups: [ security.openshift.io ] resources: [ securitycontextconstraints ] verbs: [ use ] @@ -67,6 +64,34 @@ roleRef: name: #@ defaultResourceNameWithSuffix("aggregated-api-server") apiGroup: rbac.authorization.k8s.io +#! Give permission to the kube-cert-agent Pod to run privileged. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: #@ defaultResourceNameWithSuffix("kube-cert-agent") + namespace: #@ namespace() + labels: #@ labels() +rules: + - apiGroups: [ policy ] + resources: [ podsecuritypolicies ] + verbs: [ use ] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: #@ defaultResourceNameWithSuffix("kube-cert-agent") + namespace: #@ namespace() + labels: #@ labels() +subjects: + - kind: ServiceAccount + name: #@ defaultResourceNameWithSuffix("kube-cert-agent") + namespace: #@ namespace() +roleRef: + kind: Role + name: #@ defaultResourceNameWithSuffix("kube-cert-agent") + apiGroup: rbac.authorization.k8s.io + #! Give permission to various objects within the app's own namespace --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/internal/concierge/impersonator/doc.go b/internal/concierge/impersonator/doc.go index d8504603..d15c6715 100644 --- a/internal/concierge/impersonator/doc.go +++ b/internal/concierge/impersonator/doc.go @@ -35,6 +35,13 @@ only shares this information with the audit stack). To keep things simple, we use the fake audit backend at the Metadata level for all requests. This guarantees that we always have an audit event on every request. +One final wrinkle is that impersonation cannot impersonate UIDs (yet). This is +problematic because service account tokens always assert a UID. To handle this +case without losing authentication information, when we see an identity with a +UID that was asserted via a bearer token, we simply pass the request through +with the original bearer token and no impersonation headers set (as if the user +had made the request directly against the Kubernetes API server). + For all normal requests, we only use http/2.0 when proxying to the API server. For upgrade requests, we only use http/1.1 since these always go from http/1.1 to either websockets or SPDY. diff --git a/internal/concierge/impersonator/impersonator.go b/internal/concierge/impersonator/impersonator.go index 087329dd..5778b795 100644 --- a/internal/concierge/impersonator/impersonator.go +++ b/internal/concierge/impersonator/impersonator.go @@ -15,6 +15,8 @@ import ( "strings" "time" + authenticationv1 "k8s.io/api/authentication/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -26,6 +28,8 @@ import ( "k8s.io/apimachinery/pkg/util/sets" auditinternal "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/audit/policy" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/filterlatency" @@ -45,6 +49,7 @@ import ( "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/valuelesscontext" ) // FactoryFunc is a function which can create an impersonator server. @@ -176,6 +181,11 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. // See the genericapiserver.DefaultBuildHandlerChain func for details. handler = defaultBuildHandlerChainFunc(handler, c) + // we need to grab the bearer token before WithAuthentication deletes it. + handler = filterlatency.TrackCompleted(handler) + handler = withBearerTokenPreservation(handler) + handler = filterlatency.TrackStarted(handler, "bearertokenpreservation") + // Always set security headers so browsers do the right thing. handler = filterlatency.TrackCompleted(handler) handler = securityheader.Wrap(handler) @@ -189,6 +199,9 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil) serverConfig.AuditBackend = &auditfake.Backend{} + // if we ever start unioning a TCR bearer token authenticator with serverConfig.Authenticator + // then we will need to update the related assumption in tokenPassthroughRoundTripper + delegatingAuthorizer := serverConfig.Authorization.Authorizer nestedImpersonationAuthorizer := &comparableAuthorizer{ authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { @@ -290,6 +303,35 @@ func (f authorizerFunc) Authorize(ctx context.Context, a authorizer.Attributes) return f(ctx, a) } +func withBearerTokenPreservation(delegate http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // this looks a bit hacky but lets us avoid writing any logic for parsing out the bearer token + var reqToken string + _, _, _ = bearertoken.New(authenticator.TokenFunc(func(_ context.Context, token string) (*authenticator.Response, bool, error) { + reqToken = token + return nil, false, nil + })).AuthenticateRequest(r) + + // smuggle the token through the context. this does mean that we need to avoid logging the context. + if len(reqToken) != 0 { + ctx := context.WithValue(r.Context(), tokenKey, reqToken) + r = r.WithContext(ctx) + } + + delegate.ServeHTTP(w, r) + }) +} + +func tokenFrom(ctx context.Context) string { + token, _ := ctx.Value(tokenKey).(string) + return token +} + +// contextKey type is unexported to prevent collisions. +type contextKey int + +const tokenKey contextKey = iota + func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapiserver.Config) http.Handler, error) { serverURL, err := url.Parse(restConfig.Host) if err != nil { @@ -300,11 +342,19 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi if err != nil { return nil, fmt.Errorf("could not get http/1.1 round tripper: %w", err) } + http1RoundTripperAnonymous, err := getTransportForProtocol(rest.AnonymousClientConfig(restConfig), "http/1.1") + if err != nil { + return nil, fmt.Errorf("could not get http/1.1 anonymous round tripper: %w", err) + } http2RoundTripper, err := getTransportForProtocol(restConfig, "h2") if err != nil { return nil, fmt.Errorf("could not get http/2.0 round tripper: %w", err) } + http2RoundTripperAnonymous, err := getTransportForProtocol(rest.AnonymousClientConfig(restConfig), "h2") + if err != nil { + return nil, fmt.Errorf("could not get http/2.0 anonymous round tripper: %w", err) + } return func(c *genericapiserver.Config) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -347,15 +397,18 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi return } + // grab the request's bearer token if present. this is optional and does not fail the request if missing. + token := tokenFrom(r.Context()) + // KAS only supports upgrades via http/1.1 to websockets/SPDY (upgrades never use http/2.0) // Thus we default to using http/2.0 when the request is not an upgrade, otherwise we use http/1.1 - baseRT := http2RoundTripper + baseRT, baseRTAnonymous := http2RoundTripper, http2RoundTripperAnonymous isUpgradeRequest := httpstream.IsUpgradeRequest(r) if isUpgradeRequest { - baseRT = http1RoundTripper + baseRT, baseRTAnonymous = http1RoundTripper, http1RoundTripperAnonymous } - rt, err := getTransportForUser(userInfo, baseRT, ae) + rt, err := getTransportForUser(r.Context(), userInfo, baseRT, baseRTAnonymous, ae, token, c.Authentication.Authenticator) if err != nil { plog.WarningErr("rejecting request as we cannot act as the current user", err, "url", r.URL.String(), @@ -413,34 +466,117 @@ func ensureNoImpersonationHeaders(r *http.Request) error { return nil } -func getTransportForUser(userInfo user.Info, delegate http.RoundTripper, ae *auditinternal.Event) (http.RoundTripper, error) { - if len(userInfo.GetUID()) == 0 { - extra, err := buildExtra(userInfo.GetExtra(), ae) - if err != nil { - return nil, err - } - - impersonateConfig := transport.ImpersonationConfig{ - UserName: userInfo.GetName(), - Groups: userInfo.GetGroups(), - Extra: extra, - } - // transport.NewImpersonatingRoundTripper clones the request before setting headers - // thus it will not accidentally mutate the input request (see http.Handler docs) - return transport.NewImpersonatingRoundTripper(impersonateConfig, delegate), nil +func getTransportForUser(ctx context.Context, userInfo user.Info, delegate, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) { + if canImpersonateFully(userInfo) { + return standardImpersonationRoundTripper(userInfo, ae, delegate) } - // 0. in the case of a request that is not attempting to do nested impersonation - // 1. if we make the assumption that the TCR API does not issue tokens (or pass the TCR API bearer token - // authenticator into this func - we need to know the authentication cred is something KAS would honor) - // 2. then if preserve the incoming authorization header into the request's context - // 3. we could reauthenticate it here (it would be a free cache hit) - // 4. confirm that it matches the passed in user info (i.e. it was actually the cred used to authenticate and not a client cert) - // 5. then we could issue a reverse proxy request using an anonymous rest config and the bearer token - // 6. thus instead of impersonating the user, we would just be passing their request through - // 7. this would preserve the UID info and thus allow us to safely support all token based auth - // 8. the above would be safe even if in the future Kube started supporting UIDs asserted by client certs - return nil, constable.Error("unexpected uid") + return tokenPassthroughRoundTripper(ctx, delegateAnonymous, ae, token, authenticator) +} + +func canImpersonateFully(userInfo user.Info) bool { + // nolint: gosimple // this structure is on purpose because we plan to expand this function + if len(userInfo.GetUID()) == 0 { + return true + } + + // once kube supports UID impersonation, add logic to detect if the KAS is + // new enough to have this functionality and return true in that case as well + return false +} + +func standardImpersonationRoundTripper(userInfo user.Info, ae *auditinternal.Event, delegate http.RoundTripper) (http.RoundTripper, error) { + extra, err := buildExtra(userInfo.GetExtra(), ae) + if err != nil { + return nil, err + } + + impersonateConfig := transport.ImpersonationConfig{ + UserName: userInfo.GetName(), + Groups: userInfo.GetGroups(), + Extra: extra, + } + // transport.NewImpersonatingRoundTripper clones the request before setting headers + // thus it will not accidentally mutate the input request (see http.Handler docs) + return transport.NewImpersonatingRoundTripper(impersonateConfig, delegate), nil +} + +func tokenPassthroughRoundTripper(ctx context.Context, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) { + // all code below assumes KAS does not support UID impersonation because that case is handled in the standard path + + // it also assumes that the TCR API does not issue tokens - if this assumption changes, we will need + // some way to distinguish a token that is only valid against this impersonation proxy and not against KAS. + // this code will fail closed because said TCR token would not work against KAS and the request would fail. + + // if we get here we know the final user info had a UID + // if the original user is also performing a nested impersonation, it means that said nested + // impersonation is trying to impersonate a UID since final user info == ae.ImpersonatedUser + // we know this KAS does not support UID impersonation so this request must be rejected + if ae.ImpersonatedUser != nil { + return nil, constable.Error("unable to impersonate uid") + } + + // see what KAS thinks this token translates into + // this is important because certs have precedence over tokens and we want + // to make sure that we do not get confused and pass along the wrong token + tokenUser, err := tokenReview(ctx, token, authenticator) + if err != nil { + return nil, err + } + + // we want to compare the result of the token authentication with the original user that made the request + // if the user who made the request and the token do not match, we cannot go any further at this point + if !apiequality.Semantic.DeepEqual(ae.User, tokenUser) { + // this info leak seems fine for trace level logs + plog.Trace("failed to passthrough token due to user mismatch", + "original-username", ae.User.Username, + "original-uid", ae.User.UID, + "token-username", tokenUser.Username, + "token-uid", tokenUser.UID, + ) + return nil, constable.Error("token authenticated as a different user") + } + + // now we know that if we send this token to KAS, it will authenticate correctly + return transport.NewBearerAuthRoundTripper(token, delegateAnonymous), nil +} + +func tokenReview(ctx context.Context, token string, authenticator authenticator.Request) (authenticationv1.UserInfo, error) { + if len(token) == 0 { + return authenticationv1.UserInfo{}, constable.Error("no token on request") + } + + // create a header that contains nothing but the token + // an astute observer may ask "but what about the token's audience?" + // in this case, we want to leave audiences unset per the token review docs: + // > If no audiences are provided, the audience will default to the audience of the Kubernetes apiserver. + // i.e. we want to make sure that the given token is valid against KAS + fakeReq := &http.Request{Header: http.Header{}} + fakeReq.Header.Set("Authorization", "Bearer "+token) + + // propagate cancellation of parent context (without any values such as audience) + fakeReq = fakeReq.WithContext(valuelesscontext.New(ctx)) + + // this will almost always be a free call that hits our 10 second cache TTL + resp, ok, err := authenticator.AuthenticateRequest(fakeReq) + if err != nil { + return authenticationv1.UserInfo{}, err + } + if !ok { + return authenticationv1.UserInfo{}, constable.Error("token failed to authenticate") + } + + tokenUser := authenticationv1.UserInfo{ + Username: resp.User.GetName(), + UID: resp.User.GetUID(), + Groups: resp.User.GetGroups(), + Extra: make(map[string]authenticationv1.ExtraValue, len(resp.User.GetExtra())), + } + for k, v := range resp.User.GetExtra() { + tokenUser.Extra[k] = v + } + + return tokenUser, nil } func buildExtra(extra map[string][]string, ae *auditinternal.Event) (map[string][]string, error) { diff --git a/internal/concierge/impersonator/impersonator_test.go b/internal/concierge/impersonator/impersonator_test.go index dfd765c3..78fd1759 100644 --- a/internal/concierge/impersonator/impersonator_test.go +++ b/internal/concierge/impersonator/impersonator_test.go @@ -22,6 +22,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/httpstream" auditinternal "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/features" @@ -33,6 +35,7 @@ import ( featuregatetesting "k8s.io/component-base/featuregate/testing" "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/httputil/roundtripper" @@ -176,6 +179,26 @@ func TestImpersonator(t *testing.T) { "X-Forwarded-For": {"127.0.0.1"}, }, }, + { + name: "when there is no client cert on request but it has basic auth, it is still an anonymous request", + clientCert: &clientCert{}, + clientMutateHeaders: func(header http.Header) { + header.Set("Test", "val") + req := &http.Request{Header: header} + req.SetBasicAuth("foo", "bar") + }, + kubeAPIServerClientBearerTokenFile: "required-to-be-set", + wantKubeAPIServerRequestHeaders: http.Header{ + "Impersonate-User": {"system:anonymous"}, + "Impersonate-Group": {"system:unauthenticated"}, + "Authorization": {"Bearer some-service-account-token"}, + "User-Agent": {"test-agent"}, + "Accept": {"application/vnd.kubernetes.protobuf,application/json"}, + "Accept-Encoding": {"gzip"}, + "X-Forwarded-For": {"127.0.0.1"}, + "Test": {"val"}, + }, + }, { name: "failed client cert authentication", clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}), @@ -499,39 +522,12 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "extra-2": {"some", "more", "extra", "stuff"}, } - validURL, _ := url.Parse("http://pinniped.dev/blah") - newRequest := func(h http.Header, userInfo user.Info, event *auditinternal.Event) *http.Request { - ctx := context.Background() - - if userInfo != nil { - ctx = request.WithUser(ctx, userInfo) - } - - ae := &auditinternal.Event{Level: auditinternal.LevelMetadata} - if event != nil { - ae = event - } - ctx = request.WithAuditEvent(ctx, ae) - - reqInfo := &request.RequestInfo{ - IsResourceRequest: false, - Path: validURL.Path, - Verb: "get", - } - ctx = request.WithRequestInfo(ctx, reqInfo) - - r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil) - require.NoError(t, err) - r.Header = h - - return r - } - tests := []struct { name string restConfig *rest.Config wantCreationErr string request *http.Request + authenticator authenticator.Request wantHTTPBody string wantHTTPStatus int wantKubeAPIServerRequestHeaders http.Header @@ -563,50 +559,50 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "Impersonate-User header already in request", - request: newRequest(map[string][]string{"Impersonate-User": {"some-user"}}, nil, nil), + request: newRequest(t, map[string][]string{"Impersonate-User": {"some-user"}}, nil, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "Impersonate-Group header already in request", - request: newRequest(map[string][]string{"Impersonate-Group": {"some-group"}}, nil, nil), + request: newRequest(t, map[string][]string{"Impersonate-Group": {"some-group"}}, nil, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "Impersonate-Extra header already in request", - request: newRequest(map[string][]string{"Impersonate-Extra-something": {"something"}}, nil, nil), + request: newRequest(t, map[string][]string{"Impersonate-Extra-something": {"something"}}, nil, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "Impersonate-* header already in request", - request: newRequest(map[string][]string{"Impersonate-Something": {"some-newfangled-impersonate-header"}}, nil, nil), + request: newRequest(t, map[string][]string{"Impersonate-Something": {"some-newfangled-impersonate-header"}}, nil, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "unexpected authorization header", - request: newRequest(map[string][]string{"Authorization": {"panda"}}, nil, nil), + request: newRequest(t, map[string][]string{"Authorization": {"panda"}}, nil, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid authorization header","reason":"InternalError","details":{"causes":[{"message":"invalid authorization header"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "missing user", - request: newRequest(map[string][]string{}, nil, nil), + request: newRequest(t, map[string][]string{}, nil, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid user","reason":"InternalError","details":{"causes":[{"message":"invalid user"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "unexpected UID", - request: newRequest(map[string][]string{}, &user.DefaultInfo{UID: "007"}, nil), + request: newRequest(t, map[string][]string{}, &user.DefaultInfo{UID: "007"}, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "authenticated user but missing audit event", request: func() *http.Request { - req := newRequest(map[string][]string{ + req := newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -615,7 +611,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { Name: testUser, Groups: testGroups, Extra: testExtra, - }, nil) + }, nil, "") ctx := request.WithAuditEvent(req.Context(), nil) req = req.WithContext(ctx) return req @@ -625,7 +621,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with upper case extra", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -639,13 +635,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "valid-key": {"valid-value"}, "Invalid-key": {"still-valid-value"}, }, - }, nil), + }, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "authenticated user with upper case extra across multiple lines", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -659,13 +655,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "valid-key": {"valid-value"}, "valid-data\nInvalid-key": {"still-valid-value"}, }, - }, nil), + }, nil, ""), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, { name: "authenticated user with reserved extra key", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -679,14 +675,164 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "valid-key": {"valid-value"}, "foo.impersonation-proxy.concierge.pinniped.dev": {"still-valid-value"}, }, - }, nil), + }, nil, ""), + wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", + wantHTTPStatus: http.StatusInternalServerError, + }, + { + name: "authenticated user with UID but no bearer token", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: testUser, + UID: "fancy-uid", + Groups: testGroups, + Extra: map[string]authenticationv1.ExtraValue{ + "extra-1": {"some", "extra", "stuff"}, + "extra-2": {"some", "more", "extra", "stuff"}, + }, + }, + ImpersonatedUser: nil, + }, + "", + ), + authenticator: nil, + wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", + wantHTTPStatus: http.StatusInternalServerError, + }, + { + name: "authenticated user with UID and bearer token and nested impersonation", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: &authenticationv1.UserInfo{}, + }, + "token-from-user-nested", + ), + authenticator: nil, + wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", + wantHTTPStatus: http.StatusInternalServerError, + }, + { + name: "authenticated user with UID and bearer token results in error", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: nil, + }, + "some-non-empty-token", + ), + authenticator: testTokenAuthenticator(t, "", nil, constable.Error("some err")), + wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", + wantHTTPStatus: http.StatusInternalServerError, + }, + { + name: "authenticated user with UID and bearer token does not authenticate", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: nil, + }, + "this-token-does-not-work", + ), + authenticator: testTokenAuthenticator(t, "some-other-token-works", nil, nil), + wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", + wantHTTPStatus: http.StatusInternalServerError, + }, + { + name: "authenticated user with UID and bearer token authenticates as different user", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: nil, + }, + "this-token-does-work", + ), + authenticator: testTokenAuthenticator(t, "this-token-does-work", &user.DefaultInfo{Name: "someone-else"}, nil), wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n", wantHTTPStatus: http.StatusInternalServerError, }, // happy path { name: "authenticated user", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -699,7 +845,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { Name: testUser, Groups: testGroups, Extra: testExtra, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Extra-1": {"some", "extra", "stuff"}, @@ -717,9 +863,61 @@ func TestImpersonatorHTTPHandler(t *testing.T) { wantHTTPBody: "successful proxied response", wantHTTPStatus: http.StatusOK, }, + { + name: "authenticated user with UID and bearer token", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Accept": {"some-accepted-format"}, + "Accept-Encoding": {"some-accepted-encoding"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: testUser, + UID: "fancy-uid", + Groups: testGroups, + Extra: map[string]authenticationv1.ExtraValue{ + "extra-1": {"some", "extra", "stuff"}, + "extra-2": {"some", "more", "extra", "stuff"}, + }, + }, + ImpersonatedUser: nil, + }, + "token-from-user", + ), + authenticator: testTokenAuthenticator( + t, + "token-from-user", + &user.DefaultInfo{ + Name: testUser, + UID: "fancy-uid", + Groups: testGroups, + Extra: testExtra, + }, + nil, + ), + wantKubeAPIServerRequestHeaders: map[string][]string{ + "Authorization": {"Bearer token-from-user"}, + "User-Agent": {"test-user-agent"}, + "Accept": {"some-accepted-format"}, + "Accept-Encoding": {"some-accepted-encoding"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Other-Header": {"test-header-value-1"}, + }, + wantHTTPBody: "successful proxied response", + wantHTTPStatus: http.StatusOK, + }, { name: "authenticated gke user", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -736,7 +934,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "iam.gke.io/user-assertion": {"ABC"}, "user-assertion.cloud.google.com": {"XYZ"}, }, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"ABC"}, @@ -756,7 +954,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated openshift/openstack user", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -781,7 +979,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "alpha.kubernetes.io/identity/user/domain/id": {"domain-id"}, "alpha.kubernetes.io/identity/user/domain/name": {"domain-name"}, }, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full"}, @@ -805,7 +1003,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with almost reserved key", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -820,7 +1018,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { Extra: map[string][]string{ "foo.iimpersonation-proxy.concierge.pinniped.dev": {"still-valid-value"}, }, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Foo.iimpersonation-Proxy.concierge.pinniped.dev": {"still-valid-value"}, @@ -839,7 +1037,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with almost reserved key and nested impersonation", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -866,6 +1064,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -886,7 +1085,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with nested impersonation", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -912,6 +1111,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -933,7 +1133,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated gke user with nested impersonation", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -959,6 +1159,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -980,7 +1181,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with nested impersonation of gke user", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -1010,6 +1211,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -1031,13 +1233,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "user is authenticated but the kube API request returns an error", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, }, &user.DefaultInfo{ Name: testUser, Groups: testGroups, Extra: testExtra, - }, nil), + }, nil, ""), kubeAPIServerStatusCode: http.StatusNotFound, wantKubeAPIServerRequestHeaders: map[string][]string{ "Accept-Encoding": {"gzip"}, // because the rest client used in this test does not disable compression @@ -1095,6 +1297,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { metav1.AddToGroupVersion(scheme, metav1.Unversioned) codecs := serializer.NewCodecFactory(scheme) serverConfig := genericapiserver.NewRecommendedConfig(codecs) + serverConfig.Authentication.Authenticator = tt.authenticator w := httptest.NewRecorder() @@ -1137,6 +1340,83 @@ func TestImpersonatorHTTPHandler(t *testing.T) { } } +func newRequest(t *testing.T, h http.Header, userInfo user.Info, event *auditinternal.Event, token string) *http.Request { + t.Helper() + + validURL, err := url.Parse("http://pinniped.dev/blah") + require.NoError(t, err) + + ctx := context.Background() + + if userInfo != nil { + ctx = request.WithUser(ctx, userInfo) + } + + ae := &auditinternal.Event{Level: auditinternal.LevelMetadata} + if event != nil { + ae = event + } + ctx = request.WithAuditEvent(ctx, ae) + + reqInfo := &request.RequestInfo{ + IsResourceRequest: false, + Path: validURL.Path, + Verb: "get", + } + ctx = request.WithRequestInfo(ctx, reqInfo) + + ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"must-be-ignored"}) + + if len(token) != 0 { + ctx = context.WithValue(ctx, tokenKey, token) + } + + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(time.Hour)) + t.Cleanup(cancel) + + r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil) + require.NoError(t, err) + + r.Header = h + + return r +} + +func testTokenAuthenticator(t *testing.T, token string, userInfo user.Info, err error) authenticator.Request { + t.Helper() + + return authenticator.RequestFunc(func(r *http.Request) (*authenticator.Response, bool, error) { + if auds, ok := authenticator.AudiencesFrom(r.Context()); ok || len(auds) != 0 { + t.Errorf("unexpected audiences on request: %v", auds) + } + + if ctxToken := tokenFrom(r.Context()); len(ctxToken) != 0 { + t.Errorf("unexpected token on request: %v", ctxToken) + } + + if _, ok := r.Context().Deadline(); !ok { + t.Error("request should always have deadline") + } + + if err != nil { + return nil, false, err + } + + var reqToken string + _, _, _ = bearertoken.New(authenticator.TokenFunc(func(_ context.Context, token string) (*authenticator.Response, bool, error) { + reqToken = token + return nil, false, nil + })).AuthenticateRequest(r) + + if reqToken != token { + return nil, false, nil + } + + return &authenticator.Response{User: userInfo}, true, nil + }) +} + type clientCert struct { certPEM, keyPEM []byte } @@ -1242,7 +1522,9 @@ func Test_deleteKnownImpersonationHeaders(t *testing.T) { inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background()) inputReqCopy := inputReq.Clone(inputReq.Context()) + var called bool delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) { + called = true require.Nil(t, w) // assert only headers mutated @@ -1259,6 +1541,85 @@ func Test_deleteKnownImpersonationHeaders(t *testing.T) { deleteKnownImpersonationHeaders(delegate).ServeHTTP(nil, inputReq) require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred + require.True(t, called) + }) + } +} + +func Test_withBearerTokenPreservation(t *testing.T) { + tests := []struct { + name string + headers http.Header + want string + }{ + { + name: "has bearer token", + headers: map[string][]string{ + "Authorization": {"Bearer thingy"}, + }, + want: "thingy", + }, + { + name: "has bearer token but too many preceding spaces", + headers: map[string][]string{ + "Authorization": {"Bearer 1"}, + }, + want: "", + }, + { + name: "has bearer token with space, only keeps first part", + headers: map[string][]string{ + "Authorization": {"Bearer panda man"}, + }, + want: "panda", + }, + { + name: "has bearer token with surrounding whitespace", + headers: map[string][]string{ + "Authorization": {" Bearer cool beans "}, + }, + want: "cool", + }, + { + name: "has multiple bearer tokens", + headers: map[string][]string{ + "Authorization": {"Bearer this thing", "what does this mean?"}, + }, + want: "this", + }, + { + name: "no bearer token", + headers: map[string][]string{ + "Not-Authorization": {"Bearer not a token"}, + }, + want: "", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background()) + inputReqCopy := inputReq.Clone(inputReq.Context()) + + var called bool + delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) { + called = true + require.Nil(t, w) + + // assert only context is mutated + outputReqCopy := outputReq.Clone(inputReq.Context()) + require.Equal(t, inputReqCopy, outputReqCopy) + + require.Equal(t, tt.want, tokenFrom(outputReq.Context())) + + if len(tt.want) == 0 { + require.True(t, inputReq == outputReq, "expect req to passed through when no token expected") + } + }) + + withBearerTokenPreservation(delegate).ServeHTTP(nil, inputReq) + require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred + require.True(t, called) }) } } diff --git a/internal/config/concierge/config.go b/internal/config/concierge/config.go index b4d4c9a5..610caa7e 100644 --- a/internal/config/concierge/config.go +++ b/internal/config/concierge/config.go @@ -122,6 +122,9 @@ func validateNames(names *NamesConfigSpec) error { if names.ImpersonationSignerSecret == "" { missingNames = append(missingNames, "impersonationSignerSecret") } + if names.AgentServiceAccount == "" { + missingNames = append(missingNames, "agentServiceAccount") + } if len(missingNames) > 0 { return constable.Error("missing required names: " + strings.Join(missingNames, ", ")) } diff --git a/internal/config/concierge/config_test.go b/internal/config/concierge/config_test.go index 0034d06b..1101d2d5 100644 --- a/internal/config/concierge/config_test.go +++ b/internal/config/concierge/config_test.go @@ -43,6 +43,7 @@ func TestFromPath(t *testing.T) { impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value labels: myLabelKey1: myLabelValue1 myLabelKey2: myLabelValue2 @@ -72,6 +73,7 @@ func TestFromPath(t *testing.T) { ImpersonationTLSCertificateSecret: "impersonationTLSCertificateSecret-value", ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value", ImpersonationSignerSecret: "impersonationSignerSecret-value", + AgentServiceAccount: "agentServiceAccount-value", }, Labels: map[string]string{ "myLabelKey1": "myLabelValue1", @@ -98,6 +100,7 @@ func TestFromPath(t *testing.T) { impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantConfig: &Config{ DiscoveryInfo: DiscoveryInfoSpec{ @@ -119,6 +122,7 @@ func TestFromPath(t *testing.T) { ImpersonationTLSCertificateSecret: "impersonationTLSCertificateSecret-value", ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value", ImpersonationSignerSecret: "impersonationSignerSecret-value", + AgentServiceAccount: "agentServiceAccount-value", }, Labels: map[string]string{}, KubeCertAgentConfig: KubeCertAgentSpec{ @@ -133,7 +137,7 @@ func TestFromPath(t *testing.T) { wantError: "validate names: missing required names: servingCertificateSecret, credentialIssuer, " + "apiService, impersonationConfigMap, impersonationLoadBalancerService, " + "impersonationTLSCertificateSecret, impersonationCACertificateSecret, " + - "impersonationSignerSecret", + "impersonationSignerSecret, agentServiceAccount", }, { name: "Missing apiService name", @@ -147,6 +151,7 @@ func TestFromPath(t *testing.T) { impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: apiService", }, @@ -162,6 +167,7 @@ func TestFromPath(t *testing.T) { impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: credentialIssuer", }, @@ -177,6 +183,7 @@ func TestFromPath(t *testing.T) { impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: servingCertificateSecret", }, @@ -192,6 +199,7 @@ func TestFromPath(t *testing.T) { impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: impersonationConfigMap", }, @@ -207,6 +215,7 @@ func TestFromPath(t *testing.T) { impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: impersonationLoadBalancerService", }, @@ -222,6 +231,7 @@ func TestFromPath(t *testing.T) { impersonationLoadBalancerService: impersonationLoadBalancerService-value impersonationCACertificateSecret: impersonationCACertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: impersonationTLSCertificateSecret", }, @@ -237,6 +247,7 @@ func TestFromPath(t *testing.T) { impersonationLoadBalancerService: impersonationLoadBalancerService-value impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: impersonationCACertificateSecret", }, @@ -252,6 +263,7 @@ func TestFromPath(t *testing.T) { impersonationLoadBalancerService: impersonationLoadBalancerService-value impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value impersonationCACertificateSecret: impersonationCACertificateSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: impersonationSignerSecret", }, @@ -265,6 +277,7 @@ func TestFromPath(t *testing.T) { apiService: pinniped-api impersonationLoadBalancerService: impersonationLoadBalancerService-value impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value `), wantError: "validate names: missing required names: impersonationConfigMap, " + "impersonationTLSCertificateSecret, impersonationCACertificateSecret", diff --git a/internal/config/concierge/types.go b/internal/config/concierge/types.go index cfec621e..ecd36d0a 100644 --- a/internal/config/concierge/types.go +++ b/internal/config/concierge/types.go @@ -41,6 +41,7 @@ type NamesConfigSpec struct { ImpersonationTLSCertificateSecret string `json:"impersonationTLSCertificateSecret"` ImpersonationCACertificateSecret string `json:"impersonationCACertificateSecret"` ImpersonationSignerSecret string `json:"impersonationSignerSecret"` + AgentServiceAccount string `json:"agentServiceAccount"` } // ServingCertificateConfigSpec contains the configuration knobs for the API's diff --git a/internal/controller/authenticator/authenticator.go b/internal/controller/authenticator/authenticator.go index 14af741d..2ac68500 100644 --- a/internal/controller/authenticator/authenticator.go +++ b/internal/controller/authenticator/authenticator.go @@ -5,7 +5,9 @@ package authenticator import ( + "crypto/x509" "encoding/base64" + "fmt" auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" ) @@ -22,8 +24,18 @@ type Closer interface { // 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 { + if spec == nil || len(spec.CertificateAuthorityData) == 0 { return nil, nil } - return base64.StdEncoding.DecodeString(spec.CertificateAuthorityData) + + pem, err := base64.StdEncoding.DecodeString(spec.CertificateAuthorityData) + if err != nil { + return nil, err + } + + if ok := x509.NewCertPool().AppendCertsFromPEM(pem); !ok { + return nil, fmt.Errorf("certificateAuthorityData is not valid PEM") + } + + return pem, nil } diff --git a/internal/controller/authenticator/authncache/cache.go b/internal/controller/authenticator/authncache/cache.go index 5aa31004..14366c39 100644 --- a/internal/controller/authenticator/authncache/cache.go +++ b/internal/controller/authenticator/authncache/cache.go @@ -16,6 +16,7 @@ import ( loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/valuelesscontext" ) // ErrNoSuchAuthenticator is returned by Cache.AuthenticateTokenCredentialRequest() when the requested authenticator is not configured. @@ -101,7 +102,7 @@ func (c *Cache) AuthenticateTokenCredentialRequest(ctx context.Context, req *log // 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} + ctx = valuelesscontext.New(ctx) // Call the selected authenticator. resp, authenticated, err := val.AuthenticateToken(ctx, req.Spec.Token) @@ -119,7 +120,3 @@ func (c *Cache) AuthenticateTokenCredentialRequest(ctx context.Context, req *log } return respUser, nil } - -type valuelessContext struct{ context.Context } - -func (valuelessContext) Value(interface{}) interface{} { return nil } diff --git a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go index 4d6a0744..aae172d9 100644 --- a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go +++ b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go @@ -135,6 +135,15 @@ func TestNewWebhookAuthenticator(t *testing.T) { require.EqualError(t, err, "invalid TLS configuration: illegal base64 data at input byte 7") }) + t.Run("invalid pem data", func(t *testing.T) { + res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://example.com", + TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("bad data"))}, + }, ioutil.TempFile, clientcmd.WriteToFile) + require.Nil(t, res) + require.EqualError(t, err, "invalid TLS configuration: certificateAuthorityData is not valid PEM") + }) + t.Run("valid config with no TLS spec", func(t *testing.T) { res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{ Endpoint: "https://example.com", diff --git a/internal/controller/kubecertagent/kubecertagent.go b/internal/controller/kubecertagent/kubecertagent.go index 478d27fe..e45bf850 100644 --- a/internal/controller/kubecertagent/kubecertagent.go +++ b/internal/controller/kubecertagent/kubecertagent.go @@ -64,6 +64,9 @@ type AgentConfig struct { // NamePrefix will be prefixed to all agent pod names. NamePrefix string + // ServiceAccountName is the service account under which to run the agent pods. + ServiceAccountName string + // ContainerImagePullSecrets is a list of names of Kubernetes Secret objects that will be used as // ImagePullSecrets on the kube-cert-agent pods. ContainerImagePullSecrets []string @@ -472,6 +475,7 @@ func (c *agentController) newAgentDeployment(controllerManagerPod *corev1.Pod) * RestartPolicy: corev1.RestartPolicyAlways, NodeSelector: controllerManagerPod.Spec.NodeSelector, AutomountServiceAccountToken: pointer.BoolPtr(false), + ServiceAccountName: c.cfg.ServiceAccountName, NodeName: controllerManagerPod.Spec.NodeName, Tolerations: controllerManagerPod.Spec.Tolerations, // We need to run the agent pod as root since the file permissions diff --git a/internal/controller/kubecertagent/kubecertagent_test.go b/internal/controller/kubecertagent/kubecertagent_test.go index 4a1a17f9..73d0a749 100644 --- a/internal/controller/kubecertagent/kubecertagent_test.go +++ b/internal/controller/kubecertagent/kubecertagent_test.go @@ -123,6 +123,7 @@ func TestAgentController(t *testing.T) { }}, RestartPolicy: corev1.RestartPolicyAlways, TerminationGracePeriodSeconds: pointer.Int64Ptr(0), + ServiceAccountName: "test-service-account-name", AutomountServiceAccountToken: pointer.BoolPtr(false), SecurityContext: &corev1.PodSecurityContext{ RunAsUser: pointer.Int64Ptr(0), @@ -672,6 +673,7 @@ func TestAgentController(t *testing.T) { AgentConfig{ Namespace: "concierge", ContainerImage: "pinniped-server-image", + ServiceAccountName: "test-service-account-name", NamePrefix: "pinniped-concierge-kube-cert-agent-", ContainerImagePullSecrets: []string{"pinniped-image-pull-secret"}, CredentialIssuerName: "pinniped-concierge-config", diff --git a/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher.go b/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher.go index 7fa0d541..609e1679 100644 --- a/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher.go +++ b/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "sort" + "strings" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -269,11 +270,17 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1 discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer) if err != nil { + const klogLevelTrace = 6 + c.log.V(klogLevelTrace).WithValues( + "namespace", upstream.Namespace, + "name", upstream.Name, + "issuer", upstream.Spec.Issuer, + ).Error(err, "failed to perform OIDC discovery") return &v1alpha1.Condition{ Type: typeOIDCDiscoverySucceeded, Status: v1alpha1.ConditionFalse, Reason: reasonUnreachable, - Message: fmt.Sprintf("failed to perform OIDC discovery against %q", upstream.Spec.Issuer), + Message: fmt.Sprintf("failed to perform OIDC discovery against %q:\n%s", upstream.Spec.Issuer, truncateNonOIDCErr(err)), } } @@ -428,3 +435,14 @@ func (*oidcWatcherController) computeScopes(additionalScopes []string) []string sort.Strings(scopes) return scopes } + +func truncateNonOIDCErr(err error) string { + const max = 100 + msg := err.Error() + + if len(msg) <= max || strings.HasPrefix(msg, "oidc:") { + return msg + } + + return msg[:max] + fmt.Sprintf(" [truncated %d chars]", len(msg)-max) +} diff --git a/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher_test.go b/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher_test.go index 44b2a57e..06b82c02 100644 --- a/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/upstreamwatcher/oidc_upstream_watcher_test.go @@ -370,7 +370,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: "invalid-url", + Issuer: "invalid-url-that-is-really-really-long", Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, }, @@ -382,9 +382,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ + `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="Get \"invalid-url-that-is-really-really-long/.well-known/openid-configuration\": unsupported protocol scheme \"\"" "issuer"="invalid-url-that-is-really-really-long" "name"="test-name" "namespace"="test-namespace"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"invalid-url\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, - `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"invalid-url\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long\":\nGet \"invalid-url-that-is-really-really-long/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long\":\nGet \"invalid-url-that-is-really-really-long/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -404,7 +405,8 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: "False", LastTransitionTime: now, Reason: "Unreachable", - Message: `failed to perform OIDC discovery against "invalid-url"`, + Message: `failed to perform OIDC discovery against "invalid-url-that-is-really-really-long": +Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": unsupported protocol [truncated 9 chars]`, }, }, }, @@ -600,6 +602,151 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { }, }}, }, + { + name: "existing valid upstream with trailing slash", + inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Spec: v1alpha1.OIDCIdentityProviderSpec{ + Issuer: testIssuerURL + "/ends-with-slash/", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, + }, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"}, + }, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantLogs: []string{ + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ + &oidctestutil.TestUpstreamOIDCIdentityProvider{ + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: testExpectedScopes, + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + }, + }, + wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234}, + }, + }, + }}, + }, + { + name: "issuer is invalid URL, missing trailing slash", + inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + Spec: v1alpha1.OIDCIdentityProviderSpec{ + Issuer: testIssuerURL + "/ends-with-slash", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantLogs: []string{ + `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "issuer"="` + testIssuerURL + `/ends-with-slash" "name"="test-name" "namespace"="test-namespace"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, + wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "ClientCredentialsValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "loaded client credentials", + }, + { + Type: "OIDCDiscoverySucceeded", + Status: "False", + LastTransitionTime: now, + Reason: "Unreachable", + Message: `failed to perform OIDC discovery against "` + testIssuerURL + `/ends-with-slash": +oidc: issuer did not match the issuer returned by provider, expected "` + testIssuerURL + `/ends-with-slash" got "` + testIssuerURL + `/ends-with-slash/"`, + }, + }, + }, + }}, + }, + { + name: "issuer is invalid URL, extra trailing slash", + inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + Spec: v1alpha1.OIDCIdentityProviderSpec{ + Issuer: testIssuerURL + "/", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantLogs: []string{ + `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "issuer"="` + testIssuerURL + `/" "name"="test-name" "namespace"="test-namespace"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, + wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "ClientCredentialsValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "loaded client credentials", + }, + { + Type: "OIDCDiscoverySucceeded", + Status: "False", + LastTransitionTime: now, + Reason: "Unreachable", + Message: `failed to perform OIDC discovery against "` + testIssuerURL + `/": +oidc: issuer did not match the issuer returned by provider, expected "` + testIssuerURL + `/" got "` + testIssuerURL + `"`, + }, + }, + }, + }}, + }, } for _, tt := range tests { tt := tt @@ -728,5 +875,25 @@ func newTestIssuer(t *testing.T) (string, string) { }) }) + // handle the four issuer with trailing slash configs + + // valid case in= out= + // handled above at the root of testURL + + // valid case in=/ out=/ + mux.HandleFunc("/ends-with-slash/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&providerJSON{ + Issuer: testURL + "/ends-with-slash/", + AuthURL: "https://example.com/authorize", + }) + }) + + // invalid case in= out=/ + // can be tested using /ends-with-slash/ endpoint + + // invalid case in=/ out= + // can be tested using root endpoint + return caBundlePEM, testURL } diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index e2d35d30..a6d7ebce 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisorstorage @@ -59,7 +59,7 @@ func GarbageCollectorController( return isSecretWithGCAnnotation(oldObj) || isSecretWithGCAnnotation(newObj) }, DeleteFunc: func(obj metav1.Object) bool { return false }, // ignore all deletes - ParentFunc: nil, + ParentFunc: pinnipedcontroller.SingletonQueue(), }, controllerlib.InformerOption{}, ), @@ -67,16 +67,20 @@ func GarbageCollectorController( } func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { + // make sure we have a consistent, static meaning for the current time during the sync loop + frozenClock := clock.NewFakeClock(c.clock.Now()) + // The Sync method is triggered upon any change to any Secret, which would make this // controller too chatty, so it rate limits itself to a more reasonable interval. // Note that even during a period when no secrets are changing, it will still run // at the informer's full-resync interval (as long as there are some secrets). - if c.clock.Now().Sub(c.timeOfMostRecentSweep) < minimumRepeatInterval { + if since := frozenClock.Since(c.timeOfMostRecentSweep); since < minimumRepeatInterval { + ctx.Queue.AddAfter(ctx.Key, minimumRepeatInterval-since) return nil } plog.Info("starting storage garbage collection sweep") - c.timeOfMostRecentSweep = c.clock.Now() + c.timeOfMostRecentSweep = frozenClock.Now() listOfSecrets, err := c.secretInformer.Lister().List(labels.Everything()) if err != nil { @@ -97,7 +101,7 @@ func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { continue } - if garbageCollectAfterTime.Before(c.clock.Now()) { + if garbageCollectAfterTime.Before(frozenClock.Now()) { err = c.kubeClient.CoreV1().Secrets(secret.Namespace).Delete(ctx.Context, secret.Name, metav1.DeleteOptions{}) if err != nil { plog.WarningErr("failed to garbage collect resource", err, logKV(secret)) diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index a5606cf8..ac64acf3 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -66,6 +66,10 @@ func TestGarbageCollectorControllerInformerFilters(t *testing.T) { r.True(subject.Update(secretWithAnnotation, otherSecret)) r.True(subject.Update(otherSecret, secretWithAnnotation)) }) + + it("returns the same singleton key", func() { + r.Equal(controllerlib.Key{}, subject.Parent(secretWithAnnotation)) + }) }) when("any Secret with the required annotation is deleted", func() { @@ -136,9 +140,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) { Context: cancelContext, Name: subject.Name(), Key: controllerlib.Key{ - Namespace: "", - Name: "", + Namespace: "foo", + Name: "bar", }, + Queue: &testQueue{t: t}, } // Must start informers before calling TestRunSynchronously() @@ -262,16 +267,23 @@ func TestGarbageCollectorControllerSync(t *testing.T) { // Run sync once with the current time set to frozenTime. r.NoError(controllerlib.TestSync(t, subject, *syncContext)) require.Empty(t, kubeClient.Actions()) + r.False(syncContext.Queue.(*testQueue).called) // Run sync again when not enough time has passed since the most recent run, so no delete // operations should happen even though there is a expired secret now. fakeClock.Step(29 * time.Second) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) require.Empty(t, kubeClient.Actions()) + r.True(syncContext.Queue.(*testQueue).called) + r.Equal(controllerlib.Key{Namespace: "foo", Name: "bar"}, syncContext.Queue.(*testQueue).key) // assert key is passed through + r.Equal(time.Second, syncContext.Queue.(*testQueue).duration) // assert that we get the exact requeue time + + syncContext.Queue = &testQueue{t: t} // reset the queue for the next sync // Step to the exact threshold and run Sync again. Now we are past the rate limiting period. - fakeClock.Step(1*time.Second + 1*time.Millisecond) + fakeClock.Step(time.Second) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) + r.False(syncContext.Queue.(*testQueue).called) // It should have deleted the expired secret. r.ElementsMatch( @@ -381,3 +393,23 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }) }, spec.Parallel(), spec.Report(report.Terminal{})) } + +type testQueue struct { + t *testing.T + + called bool + key controllerlib.Key + duration time.Duration + + controllerlib.Queue // panic if any other methods called +} + +func (q *testQueue) AddAfter(key controllerlib.Key, duration time.Duration) { + q.t.Helper() + + require.False(q.t, q.called, "AddAfter should only be called once") + + q.called = true + q.key = key + q.duration = duration +} diff --git a/internal/controllermanager/prepare_controllers.go b/internal/controllermanager/prepare_controllers.go index 8990a4ec..acd5bee7 100644 --- a/internal/controllermanager/prepare_controllers.go +++ b/internal/controllermanager/prepare_controllers.go @@ -121,6 +121,7 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) { agentConfig := kubecertagent.AgentConfig{ Namespace: c.ServerInstallationInfo.Namespace, + ServiceAccountName: c.NamesConfig.AgentServiceAccount, ContainerImage: *c.KubeCertAgentConfig.Image, NamePrefix: *c.KubeCertAgentConfig.NamePrefix, ContainerImagePullSecrets: c.KubeCertAgentConfig.ImagePullSecrets, diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 82fe511d..3be60dcb 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -165,13 +165,8 @@ type TimeoutsConfiguration struct { OIDCSessionStorageLifetime time.Duration // AccessTokenSessionStorageLifetime is the length of time after which an access token's session data is allowed - // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid. - // Therefore, this can be just slightly longer than the AccessTokenLifespan. Access tokens are handed back to - // the token endpoint for the token exchange use case. During a token exchange, if the access token is expired - // and still exists in storage, then the endpoint will be able to give a slightly more specific error message, - // rather than a more generic error that is returned when the token does not exist. If this is desirable, then - // the AccessTokenSessionStorageLifetime can be made to be significantly larger than AccessTokenLifespan, at the - // cost of slower cleanup. + // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid + // or else the refresh flow will not work properly. So this must be longer than RefreshTokenLifespan. AccessTokenSessionStorageLifetime time.Duration // RefreshTokenSessionStorageLifetime is the length of time after which a refresh token's session data is allowed @@ -187,7 +182,7 @@ type TimeoutsConfiguration struct { // Get the defaults for the Supervisor server. func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration { - accessTokenLifespan := 15 * time.Minute + accessTokenLifespan := 2 * time.Minute authorizationCodeLifespan := 10 * time.Minute refreshTokenLifespan := 9 * time.Hour @@ -200,7 +195,7 @@ func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration { AuthorizationCodeSessionStorageLifetime: authorizationCodeLifespan + refreshTokenLifespan, PKCESessionStorageLifetime: authorizationCodeLifespan + (1 * time.Minute), OIDCSessionStorageLifetime: authorizationCodeLifespan + (1 * time.Minute), - AccessTokenSessionStorageLifetime: accessTokenLifespan + (1 * time.Minute), + AccessTokenSessionStorageLifetime: refreshTokenLifespan + accessTokenLifespan, RefreshTokenSessionStorageLifetime: refreshTokenLifespan + accessTokenLifespan, } } diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index dee77cd8..61006f84 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -61,8 +61,8 @@ const ( hmacSecret = "this needs to be at least 32 characters to meet entropy requirements" authCodeExpirationSeconds = 10 * 60 // Current, we set our auth code expiration to 10 minutes - accessTokenExpirationSeconds = 15 * 60 // Currently, we set our access token expiration to 15 minutes - idTokenExpirationSeconds = 15 * 60 // Currently, we set our ID token expiration to 15 minutes + accessTokenExpirationSeconds = 2 * 60 // Currently, we set our access token expiration to 2 minutes + idTokenExpirationSeconds = 2 * 60 // Currently, we set our ID token expiration to 2 minutes timeComparisonFudgeSeconds = 15 ) diff --git a/internal/plog/plog.go b/internal/plog/plog.go index b38e7179..cf6bc80b 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package plog implements a thin layer over klog to help enforce pinniped's logging convention. @@ -26,56 +26,157 @@ // act of desperation to determine why the system is broken. package plog -import "k8s.io/klog/v2" +import ( + "k8s.io/klog/v2" +) const errorKey = "error" -// Use Error to log an unexpected system error. -func Error(msg string, err error, keysAndValues ...interface{}) { - klog.ErrorS(err, msg, keysAndValues...) +type _ interface { + Error(msg string, err error, keysAndValues ...interface{}) + Warning(msg string, keysAndValues ...interface{}) + WarningErr(msg string, err error, keysAndValues ...interface{}) + Info(msg string, keysAndValues ...interface{}) + InfoErr(msg string, err error, keysAndValues ...interface{}) + Debug(msg string, keysAndValues ...interface{}) + DebugErr(msg string, err error, keysAndValues ...interface{}) + Trace(msg string, keysAndValues ...interface{}) + TraceErr(msg string, err error, keysAndValues ...interface{}) + All(msg string, keysAndValues ...interface{}) } -func Warning(msg string, keysAndValues ...interface{}) { +type PLogger struct { + prefix string + depth int +} + +func New(prefix string) PLogger { + return PLogger{ + depth: 0, + prefix: prefix, + } +} + +func (p *PLogger) Error(msg string, err error, keysAndValues ...interface{}) { + klog.ErrorSDepth(p.depth+1, err, p.prefix+msg, keysAndValues...) +} + +func (p *PLogger) warningDepth(msg string, depth int, keysAndValues ...interface{}) { // klog's structured logging has no concept of a warning (i.e. no WarningS function) // Thus we use info at log level zero as a proxy // klog's info logs have an I prefix and its warning logs have a W prefix // Since we lose the W prefix by using InfoS, just add a key to make these easier to find keysAndValues = append([]interface{}{"warning", "true"}, keysAndValues...) - klog.V(klogLevelWarning).InfoS(msg, keysAndValues...) + if klog.V(klogLevelWarning).Enabled() { + klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...) + } +} + +func (p *PLogger) Warning(msg string, keysAndValues ...interface{}) { + p.warningDepth(msg, p.depth+1, keysAndValues...) +} + +// Use WarningErr to issue a Warning message with an error object as part of the message. +func (p *PLogger) WarningErr(msg string, err error, keysAndValues ...interface{}) { + p.warningDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...) +} + +func (p *PLogger) infoDepth(msg string, depth int, keysAndValues ...interface{}) { + if klog.V(klogLevelInfo).Enabled() { + klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...) + } +} + +func (p *PLogger) Info(msg string, keysAndValues ...interface{}) { + p.infoDepth(msg, p.depth+1, keysAndValues...) +} + +// Use InfoErr to log an expected error, e.g. validation failure of an http parameter. +func (p *PLogger) InfoErr(msg string, err error, keysAndValues ...interface{}) { + p.infoDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...) +} + +func (p *PLogger) debugDepth(msg string, depth int, keysAndValues ...interface{}) { + if klog.V(klogLevelDebug).Enabled() { + klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...) + } +} + +func (p *PLogger) Debug(msg string, keysAndValues ...interface{}) { + p.debugDepth(msg, p.depth+1, keysAndValues...) +} + +// Use DebugErr to issue a Debug message with an error object as part of the message. +func (p *PLogger) DebugErr(msg string, err error, keysAndValues ...interface{}) { + p.debugDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...) +} + +func (p *PLogger) traceDepth(msg string, depth int, keysAndValues ...interface{}) { + if klog.V(klogLevelTrace).Enabled() { + klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...) + } +} + +func (p *PLogger) Trace(msg string, keysAndValues ...interface{}) { + p.traceDepth(msg, p.depth+1, keysAndValues...) +} + +// Use TraceErr to issue a Trace message with an error object as part of the message. +func (p *PLogger) TraceErr(msg string, err error, keysAndValues ...interface{}) { + p.traceDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...) +} + +func (p *PLogger) All(msg string, keysAndValues ...interface{}) { + if klog.V(klogLevelAll).Enabled() { + klog.InfoSDepth(p.depth+1, p.prefix+msg, keysAndValues...) + } +} + +var pLogger = PLogger{ //nolint:gochecknoglobals + depth: 1, +} + +// Use Error to log an unexpected system error. +func Error(msg string, err error, keysAndValues ...interface{}) { + pLogger.Error(msg, err, keysAndValues...) +} + +func Warning(msg string, keysAndValues ...interface{}) { + pLogger.Warning(msg, keysAndValues...) } // Use WarningErr to issue a Warning message with an error object as part of the message. func WarningErr(msg string, err error, keysAndValues ...interface{}) { - Warning(msg, append([]interface{}{errorKey, err}, keysAndValues...)...) + pLogger.WarningErr(msg, err, keysAndValues...) } func Info(msg string, keysAndValues ...interface{}) { - klog.V(klogLevelInfo).InfoS(msg, keysAndValues...) + pLogger.Info(msg, keysAndValues...) } // Use InfoErr to log an expected error, e.g. validation failure of an http parameter. func InfoErr(msg string, err error, keysAndValues ...interface{}) { - Info(msg, append([]interface{}{errorKey, err}, keysAndValues...)...) + pLogger.InfoErr(msg, err, keysAndValues...) } func Debug(msg string, keysAndValues ...interface{}) { - klog.V(klogLevelDebug).InfoS(msg, keysAndValues...) + pLogger.Debug(msg, keysAndValues...) } // Use DebugErr to issue a Debug message with an error object as part of the message. func DebugErr(msg string, err error, keysAndValues ...interface{}) { - Debug(msg, append([]interface{}{errorKey, err}, keysAndValues...)...) + pLogger.DebugErr(msg, err, keysAndValues...) } func Trace(msg string, keysAndValues ...interface{}) { - klog.V(klogLevelTrace).InfoS(msg, keysAndValues...) + pLogger.Trace(msg, keysAndValues...) } // Use TraceErr to issue a Trace message with an error object as part of the message. func TraceErr(msg string, err error, keysAndValues ...interface{}) { - Trace(msg, append([]interface{}{errorKey, err}, keysAndValues...)...) + pLogger.TraceErr(msg, err, keysAndValues...) } func All(msg string, keysAndValues ...interface{}) { - klog.V(klogLevelAll).InfoS(msg, keysAndValues...) + pLogger.All(msg, keysAndValues...) } diff --git a/internal/valuelesscontext/valuelesscontext.go b/internal/valuelesscontext/valuelesscontext.go new file mode 100644 index 00000000..93c90a5e --- /dev/null +++ b/internal/valuelesscontext/valuelesscontext.go @@ -0,0 +1,14 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package valuelesscontext + +import "context" + +func New(ctx context.Context) context.Context { + return valuelessContext{Context: ctx} +} + +type valuelessContext struct{ context.Context } + +func (valuelessContext) Value(interface{}) interface{} { return nil } diff --git a/internal/valuelesscontext/valuelesscontext_test.go b/internal/valuelesscontext/valuelesscontext_test.go new file mode 100644 index 00000000..ba593dfe --- /dev/null +++ b/internal/valuelesscontext/valuelesscontext_test.go @@ -0,0 +1,242 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package valuelesscontext + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" +) + +func TestNew(t *testing.T) { + t.Parallel() + + type contextKey int + + tests := []struct { + name string + f func(*testing.T, context.Context) context.Context + wantReg, wantNew, wantBoth func(*testing.T, context.Context) + }{ + { + name: "empty context", + f: func(t *testing.T, ctx context.Context) context.Context { + return ctx + }, + wantReg: func(t *testing.T, ctx context.Context) {}, + wantNew: func(t *testing.T, ctx context.Context) {}, + wantBoth: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.False(t, ok) + require.Nil(t, auds) + + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.False(t, ok) + require.Zero(t, val) + + deadline, ok := ctx.Deadline() + require.False(t, ok) + require.Zero(t, deadline) + + require.Nil(t, ctx.Done()) + + require.NoError(t, ctx.Err()) + }, + }, + { + name: "context with audience", + f: func(t *testing.T, ctx context.Context) context.Context { + return authenticator.WithAudiences(ctx, authenticator.Audiences{"1", "2"}) + }, + wantReg: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.True(t, ok) + require.Equal(t, authenticator.Audiences{"1", "2"}, auds) + }, + wantNew: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.False(t, ok) + require.Nil(t, auds) + }, + wantBoth: func(t *testing.T, ctx context.Context) { + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.False(t, ok) + require.Zero(t, val) + + deadline, ok := ctx.Deadline() + require.False(t, ok) + require.Zero(t, deadline) + + require.Nil(t, ctx.Done()) + + require.NoError(t, ctx.Err()) + }, + }, + { + name: "context with audience and past deadline", + f: func(t *testing.T, ctx context.Context) context.Context { + ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"3", "4"}) + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Hour)) + t.Cleanup(cancel) + return ctx + }, + wantReg: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.True(t, ok) + require.Equal(t, authenticator.Audiences{"3", "4"}, auds) + }, + wantNew: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.False(t, ok) + require.Nil(t, auds) + }, + wantBoth: func(t *testing.T, ctx context.Context) { + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.False(t, ok) + require.Zero(t, val) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.NotZero(t, deadline) + require.True(t, deadline.Before(time.Now())) + + ch := ctx.Done() + require.NotNil(t, ch) + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Error("expected closed done channel") + } + + require.Equal(t, context.DeadlineExceeded, ctx.Err()) + }, + }, + { + name: "context with audience and custom value and past deadline", + f: func(t *testing.T, ctx context.Context) context.Context { + ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"3", "4"}) + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Hour)) + t.Cleanup(cancel) + ctx = context.WithValue(ctx, contextKey(0xDEADBEEF), "mooo") + return ctx + }, + wantReg: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.True(t, ok) + require.Equal(t, authenticator.Audiences{"3", "4"}, auds) + + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.True(t, ok) + require.Equal(t, "mooo", val) + }, + wantNew: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.False(t, ok) + require.Nil(t, auds) + + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.False(t, ok) + require.Zero(t, val) + }, + wantBoth: func(t *testing.T, ctx context.Context) { + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.NotZero(t, deadline) + require.True(t, deadline.Before(time.Now())) + + ch := ctx.Done() + require.NotNil(t, ch) + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Error("expected closed done channel") + } + + require.Equal(t, context.DeadlineExceeded, ctx.Err()) + }, + }, + { + name: "context with audience and custom value and future deadline", + f: func(t *testing.T, ctx context.Context) context.Context { + ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"3", "4"}) + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(time.Hour)) + t.Cleanup(cancel) + ctx = context.WithValue(ctx, contextKey(0xDEADBEEF), "mooo") + return ctx + }, + wantReg: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.True(t, ok) + require.Equal(t, authenticator.Audiences{"3", "4"}, auds) + + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.True(t, ok) + require.Equal(t, "mooo", val) + }, + wantNew: func(t *testing.T, ctx context.Context) { + auds, ok := authenticator.AudiencesFrom(ctx) + require.False(t, ok) + require.Nil(t, auds) + + val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string) + require.False(t, ok) + require.Zero(t, val) + }, + wantBoth: func(t *testing.T, ctx context.Context) { + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.NotZero(t, deadline) + require.True(t, deadline.After(time.Now())) + + ch := ctx.Done() + require.NotNil(t, ch) + select { + case <-ch: + t.Error("expected not closed done channel") + case <-time.After(3 * time.Second): + } + + require.NoError(t, ctx.Err()) + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := tt.f(t, context.Background()) + + t.Run("reg", func(t *testing.T) { + t.Parallel() + + tt.wantReg(t, ctx) + }) + + t.Run("reg-both", func(t *testing.T) { + t.Parallel() + + tt.wantBoth(t, ctx) + }) + + t.Run("new", func(t *testing.T) { + t.Parallel() + + tt.wantNew(t, New(ctx)) + }) + + t.Run("new-both", func(t *testing.T) { + t.Parallel() + + tt.wantBoth(t, New(ctx)) + }) + }) + } +} diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index 289ef31b..9908b792 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -20,6 +20,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-logr/logr" "github.com/pkg/browser" "golang.org/x/oauth2" "golang.org/x/term" @@ -58,11 +59,14 @@ const ( defaultLDAPPasswordPrompt = "Password: " httpLocationHeaderName = "Location" + + debugLogLevel = 4 ) type handlerState struct { // Basic parameters. ctx context.Context + logger logr.Logger issuer string clientID string scopes []string @@ -117,6 +121,15 @@ func WithContext(ctx context.Context) Option { } } +// WithLogger specifies a PLogger to use with the login. +// If not specified this will default to a new logger. +func WithLogger(logger logr.Logger) Option { + return func(h *handlerState) error { + h.logger = logger + return nil + } +} + // WithListenPort specifies a TCP listen port on localhost, which will be used for the redirect_uri and to handle the // authorization code callback. By default, a random high port will be chosen which requires the authorization server // to support wildcard port numbers as described by https://tools.ietf.org/html/rfc8252#section-7.3: @@ -227,6 +240,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er cache: &nopCache{}, callbackPath: "/callback", ctx: context.Background(), + logger: logr.Discard(), // discard logs unless a logger is specified callbacks: make(chan callbackResult), httpClient: http.DefaultClient, @@ -306,6 +320,7 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { // If the ID token is still valid for a bit, return it immediately and skip the rest of the flow. cached := h.cache.GetToken(cacheKey) if cached != nil && cached.IDToken != nil && time.Until(cached.IDToken.Expiry.Time) > minIDTokenValidity { + h.logger.V(debugLogLevel).Info("Pinniped: Found unexpired cached token.") return cached, nil } @@ -538,6 +553,7 @@ func (h *handlerState) initOIDCDiscovery() error { return nil } + h.logger.V(debugLogLevel).Info("Pinniped: Performing OIDC discovery", "issuer", h.issuer) var err error h.provider, err = oidc.NewProvider(h.ctx, h.issuer) if err != nil { @@ -554,6 +570,7 @@ func (h *handlerState) initOIDCDiscovery() error { } func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidctypes.Token, error) { + h.logger.V(debugLogLevel).Info("Pinniped: Performing RFC8693 token exchange", "requestedAudience", h.requestedAudience) // Perform OIDC discovery. This may have already been performed if there was not a cached base token. if err := h.initOIDCDiscovery(); err != nil { return nil, err @@ -624,6 +641,7 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty } func (h *handlerState) handleRefresh(ctx context.Context, refreshToken *oidctypes.RefreshToken) (*oidctypes.Token, error) { + h.logger.V(debugLogLevel).Info("Pinniped: Refreshing cached token.") refreshSource := h.oauth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken.Token}) refreshed, err := refreshSource.Token() diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index d20a10e1..bf5ded20 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -16,18 +16,22 @@ import ( "testing" "time" + "github.com/go-logr/stdr" + "github.com/coreos/go-oidc/v3/oidc" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/roundtripper" "go.pinniped.dev/internal/mocks/mockupstreamoidcidentityprovider" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/testlogger" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" "go.pinniped.dev/pkg/oidcclient/pkce" @@ -254,6 +258,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo clientID string wantErr string wantToken *oidctypes.Token + wantLogs []string }{ { name: "option error", @@ -318,7 +323,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return WithSessionCache(cache)(h) } }, - wantErr: `could not perform OIDC discovery for "test-issuer": Get "test-issuer/.well-known/openid-configuration": unsupported protocol scheme ""`, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"test-issuer\""}, + wantErr: `could not perform OIDC discovery for "test-issuer": Get "test-issuer/.well-known/openid-configuration": unsupported protocol scheme ""`, }, { name: "session cache hit with valid token", @@ -339,6 +345,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return WithSessionCache(cache)(h) } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\""}, wantToken: &testToken, }, { @@ -346,8 +353,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo opt: func(t *testing.T) Option { return func(h *handlerState) error { return nil } }, - issuer: errorServer.URL, - wantErr: fmt.Sprintf("could not perform OIDC discovery for %q: 500 Internal Server Error: some discovery error\n", errorServer.URL), + issuer: errorServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + errorServer.URL + "\""}, + wantErr: fmt.Sprintf("could not perform OIDC discovery for %q: 500 Internal Server Error: some discovery error\n", errorServer.URL), }, { name: "session cache hit with refreshable token", @@ -386,6 +394,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"", + "\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\""}, wantToken: &testToken, }, { @@ -418,6 +428,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"", + "\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\""}, wantErr: "some validation error", }, { @@ -444,6 +456,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"", + "\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\""}, // Expect this to fall through to the authorization code flow, so it fails here. wantErr: "could not open callback listener: listen tcp: address invalid-listen-address: missing port in address", }, @@ -455,8 +469,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: "could not open callback listener: listen tcp: address invalid-listen-address: missing port in address", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "could not open callback listener: listen tcp: address invalid-listen-address: missing port in address", }, { name: "browser open failure", @@ -465,8 +480,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return fmt.Errorf("some browser open error") }) }, - issuer: successServer.URL, - wantErr: "could not open browser: some browser open error", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "could not open browser: some browser open error", }, { name: "timeout waiting for callback", @@ -482,8 +498,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: "timed out waiting for token callback: context canceled", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "timed out waiting for token callback: context canceled", }, { name: "callback returns error", @@ -498,8 +515,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: "error handling callback: some callback error", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "error handling callback: some callback error", }, { name: "callback returns success", @@ -559,6 +577,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo } }, issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantToken: &testToken, }, { @@ -622,6 +641,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo } }, issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantToken: &testToken, }, { @@ -637,8 +657,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: "error prompting for username: some prompt error", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "error prompting for username: some prompt error", }, { name: "ldap login when prompting for password returns an error", @@ -650,8 +671,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: "error prompting for password: some prompt error", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "error prompting for password: some prompt error", }, { name: "ldap login when there is a problem with parsing the authorize URL", @@ -690,8 +712,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: `could not build authorize request: parse "%?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": invalid URL escape "%"`, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: `could not build authorize request: parse "%?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": invalid URL escape "%"`, }, { name: "ldap login when there is an error calling the authorization endpoint", @@ -701,7 +724,8 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return defaultLDAPTestOpts(t, h, nil, errors.New("some error fetching authorize endpoint")) } }, - issuer: successServer.URL, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `authorization response error: Get "http://` + successServer.Listener.Addr().String() + `/authorize?access_type=offline&client_id=test-client-id&code_challenge=VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g&code_challenge_method=S256&nonce=test-nonce&pinniped_idp_name=some-upstream-name&pinniped_idp_type=ldap&redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback&response_type=code&scope=test-scope&state=test-state": some error fetching authorize endpoint`, }, @@ -713,8 +737,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return defaultLDAPTestOpts(t, h, &http.Response{StatusCode: http.StatusBadGateway, Status: "502 Bad Gateway"}, nil) } }, - issuer: successServer.URL, - wantErr: `error getting authorization: expected to be redirected, but response status was 502 Bad Gateway`, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: `error getting authorization: expected to be redirected, but response status was 502 Bad Gateway`, }, { name: "ldap login when the OIDC provider authorization endpoint redirect has an error and error description", @@ -729,8 +754,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo }, nil) } }, - issuer: successServer.URL, - wantErr: `login failed with code "access_denied": optional-error-description`, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: `login failed with code "access_denied": optional-error-description`, }, { name: "ldap login when the OIDC provider authorization endpoint redirects us to a different server", @@ -745,8 +771,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo }, nil) } }, - issuer: successServer.URL, - wantErr: `error getting authorization: redirected to the wrong location: http://other-server.example.com/callback?code=foo&state=test-state`, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: `error getting authorization: redirected to the wrong location: http://other-server.example.com/callback?code=foo&state=test-state`, }, { name: "ldap login when the OIDC provider authorization endpoint redirect has an error but no error description", @@ -761,8 +788,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo }, nil) } }, - issuer: successServer.URL, - wantErr: `login failed with code "access_denied"`, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: `login failed with code "access_denied"`, }, { name: "ldap login when the OIDC provider authorization endpoint redirect has the wrong state value", @@ -775,8 +803,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo }, nil) } }, - issuer: successServer.URL, - wantErr: `missing or invalid state parameter in authorization response: http://127.0.0.1:0/callback?code=foo&state=wrong-state`, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: `missing or invalid state parameter in authorization response: http://127.0.0.1:0/callback?code=foo&state=wrong-state`, }, { name: "ldap login when there is an error exchanging the authcode or validating the tokens", @@ -801,8 +830,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, - issuer: successServer.URL, - wantErr: "error during authorization code exchange: some authcode exchange or token validation error", + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantErr: "error during authorization code exchange: some authcode exchange or token validation error", }, { name: "successful ldap login", @@ -898,6 +928,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo } }, issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantToken: &testToken, }, { @@ -921,6 +952,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"cluster-1234\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + errorServer.URL + "\""}, wantErr: fmt.Sprintf("failed to exchange token: could not perform OIDC discovery for %q: 500 Internal Server Error: some discovery error\n", errorServer.URL), }, { @@ -944,6 +978,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"cluster-1234\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + brokenTokenURLServer.URL + "\""}, wantErr: `failed to exchange token: could not build RFC8693 request: parse "%": invalid URL escape "%"`, }, { @@ -967,6 +1004,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-http-response\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: fmt.Sprintf(`failed to exchange token: Post "%s/token": failed to parse Location header "%%": parse "%%": invalid URL escape "%%"`, successServer.URL), }, { @@ -990,6 +1030,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-http-400\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: unexpected HTTP response status 400`, }, { @@ -1013,6 +1056,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-content-type\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: failed to decode content-type header: mime: invalid media parameter`, }, { @@ -1036,6 +1082,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-wrong-content-type\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: unexpected HTTP response content type "invalid"`, }, { @@ -1059,6 +1108,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-json\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: failed to decode response: unexpected EOF`, }, { @@ -1082,6 +1134,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-tokentype\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: got unexpected token_type "invalid"`, }, { @@ -1105,6 +1160,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-issuedtokentype\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: got unexpected issued_token_type "invalid"`, }, { @@ -1128,6 +1186,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience-produce-invalid-jwt\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantErr: `failed to exchange token: received invalid JWT: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts`, }, { @@ -1157,6 +1218,9 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Found unexpired cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantToken: &testExchangedToken, }, { @@ -1204,18 +1268,29 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return nil } }, + wantLogs: []string{ + "\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\"", + "\"level\"=4 \"msg\"=\"Pinniped: Refreshing cached token.\"", + "\"level\"=4 \"msg\"=\"Pinniped: Performing RFC8693 token exchange\" \"requestedAudience\"=\"test-audience\"", + }, wantToken: &testExchangedToken, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + testLogger := testlogger.New(t) + klog.SetLogger(testLogger) + stdr.SetVerbosity(debugLogLevel) // set stdr's global log level to debug so the test logger will send output. + tok, err := Login(tt.issuer, tt.clientID, WithContext(context.Background()), WithListenPort(0), WithScopes([]string{"test-scope"}), tt.opt(t), + WithLogger(testLogger), ) + require.Equal(t, tt.wantLogs, testLogger.Lines()) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) require.Nil(t, tok) diff --git a/site/content/docs/howto/configure-supervisor-with-gitlab.md b/site/content/docs/howto/configure-supervisor-with-gitlab.md new file mode 100644 index 00000000..e81d9e59 --- /dev/null +++ b/site/content/docs/howto/configure-supervisor-with-gitlab.md @@ -0,0 +1,140 @@ +--- +title: Configure the Pinniped Supervisor to use GitLab as an OIDC Provider +description: Set up the Pinniped Supervisor to use GitLab login. +cascade: + layout: docs +menu: + docs: + name: Configure Supervisor With GitLab + weight: 35 + parent: howtos +--- +The Supervisor is an [OpenID Connect (OIDC)](https://openid.net/connect/) issuer that supports connecting a single "upstream" OIDC identity provider to many "downstream" cluster clients. + +This guide shows you how to configure the Supervisor so that users can authenticate to their Kubernetes +cluster using their GitLab credentials. + +## Prerequisites + +This how-to guide assumes that you have already [installed the Pinniped Supervisor]({{< ref "install-supervisor" >}}) with working ingress, +and that you have [configured a `FederationDomain` to issue tokens for your downstream clusters]({{< ref "configure-supervisor" >}}). + +## Configure your GitLab Application + +Follow the instructions for [using GitLab as an OAuth2 authentication service provider](https://docs.gitlab.com/ee/integration/oauth_provider.html) and create a user, group, or instance-wide application. + +For example, to create a user-owned application: + +1. In GitLab, navigate to [_User Settings_ > _Applications_](https://gitlab.com/-/profile/applications) +1. Create a new application: + 1. Enter a name for your application, such as "My Kubernetes Clusters". + 1. Enter the redirect URI. This is the `spec.issuer` you configured in your `FederationDomain` appended with `/callback`. + 1. Check the box saying that the application is _Confidential_. + 1. Select scope `openid`. This provides access to the `nickname` (GitLab username) and `groups` (GitLab groups) claims. + 1. Save the application and make note of the _Application ID_ and _Secret_. + +## Configure the Supervisor cluster + +Create an [OIDCIdentityProvider](https://github.com/vmware-tanzu/pinniped/blob/main/generated/1.20/README.adoc#oidcidentityprovider) in the same namespace as the Supervisor. + +For example, this OIDCIdentityProvider and corresponding Secret for [gitlab.com](https://gitlab.com) use the `nickname` claim (GitLab username) as the Kubernetes username: + +```yaml +apiVersion: idp.supervisor.pinniped.dev/v1alpha1 +kind: OIDCIdentityProvider +metadata: + namespace: pinniped-supervisor + name: gitlab +spec: + + # Specify the upstream issuer URL. + issuer: https://gitlab.com + + # Specify how GitLab claims are mapped to Kubernetes identities. + claims: + + # Specify the name of the claim in your GitLab token that will be mapped + # to the "username" claim in downstream tokens minted by the Supervisor. + username: nickname + + # Specify the name of the claim in GitLab that represents the groups + # that the user belongs to. Note that GitLab's "groups" claim comes from + # their "/userinfo" endpoint, not the token. + groups: groups + + # Specify the name of the Kubernetes Secret that contains your GitLab + # application's client credentials (created below). + client: + secretName: gitlab-client-credentials +--- +apiVersion: v1 +kind: Secret +metadata: + namespace: pinniped-supervisor + name: gitlab-client-credentials +type: secrets.pinniped.dev/oidc-client +stringData: + + # The "Application ID" that you got from GitLab. + clientID: "" + + # The "Secret" that you got from GitLab. + clientSecret: "" +``` + +Once your OIDCIdentityProvider has been created, you can validate your configuration by running: + +```shell +kubectl describe OIDCIdentityProvider -n pinniped-supervisor gitlab +``` + +Look at the `status` field. If it was configured correctly, you should see `phase: Ready`. + +### (Optional) Use a different GitLab claim for Kubernetes usernames + +You can also use other GitLab claims as the username. +To do this, make sure you have configured the appropriate scopes on your GitLab application, such as `email`. + +You must also adjust the `spec.authorizationConfig` to request those scopes at login and adjust `spec.claims` to use those claims in Kubernetes, for example: + +```yaml +# [...] +spec: + # Request any scopes other than "openid" that you selected when + # creating your GitLab application. The "openid" scope is always + # included. + # + # See here for a full list of available claims: + # https://docs.gitlab.com/ee/integration/openid_connect_provider.html + authorizationConfig: + additionalScopes: [ email ] + claims: + username: email + groups: groups +# [...] +``` + +### (Optional) Use a private GitLab instance + +To use privately hosted instance of GitLab, you can change the `spec.issuer` and `spec.tls.certificateAuthorityData` fields, for example: + +```yaml +apiVersion: idp.supervisor.pinniped.dev/v1alpha1 +kind: OIDCIdentityProvider +# [...] +spec: + # Specify your GitLab instance URL. + issuer: https://gitlab.your-company.example.com. + + # Specify the CA bundle for the GitLab server as base64-encoded PEM + # data. For example, the output of `cat my-ca-bundle.pem | base64`. + # + # This is only necessary if your instance uses a custom CA. + tls: + certificateAuthorityData: "" +# [...] +``` + +## Next Steps + +Now that you have configured the Supervisor to use GitLab, you may want to [configure the Concierge to validate JWTs issued by the Supervisor]({{< ref "configure-concierge-jwt" >}}). diff --git a/site/content/docs/reference/cli.md b/site/content/docs/reference/cli.md index e5786505..556d8ba8 100644 --- a/site/content/docs/reference/cli.md +++ b/site/content/docs/reference/cli.md @@ -29,6 +29,8 @@ pinniped get kubeconfig [flags] --concierge-endpoint string API base for the Concierge endpoint --concierge-mode mode Concierge mode of operation (default TokenCredentialRequestAPI) --concierge-skip-wait Skip waiting for any pending Concierge strategies to become ready (default: false) + --credential-cache string Path to cluster-specific credentials cache + --generated-name-suffix string Suffix to append to generated cluster, context, user kubeconfig entries (default "-pinniped") -h, --help help for kubeconfig --kubeconfig string Path to kubeconfig file --kubeconfig-context string Kubeconfig context name (default: current active context) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index d0e5da17..f580fe09 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -208,6 +208,8 @@ func TestCLILoginOIDC(t *testing.T) { require.NoErrorf(t, json.Unmarshal(cmd2Output, &credOutput2), "command returned something other than an ExecCredential:\n%s", string(cmd2Output)) require.Equal(t, credOutput, credOutput2) + // the logs contain only the ExecCredential. There are 2 elements because the last one is "". + require.Len(t, strings.Split(string(cmd2Output), "\n"), 2) // Overwrite the cache entry to remove the access and ID tokens. t.Logf("overwriting cache to remove valid ID token") @@ -237,6 +239,26 @@ func TestCLILoginOIDC(t *testing.T) { require.NoErrorf(t, json.Unmarshal(cmd3Output, &credOutput3), "command returned something other than an ExecCredential:\n%s", string(cmd2Output)) require.NotEqual(t, credOutput2.Status.Token, credOutput3.Status.Token) + // the logs contain only the ExecCredential. There are 2 elements because the last one is "". + require.Len(t, strings.Split(string(cmd3Output), "\n"), 2) + + t.Logf("starting fourth CLI subprocess to test debug logging") + err = os.Setenv("PINNIPED_DEBUG", "true") + require.NoError(t, err) + command := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath) + cmd4CombinedOutput, err := command.CombinedOutput() + cmd4StringOutput := string(cmd4CombinedOutput) + require.NoError(t, err, cmd4StringOutput) + + // the logs contain only the 4 debug lines plus the ExecCredential. There are 6 elements because the last one is "". + require.Len(t, strings.Split(cmd4StringOutput, "\n"), 6) + require.Contains(t, cmd4StringOutput, "Performing OIDC login") + require.Contains(t, cmd4StringOutput, "Found unexpired cached token") + require.Contains(t, cmd4StringOutput, "No concierge configured, skipping token credential exchange") + require.Contains(t, cmd4StringOutput, "caching cluster credential for future use.") + require.Contains(t, cmd4StringOutput, credOutput3.Status.Token) + err = os.Unsetenv("PINNIPED_DEBUG") + require.NoError(t, err) } func runPinnipedLoginOIDC( @@ -271,6 +293,7 @@ func runPinnipedLoginOIDC( // Start a background goroutine to read stderr from the CLI and parse out the login URL. loginURLChan := make(chan string) spawnTestGoroutine(t, func() (err error) { + t.Helper() defer func() { closeErr := stderr.Close() if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { @@ -282,16 +305,18 @@ func runPinnipedLoginOIDC( }() reader := bufio.NewReader(library.NewLoggerReader(t, "stderr", stderr)) - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("could not read login URL line from stderr: %w", err) - } + + scanner := bufio.NewScanner(reader) const prompt = "Please log in: " - if !strings.HasPrefix(line, prompt) { - return fmt.Errorf("expected %q to have prefix %q", line, prompt) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, prompt) { + loginURLChan <- strings.TrimPrefix(line, prompt) + return nil + } } - loginURLChan <- strings.TrimPrefix(line, prompt) - return readAndExpectEmpty(reader) + + return fmt.Errorf("expected stderr to contain %s", prompt) }) // Start a background goroutine to read stdout from the CLI and parse out an ExecCredential. diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index ff936236..faf44e95 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -6,7 +6,11 @@ package integration import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/json" "encoding/pem" @@ -28,6 +32,8 @@ import ( "golang.org/x/net/http2" authenticationv1 "k8s.io/api/authentication/v1" authorizationv1 "k8s.io/api/authorization/v1" + certificatesv1 "k8s.io/api/certificates/v1" + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -42,6 +48,8 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/transport" + "k8s.io/client-go/util/cert" + "k8s.io/client-go/util/certificate/csr" "k8s.io/client-go/util/keyutil" "sigs.k8s.io/yaml" @@ -607,16 +615,26 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) require.NoError(t, err) - expectedExtra := make(map[string]authenticationv1.ExtraValue, len(whoAmIAdmin.Status.KubernetesUserInfo.User.Extra)) - for k, v := range whoAmIAdmin.Status.KubernetesUserInfo.User.Extra { + // The WhoAmI API is lossy: + // - It drops UID + // - It lowercases all extra keys + // the admin user on EKS has both a UID set and an extra key with uppercase characters + // Thus we fallback to the CSR API to grab the UID and Extra to handle this scenario + uid, extra := getUIDAndExtraViaCSR(ctx, t, whoAmIAdmin.Status.KubernetesUserInfo.User.UID, + newImpersonationProxyClientWithCredentials(t, + clusterAdminCredentials, impersonationProxyURL, impersonationProxyCACertPEM, nil). + Kubernetes, + ) + + expectedExtra := make(map[string]authenticationv1.ExtraValue, len(extra)) + for k, v := range extra { expectedExtra[k] = authenticationv1.ExtraValue(v) } expectedOriginalUserInfo := authenticationv1.UserInfo{ Username: whoAmIAdmin.Status.KubernetesUserInfo.User.Username, - // The WhoAmI API is lossy so this will fail when the admin user actually does have a UID - UID: whoAmIAdmin.Status.KubernetesUserInfo.User.UID, - Groups: whoAmIAdmin.Status.KubernetesUserInfo.User.Groups, - Extra: expectedExtra, + UID: uid, + Groups: whoAmIAdmin.Status.KubernetesUserInfo.User.Groups, + Extra: expectedExtra, } expectedOriginalUserInfoJSON, err := json.Marshal(expectedOriginalUserInfo) require.NoError(t, err) @@ -780,32 +798,148 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl whoAmI, ) - // Test using a service account token. Authenticating as Service Accounts through the impersonation - // proxy is not supported, so it should fail. + // Test using a service account token. namespaceName := createTestNamespace(t, adminClient) - _, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName) + saName, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName) impersonationProxyServiceAccountPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t, &loginv1alpha1.ClusterCredential{Token: saToken}, impersonationProxyURL, impersonationProxyCACertPEM, nil).PinnipedConcierge - _, err = impersonationProxyServiceAccountPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). + whoAmI, err = impersonationProxyServiceAccountPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) - require.EqualError(t, err, "Internal error occurred: unimplemented functionality - unable to act as current user") - require.True(t, k8serrors.IsInternalError(err), err) - require.Equal(t, &k8serrors.StatusError{ - ErrStatus: metav1.Status{ - Status: metav1.StatusFailure, - Code: http.StatusInternalServerError, - Reason: metav1.StatusReasonInternalError, - Details: &metav1.StatusDetails{ - Causes: []metav1.StatusCause{ - { - Message: "unimplemented functionality - unable to act as current user", - }, + require.NoError(t, err) + require.Equal(t, + expectedWhoAmIRequestResponse( + serviceaccount.MakeUsername(namespaceName, saName), + []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, + nil, + ), + whoAmI, + ) + }) + + t.Run("WhoAmIRequests and SA token request", func(t *testing.T) { + namespaceName := createTestNamespace(t, adminClient) + kubeClient := adminClient.CoreV1() + saName, _, saUID := createServiceAccountToken(ctx, t, adminClient, namespaceName) + + _, tokenRequestProbeErr := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{}, metav1.CreateOptions{}) + if k8serrors.IsNotFound(tokenRequestProbeErr) && tokenRequestProbeErr.Error() == "the server could not find the requested resource" { + return // stop test early since the token request API is not enabled on this cluster - other errors are caught below + } + + pod, err := kubeClient.Pods(namespaceName).Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-impersonation-proxy-", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "ignored-but-required", + Image: "does-not-matter", }, }, - Message: "Internal error occurred: unimplemented functionality - unable to act as current user", + ServiceAccountName: saName, }, - }, err) + }, metav1.CreateOptions{}) + require.NoError(t, err) + + tokenRequestBadAudience, err := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"should-fail-because-wrong-audience"}, // anything that is not an API server audience + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "", + Name: pod.Name, + UID: pod.UID, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + impersonationProxySABadAudPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t, + &loginv1alpha1.ClusterCredential{Token: tokenRequestBadAudience.Status.Token}, + impersonationProxyURL, impersonationProxyCACertPEM, nil).PinnipedConcierge + + _, badAudErr := impersonationProxySABadAudPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.True(t, k8serrors.IsUnauthorized(badAudErr), library.Sdump(badAudErr)) + + tokenRequest, err := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{}, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "", + Name: pod.Name, + UID: pod.UID, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + impersonationProxySAClient := newImpersonationProxyClientWithCredentials(t, + &loginv1alpha1.ClusterCredential{Token: tokenRequest.Status.Token}, + impersonationProxyURL, impersonationProxyCACertPEM, nil) + + whoAmITokenReq, err := impersonationProxySAClient.PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + // new service account tokens include the pod info in the extra fields + require.Equal(t, + expectedWhoAmIRequestResponse( + serviceaccount.MakeUsername(namespaceName, saName), + []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, + map[string]identityv1alpha1.ExtraValue{ + "authentication.kubernetes.io/pod-name": {pod.Name}, + "authentication.kubernetes.io/pod-uid": {string(pod.UID)}, + }, + ), + whoAmITokenReq, + ) + + // allow the test SA to create CSRs + library.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.ServiceAccountKind, Name: saName, Namespace: namespaceName}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "system:node-bootstrapper"}, + ) + library.WaitForUserToHaveAccess(t, serviceaccount.MakeUsername(namespaceName, saName), []string{}, &authorizationv1.ResourceAttributes{ + Verb: "create", Group: certificatesv1.GroupName, Version: "*", Resource: "certificatesigningrequests", + }) + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + csrPEM, err := cert.MakeCSR(privateKey, &pkix.Name{ + CommonName: "panda-man", + Organization: []string{"living-the-dream", "need-more-sleep"}, + }, nil, nil) + require.NoError(t, err) + + csrName, _, err := csr.RequestCertificate( + impersonationProxySAClient.Kubernetes, + csrPEM, + "", + certificatesv1.KubeAPIServerClientSignerName, + []certificatesv1.KeyUsage{certificatesv1.UsageClientAuth}, + privateKey, + ) + require.NoError(t, err) + + saCSR, err := impersonationProxySAClient.Kubernetes.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{}) + require.NoError(t, err) + + // make sure the user info that the CSR captured matches the SA, including the UID + require.Equal(t, serviceaccount.MakeUsername(namespaceName, saName), saCSR.Spec.Username) + require.Equal(t, string(saUID), saCSR.Spec.UID) + require.Equal(t, []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, saCSR.Spec.Groups) + require.Equal(t, map[string]certificatesv1beta1.ExtraValue{ + "authentication.kubernetes.io/pod-name": {pod.Name}, + "authentication.kubernetes.io/pod-uid": {string(pod.UID)}, + }, saCSR.Spec.Extra) }) t.Run("kubectl as a client", func(t *testing.T) { @@ -1581,17 +1715,18 @@ func getCredForConfig(t *testing.T, config *rest.Config) *loginv1alpha1.ClusterC if tlsConfig != nil && tlsConfig.GetClientCertificate != nil { cert, err := tlsConfig.GetClientCertificate(nil) require.NoError(t, err) - require.Len(t, cert.Certificate, 1) + if len(cert.Certificate) > 0 { + require.Len(t, cert.Certificate, 1) + publicKey := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Certificate[0], + }) + out.ClientCertificateData = string(publicKey) - publicKey := pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: cert.Certificate[0], - }) - out.ClientCertificateData = string(publicKey) - - privateKey, err := keyutil.MarshalPrivateKeyToPEM(cert.PrivateKey) - require.NoError(t, err) - out.ClientKeyData = string(privateKey) + privateKey, err := keyutil.MarshalPrivateKeyToPEM(cert.PrivateKey) + require.NoError(t, err) + out.ClientKeyData = string(privateKey) + } } if *out == (loginv1alpha1.ClusterCredential{}) { @@ -1600,3 +1735,39 @@ func getCredForConfig(t *testing.T, config *rest.Config) *loginv1alpha1.ClusterC return out } + +func getUIDAndExtraViaCSR(ctx context.Context, t *testing.T, uid string, client kubernetes.Interface) (string, map[string]certificatesv1beta1.ExtraValue) { + t.Helper() + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + csrPEM, err := cert.MakeCSR(privateKey, &pkix.Name{ + CommonName: "panda-man", + Organization: []string{"living-the-dream", "need-more-sleep"}, + }, nil, nil) + require.NoError(t, err) + + csrName, _, err := csr.RequestCertificate( + client, + csrPEM, + "", + certificatesv1.KubeAPIServerClientSignerName, + []certificatesv1.KeyUsage{certificatesv1.UsageClientAuth}, + privateKey, + ) + require.NoError(t, err) + + csReq, err := client.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{}) + require.NoError(t, err) + + err = client.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{}) + require.NoError(t, err) + + outUID := uid // in the future this may not be empty on some clusters + if len(outUID) == 0 { + outUID = csReq.Spec.UID + } + + return outUID, csReq.Spec.Extra +} diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 416b6081..2d7731c6 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -328,8 +328,8 @@ status: `, string(kubectlOutput3)) - expectedGroupsPlusUnauthenticated := append([]string{}, env.SupervisorUpstreamOIDC.ExpectedGroups...) - expectedGroupsPlusUnauthenticated = append(expectedGroupsPlusUnauthenticated, "system:authenticated") + expectedGroupsPlusAuthenticated := append([]string{}, env.SupervisorUpstreamOIDC.ExpectedGroups...) + expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated") // Validate that `pinniped whoami` returns the correct identity. assertWhoami( ctx, @@ -338,6 +338,6 @@ status: pinnipedExe, kubeconfigPath, env.SupervisorUpstreamOIDC.Username, - expectedGroupsPlusUnauthenticated, + expectedGroupsPlusAuthenticated, ) } diff --git a/test/integration/supervisor_storage_garbage_collection_test.go b/test/integration/supervisor_storage_garbage_collection_test.go index 84b82196..1394b6f4 100644 --- a/test/integration/supervisor_storage_garbage_collection_test.go +++ b/test/integration/supervisor_storage_garbage_collection_test.go @@ -48,10 +48,15 @@ func TestStorageGarbageCollection(t *testing.T) { // in the same namespace just to get the controller to respond faster. // This is just a performance optimization to make this test pass faster because otherwise // this test has to wait ~3 minutes for the controller's next full-resync. - stopCh := make(chan bool, 1) // It is important that this channel be buffered. - go updateSecretEveryTwoSeconds(t, stopCh, secrets, secretNotYetExpired) + stopCh := make(chan struct{}) + errCh := make(chan error) + go updateSecretEveryTwoSeconds(stopCh, errCh, secrets, secretNotYetExpired) t.Cleanup(func() { - stopCh <- true + close(stopCh) + + if updateErr := <-errCh; updateErr != nil { + panic(updateErr) + } }) // Wait long enough for the next periodic sweep of the GC controller for the secrets to be deleted, which @@ -69,10 +74,15 @@ func TestStorageGarbageCollection(t *testing.T) { require.NoError(t, err) } -func updateSecretEveryTwoSeconds(t *testing.T, stopCh chan bool, secrets corev1client.SecretInterface, secret *v1.Secret) { +func updateSecretEveryTwoSeconds(stopCh chan struct{}, errCh chan error, secrets corev1client.SecretInterface, secret *v1.Secret) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() + var updateErr error + defer func() { + errCh <- updateErr + }() + i := 0 for { select { @@ -87,9 +97,25 @@ func updateSecretEveryTwoSeconds(t *testing.T, stopCh chan bool, secrets corev1c i++ secret.Data["foo"] = []byte(fmt.Sprintf("bar-%d", i)) - var updateErr error secret, updateErr = secrets.Update(ctx, secret, metav1.UpdateOptions{}) - require.NoError(t, updateErr) + + switch { + case updateErr == nil: + // continue to next update + + case k8serrors.IsConflict(updateErr), k8serrors.IsNotFound(updateErr): + select { + case _, ok := <-stopCh: + if !ok { // stopCh is closed meaning that test is already finished so these errors are expected + updateErr = nil + } + default: + } + + return // even if the error is expected, we must stop + default: + return // unexpected error + } } } diff --git a/test/integration/supervisor_upstream_test.go b/test/integration/supervisor_upstream_test.go index 5b4c0cfd..0a9b9767 100644 --- a/test/integration/supervisor_upstream_test.go +++ b/test/integration/supervisor_upstream_test.go @@ -34,10 +34,43 @@ func TestSupervisorUpstreamOIDCDiscovery(t *testing.T) { Message: `secret "does-not-exist" not found`, }, { - Type: "OIDCDiscoverySucceeded", - Status: v1alpha1.ConditionFalse, - Reason: "Unreachable", - Message: `failed to perform OIDC discovery against "https://127.0.0.1:444444/issuer"`, + Type: "OIDCDiscoverySucceeded", + Status: v1alpha1.ConditionFalse, + Reason: "Unreachable", + Message: `failed to perform OIDC discovery against "https://127.0.0.1:444444/issuer": +Get "https://127.0.0.1:444444/issuer/.well-known/openid-configuration": dial tcp: address 444444: in [truncated 10 chars]`, + }, + }) + }) + + t.Run("invalid issuer with trailing slash", func(t *testing.T) { + t.Parallel() + spec := v1alpha1.OIDCIdentityProviderSpec{ + Issuer: env.SupervisorUpstreamOIDC.Issuer + "/", + TLS: &v1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), + }, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: []string{"email", "profile"}, + }, + Client: v1alpha1.OIDCClient{ + SecretName: library.CreateClientCredsSecret(t, "test-client-id", "test-client-secret").Name, + }, + } + upstream := library.CreateTestOIDCIdentityProvider(t, spec, v1alpha1.PhaseError) + expectUpstreamConditions(t, upstream, []v1alpha1.Condition{ + { + Type: "ClientCredentialsValid", + Status: v1alpha1.ConditionTrue, + Reason: "Success", + Message: "loaded client credentials", + }, + { + Type: "OIDCDiscoverySucceeded", + Status: v1alpha1.ConditionFalse, + Reason: "Unreachable", + Message: `failed to perform OIDC discovery against "` + env.SupervisorUpstreamOIDC.Issuer + `/": +oidc: issuer did not match the issuer returned by provider, expected "` + env.SupervisorUpstreamOIDC.Issuer + `/" got "` + env.SupervisorUpstreamOIDC.Issuer + `"`, }, }) })