diff --git a/apis/supervisor/idp/v1alpha1/register.go.tmpl b/apis/supervisor/idp/v1alpha1/register.go.tmpl index c03b7dde..ddc9c360 100644 --- a/apis/supervisor/idp/v1alpha1/register.go.tmpl +++ b/apis/supervisor/idp/v1alpha1/register.go.tmpl @@ -32,6 +32,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &OIDCIdentityProvider{}, &OIDCIdentityProviderList{}, + &LDAPIdentityProvider{}, + &LDAPIdentityProviderList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go.tmpl b/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go.tmpl new file mode 100644 index 00000000..d718ba65 --- /dev/null +++ b/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go.tmpl @@ -0,0 +1,132 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LDAPIdentityProviderPhase string + +const ( + // LDAPPhasePending is the default phase for newly-created LDAPIdentityProvider resources. + LDAPPhasePending LDAPIdentityProviderPhase = "Pending" + + // LDAPPhaseReady is the phase for an LDAPIdentityProvider resource in a healthy state. + LDAPPhaseReady LDAPIdentityProviderPhase = "Ready" + + // LDAPPhaseError is the phase for an LDAPIdentityProvider in an unhealthy state. + LDAPPhaseError LDAPIdentityProviderPhase = "Error" +) + +// Status of an LDAP identity provider. +type LDAPIdentityProviderStatus struct { + // Phase summarizes the overall status of the LDAPIdentityProvider. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase LDAPIdentityProviderPhase `json:"phase,omitempty"` + + // Represents the observations of an identity provider's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +type LDAPIdentityProviderBind struct { + // SecretName contains the name of a namespace-local Secret object that provides the username and + // password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be + // of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value + // should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + // The password must be non-empty. + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +type LDAPIdentityProviderUserSearchAttributes struct { + // Username specifies the name of attribute in the LDAP entry which whose value shall become the username + // of the user after a successful authentication. This would typically be the same attribute name used in + // the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field + // is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default + // value of "dn={}" would not work. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username,omitempty"` + + // UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely + // identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid,omitempty"` +} + +type LDAPIdentityProviderUserSearch struct { + // Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". + // +kubebuilder:validation:MinLength=1 + Base string `json:"base,omitempty"` + + // Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur + // in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" + // or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. + // Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. + // Optional. When not specified, the default will act as if the Filter were specified as the value from + // Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be + // explicitly specified, since the default value of "dn={}" would not work. + // +optional + Filter string `json:"filter,omitempty"` + + // Attributes specifies how the user's information should be read from the LDAP entry which was found as + // the result of the user search. + // +optional + Attributes LDAPIdentityProviderUserSearchAttributes `json:"attributes,omitempty"` +} + +// Spec for configuring an LDAP identity provider. +type LDAPIdentityProviderSpec struct { + // Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // TLS contains the connection settings for how to establish the connection to the Host. + TLS *TLSSpec `json:"tls,omitempty"` + + // Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server + // to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. + Bind LDAPIdentityProviderBind `json:"bind,omitempty"` + + // UserSearch contains the configuration for searching for a user by name in the LDAP provider. + UserSearch LDAPIdentityProviderUserSearch `json:"userSearch,omitempty"` +} + +// LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access +// Protocol (LDAP) identity provider. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:categories=pinniped;pinniped-idp;pinniped-idps +// +kubebuilder:printcolumn:name="Host",type=string,JSONPath=`.spec.host` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +type LDAPIdentityProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec for configuring the identity provider. + Spec LDAPIdentityProviderSpec `json:"spec"` + + // Status of the identity provider. + Status LDAPIdentityProviderStatus `json:"status,omitempty"` +} + +// List of LDAPIdentityProvider objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LDAPIdentityProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LDAPIdentityProvider `json:"items"` +} diff --git a/cmd/pinniped-supervisor/main.go b/cmd/pinniped-supervisor/main.go index 1f23e672..0cf897ae 100644 --- a/cmd/pinniped-supervisor/main.go +++ b/cmd/pinniped-supervisor/main.go @@ -32,7 +32,8 @@ import ( "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/controller/supervisorconfig" "go.pinniped.dev/internal/controller/supervisorconfig/generator" - "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatcher" + "go.pinniped.dev/internal/controller/supervisorconfig/ldapupstreamwatcher" + "go.pinniped.dev/internal/controller/supervisorconfig/oidcupstreamwatcher" "go.pinniped.dev/internal/controller/supervisorstorage" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/deploymentref" @@ -233,7 +234,7 @@ func startControllers( singletonWorker, ). WithController( - upstreamwatcher.New( + oidcupstreamwatcher.New( dynamicUpstreamIDPProvider, pinnipedClient, pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), @@ -241,6 +242,15 @@ func startControllers( klogr.New(), controllerlib.WithInformer, ), + singletonWorker). + WithController( + ldapupstreamwatcher.New( + dynamicUpstreamIDPProvider, + pinnipedClient, + pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders(), + secretInformer, + controllerlib.WithInformer, + ), singletonWorker) kubeInformers.Start(ctx.Done()) diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 50aee669..32d724a2 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -8,8 +8,10 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/json" "fmt" "io" + "io/ioutil" "log" "net/http" "os" @@ -26,6 +28,7 @@ import ( _ "k8s.io/client-go/plugin/pkg/client/auth" // Adds handlers for various dynamic auth plugins in client-go "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/client-go/transport" conciergev1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" @@ -62,6 +65,8 @@ type getKubeconfigOIDCParams struct { debugSessionCache bool caBundle caBundleFlag requestAudience string + upstreamIDPName string + upstreamIDPType string } type getKubeconfigConciergeParams struct { @@ -91,6 +96,23 @@ type getKubeconfigParams struct { credentialCachePathSet bool } +type supervisorOIDCDiscoveryResponseWithV1Alpha1 struct { + SupervisorDiscovery SupervisorDiscoveryResponseV1Alpha1 `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +type SupervisorDiscoveryResponseV1Alpha1 struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +type supervisorIDPsDiscoveryResponseV1Alpha1 struct { + PinnipedIDPs []pinnipedIDPResponse `json:"pinniped_identity_providers"` +} + +type pinnipedIDPResponse struct { + Name string `json:"name"` + Type string `json:"type"` +} + func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { var ( cmd = &cobra.Command{ @@ -128,6 +150,8 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { f.Var(&flags.oidc.caBundle, "oidc-ca-bundle", "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)") f.BoolVar(&flags.oidc.debugSessionCache, "oidc-debug-session-cache", false, "Print debug logs related to the OpenID Connect session cache") f.StringVar(&flags.oidc.requestAudience, "oidc-request-audience", "", "Request a token with an alternate audience using RFC8693 token exchange") + f.StringVar(&flags.oidc.upstreamIDPName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor") + f.StringVar(&flags.oidc.upstreamIDPType, "upstream-identity-provider-type", "", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')") f.StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file") f.StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)") f.BoolVar(&flags.skipValidate, "skip-validation", false, "Skip final validation of the kubeconfig (default: false)") @@ -165,19 +189,6 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f return fmt.Errorf("invalid API group suffix: %w", err) } - execConfig := clientcmdapi.ExecConfig{ - APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), - Args: []string{}, - Env: []clientcmdapi.ExecEnvVar{}, - } - - var err error - execConfig.Command, err = deps.getPathToSelf() - if err != nil { - return fmt.Errorf("could not determine the Pinniped executable path: %w", err) - } - execConfig.ProvideClusterInfo = true - clientConfig := newClientConfig(flags.kubeconfigPath, flags.kubeconfigContextOverride) currentKubeConfig, err := clientConfig.RawConfig() if err != nil { @@ -221,6 +232,47 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f if err := discoverAuthenticatorParams(authenticator, &flags, deps.log); err != nil { return err } + + // Point kubectl at the concierge endpoint. + cluster.Server = flags.concierge.endpoint + cluster.CertificateAuthorityData = flags.concierge.caBundle + } + + // If there is an issuer, and if both upstream flags are not already set, then try to discover Supervisor upstream IDP. + if len(flags.oidc.issuer) > 0 && (flags.oidc.upstreamIDPType == "" || flags.oidc.upstreamIDPName == "") { + if err := discoverSupervisorUpstreamIDP(ctx, &flags); err != nil { + return err + } + } + + execConfig, err := newExecConfig(deps, flags) + if err != nil { + return err + } + + kubeconfig := newExecKubeconfig(cluster, execConfig, newKubeconfigNames) + if err := validateKubeconfig(ctx, flags, kubeconfig, deps.log); err != nil { + return err + } + + return writeConfigAsYAML(out, kubeconfig) +} + +func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdapi.ExecConfig, error) { + execConfig := &clientcmdapi.ExecConfig{ + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + Args: []string{}, + Env: []clientcmdapi.ExecEnvVar{}, + ProvideClusterInfo: true, + } + + var err error + execConfig.Command, err = deps.getPathToSelf() + if err != nil { + return nil, fmt.Errorf("could not determine the Pinniped executable path: %w", err) + } + + if !flags.concierge.disabled { // Append the flags to configure the Concierge credential exchange at runtime. execConfig.Args = append(execConfig.Args, "--enable-concierge", @@ -230,10 +282,6 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f "--concierge-endpoint="+flags.concierge.endpoint, "--concierge-ca-bundle-data="+base64.StdEncoding.EncodeToString(flags.concierge.caBundle), ) - - // Point kubectl at the concierge endpoint. - cluster.Server = flags.concierge.endpoint - cluster.CertificateAuthorityData = flags.concierge.caBundle } // If --credential-cache is set, pass it through. @@ -244,7 +292,7 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f // If one of the --static-* flags was passed, output a config that runs `pinniped login static`. if flags.staticToken != "" || flags.staticTokenEnvName != "" { if flags.staticToken != "" && flags.staticTokenEnvName != "" { - return fmt.Errorf("only one of --static-token and --static-token-env can be specified") + return nil, fmt.Errorf("only one of --static-token and --static-token-env can be specified") } execConfig.Args = append([]string{"login", "static"}, execConfig.Args...) if flags.staticToken != "" { @@ -253,18 +301,13 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f if flags.staticTokenEnvName != "" { execConfig.Args = append(execConfig.Args, "--token-env="+flags.staticTokenEnvName) } - - kubeconfig := newExecKubeconfig(cluster, &execConfig, newKubeconfigNames) - if err := validateKubeconfig(ctx, flags, kubeconfig, deps.log); err != nil { - return err - } - return writeConfigAsYAML(out, kubeconfig) + return execConfig, nil } // Otherwise continue to parse the OIDC-related flags and output a config that runs `pinniped login oidc`. execConfig.Args = append([]string{"login", "oidc"}, execConfig.Args...) if flags.oidc.issuer == "" { - return fmt.Errorf("could not autodiscover --oidc-issuer and none was provided") + return nil, fmt.Errorf("could not autodiscover --oidc-issuer and none was provided") } execConfig.Args = append(execConfig.Args, "--issuer="+flags.oidc.issuer, @@ -289,11 +332,14 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f if flags.oidc.requestAudience != "" { execConfig.Args = append(execConfig.Args, "--request-audience="+flags.oidc.requestAudience) } - kubeconfig := newExecKubeconfig(cluster, &execConfig, newKubeconfigNames) - if err := validateKubeconfig(ctx, flags, kubeconfig, deps.log); err != nil { - return err + if flags.oidc.upstreamIDPName != "" { + execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-name="+flags.oidc.upstreamIDPName) } - return writeConfigAsYAML(out, kubeconfig) + if flags.oidc.upstreamIDPType != "" { + execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-type="+flags.oidc.upstreamIDPType) + } + + return execConfig, nil } type kubeconfigNames struct{ ContextName, UserName, ClusterName string } @@ -688,3 +734,151 @@ func hasPendingStrategy(credentialIssuer *configv1alpha1.CredentialIssuer) bool } return false } + +func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigParams) error { + httpClient, err := newDiscoveryHTTPClient(flags.oidc.caBundle) + if err != nil { + return err + } + + pinnipedIDPsEndpoint, err := discoverIDPsDiscoveryEndpointURL(ctx, flags.oidc.issuer, httpClient) + if err != nil { + return err + } + if pinnipedIDPsEndpoint == "" { + // The issuer is not advertising itself as a Pinniped Supervisor which supports upstream IDP discovery. + return nil + } + + upstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient) + if err != nil { + return err + } + if len(upstreamIDPs) == 1 { + flags.oidc.upstreamIDPName = upstreamIDPs[0].Name + flags.oidc.upstreamIDPType = upstreamIDPs[0].Type + } else if len(upstreamIDPs) > 1 { + idpName, idpType, err := selectUpstreamIDP(upstreamIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType) + if err != nil { + return err + } + flags.oidc.upstreamIDPName = idpName + flags.oidc.upstreamIDPType = idpType + } + return nil +} + +func newDiscoveryHTTPClient(caBundleFlag caBundleFlag) (*http.Client, error) { + t := &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + Proxy: http.ProxyFromEnvironment, + } + httpClient := &http.Client{Transport: t} + if caBundleFlag != nil { + rootCAs := x509.NewCertPool() + ok := rootCAs.AppendCertsFromPEM(caBundleFlag) + if !ok { + return nil, fmt.Errorf("unable to fetch OIDC discovery data from issuer: could not parse CA bundle") + } + t.TLSClientConfig.RootCAs = rootCAs + } + httpClient.Transport = transport.DebugWrappers(httpClient.Transport) + return httpClient, nil +} + +func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpClient *http.Client) (string, error) { + discoveredProvider, err := oidc.NewProvider(oidc.ClientContext(ctx, httpClient), issuer) + if err != nil { + return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) + } + + var body supervisorOIDCDiscoveryResponseWithV1Alpha1 + err = discoveredProvider.Claims(&body) + if err != nil { + return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) + } + + return body.SupervisorDiscovery.PinnipedIDPsEndpoint, nil +} + +func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDPsEndpoint string, httpClient *http.Client) ([]pinnipedIDPResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, pinnipedIDPsEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("while forming request to IDP discovery URL: %w", err) + } + + response, err := httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: %w", err) + } + defer func() { + _ = response.Body.Close() + }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: unexpected http response status: %s", response.Status) + } + + rawBody, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: could not read response body: %w", err) + } + + var body supervisorIDPsDiscoveryResponseV1Alpha1 + err = json.Unmarshal(rawBody, &body) + if err != nil { + return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: could not parse response JSON: %w", err) + } + + return body.PinnipedIDPs, nil +} + +func selectUpstreamIDP(pinnipedIDPs []pinnipedIDPResponse, idpName, idpType string) (string, string, error) { + pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) + switch { + case idpType != "": + discoveredName := "" + for _, idp := range pinnipedIDPs { + if idp.Type == idpType { + if discoveredName != "" { + return "", "", fmt.Errorf( + "multiple Supervisor upstream identity providers of type \"%s\" were found,"+ + " so the --upstream-identity-provider-name flag must be specified. "+ + "Found these upstreams: %s", + idpType, pinnipedIDPsString) + } + discoveredName = idp.Name + } + } + if discoveredName == "" { + return "", "", fmt.Errorf( + "no Supervisor upstream identity providers of type \"%s\" were found."+ + " Found these upstreams: %s", idpType, pinnipedIDPsString) + } + return discoveredName, idpType, nil + case idpName != "": + discoveredType := "" + for _, idp := range pinnipedIDPs { + if idp.Name == idpName { + if discoveredType != "" { + return "", "", fmt.Errorf( + "multiple Supervisor upstream identity providers with name \"%s\" were found,"+ + " so the --upstream-identity-provider-type flag must be specified. Found these upstreams: %s", + idpName, pinnipedIDPsString) + } + discoveredType = idp.Type + } + } + if discoveredType == "" { + return "", "", fmt.Errorf( + "no Supervisor upstream identity providers with name \"%s\" were found."+ + " Found these upstreams: %s", idpName, pinnipedIDPsString) + } + return idpName, discoveredType, nil + default: + return "", "", fmt.Errorf( + "multiple Supervisor upstream identity providers were found,"+ + " so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified."+ + " Found these upstreams: %s", + pinnipedIDPsString) + } +} diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index 52384bb4..2853b0e4 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "fmt" "io/ioutil" + "net/http" "path/filepath" "testing" "time" @@ -40,233 +41,326 @@ func TestGetKubeconfig(t *testing.T) { testConciergeCABundlePath := filepath.Join(tmpdir, "testconciergeca.pem") require.NoError(t, ioutil.WriteFile(testConciergeCABundlePath, testConciergeCA.Bundle(), 0600)) + credentialIssuer := func() runtime.Object { + return &configv1alpha1.CredentialIssuer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, + Status: configv1alpha1.CredentialIssuerStatus{ + Strategies: []configv1alpha1.CredentialIssuerStrategy{{ + Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, + Status: configv1alpha1.SuccessStrategyStatus, + Reason: configv1alpha1.FetchedKeyStrategyReason, + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, + TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ + Server: "https://concierge-endpoint.example.com", + CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + }, + }, + }}, + }, + } + } + + jwtAuthenticator := func(issuerCABundle string, issuerURL string) runtime.Object { + return &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + Issuer: issuerURL, + Audience: "test-audience", + TLS: &conciergev1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(issuerCABundle)), + }, + }, + } + } + + happyOIDCDiscoveryResponse := func(issuerURL string) string { + return here.Docf(`{ + "issuer": "%s", + "other-key": "other-value", + "discovery.supervisor.pinniped.dev/v1alpha1": { + "pinniped_identity_providers_endpoint": "%s/v1alpha1/pinniped_identity_providers" + }, + "another-key": "another-value" + }`, issuerURL, issuerURL) + } + + onlyIssuerOIDCDiscoveryResponse := func(issuerURL string) string { + return here.Docf(`{ + "issuer": "%s", + "other-key": "other-value" + }`, issuerURL) + } + tests := []struct { - name string - args []string - env map[string]string - getPathToSelfErr error - getClientsetErr error - conciergeObjects []runtime.Object - conciergeReactions []kubetesting.Reactor - wantLogs []string - wantError bool - wantStdout string - wantStderr string - wantOptionsCount int - wantAPIGroupSuffix string + name string + args func(string, string) []string + env map[string]string + getPathToSelfErr error + getClientsetErr error + conciergeObjects func(string, string) []runtime.Object + conciergeReactions []kubetesting.Reactor + oidcDiscoveryResponse func(string) string + oidcDiscoveryStatusCode int + idpsDiscoveryResponse string + idpsDiscoveryStatusCode int + wantLogs func(string, string) []string + wantError bool + wantStdout func(string, string) string + wantStderr func(string, string) string + wantOptionsCount int + wantAPIGroupSuffix string }{ { name: "help flag passed", - args: []string{"--help"}, - wantStdout: here.Doc(` + args: func(issuerCABundle string, issuerURL string) []string { return []string{"--help"} }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Doc(` Generate a Pinniped-based kubeconfig for a cluster Usage: kubeconfig [flags] Flags: - --concierge-api-group-suffix string Concierge API group suffix (default "pinniped.dev") - --concierge-authenticator-name string Concierge authenticator name (default: autodiscover) - --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover) - --concierge-ca-bundle path Path to TLS certificate authority bundle (PEM format, optional, can be repeated) to use when connecting to the Concierge - --concierge-credential-issuer string Concierge CredentialIssuer object to use for autodiscovery (default: autodiscover) - --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) - --no-concierge Generate a configuration which does not use the Concierge, but sends the credential to the cluster directly - --oidc-ca-bundle path Path to TLS certificate authority bundle (PEM format, optional, can be repeated) - --oidc-client-id string OpenID Connect client ID (default: autodiscover) (default "pinniped-cli") - --oidc-issuer string OpenID Connect issuer URL (default: autodiscover) - --oidc-listen-port uint16 TCP port for localhost listener (authorization code flow only) - --oidc-request-audience string Request a token with an alternate audience using RFC8693 token exchange - --oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped:request-audience]) - --oidc-session-cache string Path to OpenID Connect session cache file - --oidc-skip-browser During OpenID Connect login, skip opening the browser (just print the URL) - -o, --output string Output file path (default: stdout) - --skip-validation Skip final validation of the kubeconfig (default: false) - --static-token string Instead of doing an OIDC-based login, specify a static token - --static-token-env string Instead of doing an OIDC-based login, read a static token from the environment - --timeout duration Timeout for autodiscovery and validation (default 10m0s) - `), - }, - { - name: "fail to get self-path", - args: []string{}, - getPathToSelfErr: fmt.Errorf("some OS error"), - wantError: true, - wantStderr: here.Doc(` - Error: could not determine the Pinniped executable path: some OS error - `), + --concierge-api-group-suffix string Concierge API group suffix (default "pinniped.dev") + --concierge-authenticator-name string Concierge authenticator name (default: autodiscover) + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover) + --concierge-ca-bundle path Path to TLS certificate authority bundle (PEM format, optional, can be repeated) to use when connecting to the Concierge + --concierge-credential-issuer string Concierge CredentialIssuer object to use for autodiscovery (default: autodiscover) + --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) + --no-concierge Generate a configuration which does not use the Concierge, but sends the credential to the cluster directly + --oidc-ca-bundle path Path to TLS certificate authority bundle (PEM format, optional, can be repeated) + --oidc-client-id string OpenID Connect client ID (default: autodiscover) (default "pinniped-cli") + --oidc-issuer string OpenID Connect issuer URL (default: autodiscover) + --oidc-listen-port uint16 TCP port for localhost listener (authorization code flow only) + --oidc-request-audience string Request a token with an alternate audience using RFC8693 token exchange + --oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped:request-audience]) + --oidc-session-cache string Path to OpenID Connect session cache file + --oidc-skip-browser During OpenID Connect login, skip opening the browser (just print the URL) + -o, --output string Output file path (default: stdout) + --skip-validation Skip final validation of the kubeconfig (default: false) + --static-token string Instead of doing an OIDC-based login, specify a static token + --static-token-env string Instead of doing an OIDC-based login, read a static token from the environment + --timeout duration Timeout for autodiscovery and validation (default 10m0s) + --upstream-identity-provider-name string The name of the upstream identity provider used during login with a Supervisor + --upstream-identity-provider-type string The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap') + `) + }, }, { name: "invalid OIDC CA bundle path", - args: []string{ - "--oidc-ca-bundle", "./does/not/exist", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--oidc-ca-bundle", "./does/not/exist", + } }, wantError: true, - wantStderr: here.Doc(` - Error: invalid argument "./does/not/exist" for "--oidc-ca-bundle" flag: could not read CA bundle path: open ./does/not/exist: no such file or directory - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: invalid argument "./does/not/exist" for "--oidc-ca-bundle" flag: could not read CA bundle path: open ./does/not/exist: no such file or directory` + "\n" + }, }, { name: "invalid Concierge CA bundle", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-ca-bundle", "./does/not/exist", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-ca-bundle", "./does/not/exist", + } }, wantError: true, - wantStderr: here.Doc(` - Error: invalid argument "./does/not/exist" for "--concierge-ca-bundle" flag: could not read CA bundle path: open ./does/not/exist: no such file or directory - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: invalid argument "./does/not/exist" for "--concierge-ca-bundle" flag: could not read CA bundle path: open ./does/not/exist: no such file or directory` + "\n" + }, }, { name: "invalid kubeconfig path", - args: []string{ - "--kubeconfig", "./does/not/exist", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./does/not/exist", + } }, wantError: true, - wantStderr: here.Doc(` - Error: could not load --kubeconfig: stat ./does/not/exist: no such file or directory - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not load --kubeconfig: stat ./does/not/exist: no such file or directory` + "\n" + }, }, { name: "invalid kubeconfig context, missing", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--kubeconfig-context", "invalid", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--kubeconfig-context", "invalid", + } }, wantError: true, - wantStderr: here.Doc(` - Error: could not load --kubeconfig/--kubeconfig-context: no such context "invalid" - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not load --kubeconfig/--kubeconfig-context: no such context "invalid"` + "\n" + }, }, { name: "invalid kubeconfig context, missing cluster", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--kubeconfig-context", "invalid-context-no-such-cluster", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--kubeconfig-context", "invalid-context-no-such-cluster", + } }, wantError: true, - wantStderr: here.Doc(` - Error: could not load --kubeconfig/--kubeconfig-context: no such cluster "invalid-cluster" - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not load --kubeconfig/--kubeconfig-context: no such cluster "invalid-cluster"` + "\n" + }, }, { name: "invalid kubeconfig context, missing user", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--kubeconfig-context", "invalid-context-no-such-user", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--kubeconfig-context", "invalid-context-no-such-user", + } }, wantError: true, - wantStderr: here.Doc(` - Error: could not load --kubeconfig/--kubeconfig-context: no such user "invalid-user" - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not load --kubeconfig/--kubeconfig-context: no such user "invalid-user"` + "\n" + }, }, { name: "clientset creation failure", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, getClientsetErr: fmt.Errorf("some kube error"), wantError: true, - wantStderr: here.Doc(` - Error: could not configure Kubernetes client: some kube error - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not configure Kubernetes client: some kube error` + "\n" + }, }, { name: "no credentialissuers", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, wantError: true, - wantStderr: here.Doc(` - Error: no CredentialIssuers were found - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: no CredentialIssuers were found` + "\n" + }, }, - { name: "credentialissuer not found", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-credential-issuer", "does-not-exist", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-credential-issuer", "does-not-exist", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, wantError: true, - wantStderr: here.Doc(` - Error: credentialissuers.config.concierge.pinniped.dev "does-not-exist" not found - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: credentialissuers.config.concierge.pinniped.dev "does-not-exist" not found` + "\n" + }, }, { name: "webhook authenticator not found", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-authenticator-type", "webhook", - "--concierge-authenticator-name", "test-authenticator", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: webhookauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: webhookauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found` + "\n" + }, }, { name: "JWT authenticator not found", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-authenticator-type", "jwt", - "--concierge-authenticator-name", "test-authenticator", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", "test-authenticator", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: jwtauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: jwtauthenticators.authentication.concierge.pinniped.dev "test-authenticator" not found` + "\n" + }, }, { name: "invalid authenticator type", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-authenticator-type", "invalid", - "--concierge-authenticator-name", "test-authenticator", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-authenticator-type", "invalid", + "--concierge-authenticator-name", "test-authenticator", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: invalid authenticator type "invalid", supported values are "webhook" and "jwt" - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: invalid authenticator type "invalid", supported values are "webhook" and "jwt"` + "\n" + }, }, { name: "fail to autodetect authenticator, listing jwtauthenticators fails", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + } }, conciergeReactions: []kubetesting.Reactor{ &kubetesting.SimpleReactor{ @@ -278,17 +372,21 @@ func TestGetKubeconfig(t *testing.T) { }, }, wantError: true, - wantStderr: here.Doc(` - Error: failed to list JWTAuthenticator objects for autodiscovery: some list error - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: failed to list JWTAuthenticator objects for autodiscovery: some list error` + "\n" + }, }, { name: "fail to autodetect authenticator, listing webhookauthenticators fails", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, conciergeReactions: []kubetesting.Reactor{ &kubetesting.SimpleReactor{ @@ -299,310 +397,777 @@ func TestGetKubeconfig(t *testing.T) { }, }, }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: failed to list WebhookAuthenticator objects for autodiscovery: some list error - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: failed to list WebhookAuthenticator objects for autodiscovery: some list error` + "\n" + }, }, { name: "fail to autodetect authenticator, none found", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: no authenticators were found - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: no authenticators were found` + "\n" + }, }, { name: "fail to autodetect authenticator, multiple found", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, - &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-1"}}, - &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-2"}}, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-3"}}, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-4"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}}, + &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-1"}}, + &conciergev1alpha1.JWTAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-2"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-3"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator-4"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="found JWTAuthenticator" "name"="test-authenticator-1"`, - `"level"=0 "msg"="found JWTAuthenticator" "name"="test-authenticator-2"`, - `"level"=0 "msg"="found WebhookAuthenticator" "name"="test-authenticator-3"`, - `"level"=0 "msg"="found WebhookAuthenticator" "name"="test-authenticator-4"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="found JWTAuthenticator" "name"="test-authenticator-1"`, + `"level"=0 "msg"="found JWTAuthenticator" "name"="test-authenticator-2"`, + `"level"=0 "msg"="found WebhookAuthenticator" "name"="test-authenticator-3"`, + `"level"=0 "msg"="found WebhookAuthenticator" "name"="test-authenticator-4"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: multiple authenticators were found, so the --concierge-authenticator-type/--concierge-authenticator-name flags must be specified - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: multiple authenticators were found, so the --concierge-authenticator-type/--concierge-authenticator-name flags must be specified` + "\n" + }, }, { name: "autodetect webhook authenticator, bad credential issuer with only failing strategy", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: "SomeType", - Status: configv1alpha1.ErrorStrategyStatus, - Reason: "SomeReason", - Message: "Some message", - }}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, + Status: configv1alpha1.CredentialIssuerStatus{ + Strategies: []configv1alpha1.CredentialIssuerStrategy{{ + Type: "SomeType", + Status: configv1alpha1.ErrorStrategyStatus, + Reason: "SomeReason", + Message: "Some message", + }}, + }, }, - }, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="found CredentialIssuer strategy" "message"="Some message" "reason"="SomeReason" "status"="Error" "type"="SomeType"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="found CredentialIssuer strategy" "message"="Some message" "reason"="SomeReason" "status"="Error" "type"="SomeType"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: could not autodiscover --concierge-mode - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not autodiscover --concierge-mode` + "\n" + }, }, { name: "autodetect webhook authenticator, bad credential issuer with invalid impersonation CA", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{ - { - Type: "SomeBrokenType", - Status: configv1alpha1.ErrorStrategyStatus, - Reason: "SomeFailureReason", - Message: "Some error message", - LastUpdateTime: metav1.Now(), - }, - { - Type: "SomeUnknownType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeReason", - Message: "Some error message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: "SomeUnknownFrontendType", + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, + Status: configv1alpha1.CredentialIssuerStatus{ + Strategies: []configv1alpha1.CredentialIssuerStrategy{ + { + Type: "SomeBrokenType", + Status: configv1alpha1.ErrorStrategyStatus, + Reason: "SomeFailureReason", + Message: "Some error message", + LastUpdateTime: metav1.Now(), }, - }, - { - Type: "SomeType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeReason", - Message: "Some message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.ImpersonationProxyFrontendType, - ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ - Endpoint: "https://impersonation-endpoint", - CertificateAuthorityData: "invalid-base-64", + { + Type: "SomeUnknownType", + Status: configv1alpha1.SuccessStrategyStatus, + Reason: "SomeReason", + Message: "Some error message", + LastUpdateTime: metav1.Now(), + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: "SomeUnknownFrontendType", + }, + }, + { + Type: "SomeType", + Status: configv1alpha1.SuccessStrategyStatus, + Reason: "SomeReason", + Message: "Some message", + LastUpdateTime: metav1.Now(), + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: configv1alpha1.ImpersonationProxyFrontendType, + ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ + Endpoint: "https://impersonation-endpoint", + CertificateAuthorityData: "invalid-base-64", + }, }, }, }, }, }, - }, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in impersonation proxy mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-endpoint"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in impersonation proxy mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-endpoint"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: autodiscovered Concierge CA bundle is invalid: illegal base64 data at input byte 7 - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: autodiscovered Concierge CA bundle is invalid: illegal base64 data at input byte 7` + "\n" + }, }, { name: "autodetect webhook authenticator, missing --oidc-issuer", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: "SomeType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeReason", - Message: "Some message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, - TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ - Server: "https://concierge-endpoint", - CertificateAuthorityData: "ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==", - }, - }, - }}, - }, - }, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, - `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: could not autodiscover --oidc-issuer and none was provided - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not autodiscover --oidc-issuer and none was provided` + "\n" + }, }, { name: "autodetect JWT authenticator, invalid TLS bundle", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - KubeConfigInfo: &configv1alpha1.CredentialIssuerKubeConfigInfo{ - Server: "https://concierge-endpoint", - CertificateAuthorityData: "ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==", - }, - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, - Status: configv1alpha1.SuccessStrategyStatus, - Reason: configv1alpha1.FetchedKeyStrategyReason, - Message: "Successfully fetched key", - LastUpdateTime: metav1.Now(), - // Simulate a previous version of CredentialIssuer that's missing this Frontend field. - Frontend: nil, - }}, - }, - }, - &conciergev1alpha1.JWTAuthenticator{ - ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, - Spec: conciergev1alpha1.JWTAuthenticatorSpec{ - Issuer: "https://test-issuer.example.com", - Audience: "some-test-audience", - TLS: &conciergev1alpha1.TLSSpec{ - CertificateAuthorityData: "invalid-base64", + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, + Status: configv1alpha1.CredentialIssuerStatus{ + KubeConfigInfo: &configv1alpha1.CredentialIssuerKubeConfigInfo{ + Server: "https://concierge-endpoint", + CertificateAuthorityData: "ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==", + }, + Strategies: []configv1alpha1.CredentialIssuerStrategy{{ + Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, + Status: configv1alpha1.SuccessStrategyStatus, + Reason: configv1alpha1.FetchedKeyStrategyReason, + Message: "Successfully fetched key", + LastUpdateTime: metav1.Now(), + // Simulate a previous version of CredentialIssuer that's missing this Frontend field. + Frontend: nil, + }}, }, }, - }, + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + Issuer: issuerURL, + Audience: "some-test-audience", + TLS: &conciergev1alpha1.TLSSpec{ + CertificateAuthorityData: "invalid-base64", + }, + }, + }, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, - `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, - `"level"=0 "msg"="discovered OIDC issuer" "issuer"="https://test-issuer.example.com"`, - `"level"=0 "msg"="discovered OIDC audience" "audience"="some-test-audience"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="some-test-audience"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator test-authenticator has invalid spec.tls.certificateAuthorityData: illegal base64 data at input byte 7 - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator test-authenticator has invalid spec.tls.certificateAuthorityData: illegal base64 data at input byte 7` + "\n" + }, + }, + { + name: "fail to get self-path", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + } + }, + getPathToSelfErr: fmt.Errorf("some OS error"), + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: onlyIssuerOIDCDiscoveryResponse, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: could not determine the Pinniped executable path: some OS error` + "\n" + }, }, { name: "invalid static token flags", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--static-token", "test-token", - "--static-token-env", "TEST_TOKEN", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--static-token", "test-token", + "--static-token-env", "TEST_TOKEN", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: configv1alpha1.ImpersonationProxyStrategyType, - Status: configv1alpha1.SuccessStrategyStatus, - Reason: configv1alpha1.ListeningStrategyReason, - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.ImpersonationProxyFrontendType, - ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ - Endpoint: "https://impersonation-proxy-endpoint.example.com", - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - }, - }, - }}, - }, - }, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in impersonation proxy mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-proxy-endpoint.example.com"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=1`, - `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + } }, wantError: true, - wantStderr: here.Doc(` - Error: only one of --static-token and --static-token-env can be specified - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: only one of --static-token and --static-token-env can be specified` + "\n" + }, }, { name: "invalid API group suffix", - args: []string{ - "--concierge-api-group-suffix", ".starts.with.dot", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--concierge-api-group-suffix", ".starts.with.dot", + } }, wantError: true, - wantStderr: here.Doc(` - Error: invalid API group suffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*') - `), + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: invalid API group suffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')` + "\n" + }, + }, + { + name: "when OIDC discovery document 400s", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryStatusCode: http.StatusBadRequest, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return "Error: while fetching OIDC discovery data from issuer: 400 Bad Request: {}\n" + }, + }, + { + name: "when OIDC discovery document lists the wrong issuer", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: func(issuerURL string) string { + return here.Doc(`{ + "issuer": "https://wrong-issuer.com" + }`) + }, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return fmt.Sprintf( + "Error: while fetching OIDC discovery data from issuer: oidc: issuer did not match the issuer returned by provider, expected \"%s\" got \"https://wrong-issuer.com\"\n", + issuerURL) + }, + }, + { + name: "when IDP discovery document returns any error", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryStatusCode: http.StatusBadRequest, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return "Error: unable to fetch IDP discovery data from issuer: unexpected http response status: 400 Bad Request\n" + }, + }, + { + name: "when IDP discovery document contains multiple pinniped_idps and no name or type flags are given", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap"}, + {"name": "some-oidc-idp", "type": "oidc"} + ] + }`), + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: multiple Supervisor upstream identity providers were found, ` + + `so the --upstream-identity-provider-name/--upstream-identity-provider-type flags must be specified. ` + + `Found these upstreams: [{"name":"some-ldap-idp","type":"ldap"},{"name":"some-oidc-idp","type":"oidc"}]` + "\n" + }, + }, + { + name: "when OIDC discovery document is not valid JSON", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: func(issuerURL string) string { + return "this is not valid JSON" + }, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return "Error: while fetching OIDC discovery data from issuer: oidc: failed to decode provider discovery object: got Content-Type = application/json, but could not unmarshal as JSON: invalid character 'h' in literal true (expecting 'r')\n" + }, + }, + { + name: "when IDP discovery document is not valid JSON", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: "this is not valid JSON", + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return "Error: unable to fetch IDP discovery data from issuer: could not parse response JSON: invalid character 'h' in literal true (expecting 'r')\n" + }, + }, + { + name: "when tls information is missing from jwtauthenticator, errors because OIDC discovery fails", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + Issuer: issuerURL, + Audience: "test-audience", + }, + }, + } + }, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return fmt.Sprintf("Error: while fetching OIDC discovery data from issuer: Get \"%s/.well-known/openid-configuration\": x509: certificate signed by unknown authority\n", issuerURL) + }, + }, + { + name: "when the issuer url is bad", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--oidc-issuer", "https%://bad-issuer-url", // this url cannot be parsed + "--oidc-ca-bundle", f.Name(), + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, + Spec: conciergev1alpha1.JWTAuthenticatorSpec{ + Issuer: issuerURL, + Audience: "test-audience", + }, + }, + } + }, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: while fetching OIDC discovery data from issuer: parse "https%://bad-issuer-url/.well-known/openid-configuration": first path segment in URL cannot contain colon` + "\n" + }, + }, + { + name: "when the IDP discovery url is bad", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: func(issuerURL string) string { + return here.Docf(`{ + "issuer": "%s", + "discovery.supervisor.pinniped.dev/v1alpha1": { + "pinniped_identity_providers_endpoint": "https%%://illegal_url" + } + }`, issuerURL) + }, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: while forming request to IDP discovery URL: parse "https%://illegal_url": first path segment in URL cannot contain colon` + "\n" + }, + }, + { + name: "supervisor upstream IDP discovery fails to resolve ambiguity when type is specified but name is not", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + "--upstream-identity-provider-type", "ldap", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap"}, + {"name": "some-other-ldap-idp", "type": "ldap"}, + {"name": "some-oidc-idp", "type": "oidc"}, + {"name": "some-other-oidc-idp", "type": "oidc"} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: multiple Supervisor upstream identity providers of type "ldap" were found,` + + ` so the --upstream-identity-provider-name flag must be specified.` + + ` Found these upstreams: [{"name":"some-ldap-idp","type":"ldap"},{"name":"some-other-ldap-idp","type":"ldap"},{"name":"some-oidc-idp","type":"oidc"},{"name":"some-other-oidc-idp","type":"oidc"}]` + "\n" + }, + }, + { + name: "supervisor upstream IDP discovery fails to resolve ambiguity when name is specified but type is not", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + "--upstream-identity-provider-name", "my-idp", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "my-idp", "type": "ldap"}, + {"name": "my-idp", "type": "oidc"}, + {"name": "some-other-oidc-idp", "type": "oidc"} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: multiple Supervisor upstream identity providers with name "my-idp" were found,` + + ` so the --upstream-identity-provider-type flag must be specified.` + + ` Found these upstreams: [{"name":"my-idp","type":"ldap"},{"name":"my-idp","type":"oidc"},{"name":"some-other-oidc-idp","type":"oidc"}]` + "\n" + }, + }, + { + name: "supervisor upstream IDP discovery fails to find any matching idps when type is specified but name is not", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + "--upstream-identity-provider-type", "ldap", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-oidc-idp", "type": "oidc"}, + {"name": "some-other-oidc-idp", "type": "oidc"} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: no Supervisor upstream identity providers of type "ldap" were found.` + + ` Found these upstreams: [{"name":"some-oidc-idp","type":"oidc"},{"name":"some-other-oidc-idp","type":"oidc"}]` + "\n" + }, + }, + { + name: "supervisor upstream IDP discovery fails to find any matching idps when name is specified but type is not", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + "--upstream-identity-provider-name", "my-nonexistent-idp", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-oidc-idp", "type": "oidc"}, + {"name": "some-other-oidc-idp", "type": "oidc"} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: no Supervisor upstream identity providers with name "my-nonexistent-idp" were found.` + + ` Found these upstreams: [{"name":"some-oidc-idp","type":"oidc"},{"name":"some-other-oidc-idp","type":"oidc"}]` + "\n" + }, }, { name: "valid static token", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--static-token", "test-token", - "--skip-validation", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--static-token", "test-token", + "--skip-validation", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, - Status: configv1alpha1.SuccessStrategyStatus, - Reason: configv1alpha1.FetchedKeyStrategyReason, - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, - TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ - Server: "https://concierge-endpoint.example.com", - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - }, - }, - }}, - }, - }, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, - `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + } }, - wantStdout: here.Doc(` + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Doc(` apiVersion: v1 clusters: - cluster: @@ -635,44 +1200,36 @@ func TestGetKubeconfig(t *testing.T) { command: '.../path/to/pinniped' env: [] provideClusterInfo: true - `), + `) + }, }, { name: "valid static token from env var", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--static-token-env", "TEST_TOKEN", - "--skip-validation", - "--credential-cache", "", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--static-token-env", "TEST_TOKEN", + "--skip-validation", + "--credential-cache", "", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, - Status: configv1alpha1.SuccessStrategyStatus, - Reason: configv1alpha1.FetchedKeyStrategyReason, - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, - TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ - Server: "https://concierge-endpoint.example.com", - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - }, - }, - }}, - }, - }, - &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, - `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered WebhookAuthenticator" "name"="test-authenticator"`, + } }, - wantStdout: here.Doc(` + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Doc(` apiVersion: v1 clusters: - cluster: @@ -706,401 +1263,1118 @@ func TestGetKubeconfig(t *testing.T) { command: '.../path/to/pinniped' env: [] provideClusterInfo: true - `), + `) + }, }, { name: "autodetect JWT authenticator", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--skip-validation", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, - Status: configv1alpha1.SuccessStrategyStatus, - Reason: configv1alpha1.FetchedKeyStrategyReason, - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, - TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ - Server: "https://concierge-endpoint.example.com", - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - }, - }, - }}, - }, - }, - &conciergev1alpha1.JWTAuthenticator{ - ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, - Spec: conciergev1alpha1.JWTAuthenticatorSpec{ - Issuer: "https://example.com/issuer", - Audience: "test-audience", - TLS: &conciergev1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testOIDCCA.Bundle()), - }, - }, - }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, - `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, - `"level"=0 "msg"="discovered OIDC issuer" "issuer"="https://example.com/issuer"`, - `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, - `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + oidcDiscoveryResponse: onlyIssuerOIDCDiscoveryResponse, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) }, - wantStdout: here.Docf(` - apiVersion: v1 - clusters: - - cluster: - certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - server: https://fake-server-url-value - name: kind-cluster-pinniped - contexts: - - context: - cluster: kind-cluster-pinniped - user: kind-user-pinniped - name: kind-context-pinniped - current-context: kind-context-pinniped - kind: Config - preferences: {} - users: - - name: kind-user-pinniped - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - args: - - login - - oidc - - --enable-concierge - - --concierge-api-group-suffix=pinniped.dev - - --concierge-authenticator-name=test-authenticator - - --concierge-authenticator-type=jwt - - --concierge-endpoint=https://fake-server-url-value - - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== - - --issuer=https://example.com/issuer - - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience - - --ca-bundle-data=%s - - --request-audience=test-audience - command: '.../path/to/pinniped' - env: [] - provideClusterInfo: true - `, base64.StdEncoding.EncodeToString(testOIDCCA.Bundle())), }, { + name: "autodetect nothing, set a bunch of options", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-credential-issuer", "test-credential-issuer", - "--concierge-api-group-suffix", "tuna.io", - "--concierge-authenticator-type", "webhook", - "--concierge-authenticator-name", "test-authenticator", - "--concierge-mode", "TokenCredentialRequestAPI", - "--concierge-endpoint", "https://explicit-concierge-endpoint.example.com", - "--concierge-ca-bundle", testConciergeCABundlePath, - "--oidc-issuer", "https://example.com/issuer", - "--oidc-skip-browser", - "--oidc-listen-port", "1234", - "--oidc-ca-bundle", testOIDCCABundlePath, - "--oidc-session-cache", "/path/to/cache/dir/sessions.yaml", - "--oidc-debug-session-cache", - "--oidc-request-audience", "test-audience", - "--skip-validation", - "--generated-name-suffix", "-sso", - "--credential-cache", "/path/to/cache/dir/credentials.yaml", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-credential-issuer", "test-credential-issuer", + "--concierge-api-group-suffix", "tuna.io", + "--concierge-authenticator-type", "webhook", + "--concierge-authenticator-name", "test-authenticator", + "--concierge-mode", "TokenCredentialRequestAPI", + "--concierge-endpoint", "https://explicit-concierge-endpoint.example.com", + "--concierge-ca-bundle", testConciergeCABundlePath, + "--oidc-issuer", issuerURL, + "--oidc-skip-browser", + "--oidc-listen-port", "1234", + "--oidc-ca-bundle", f.Name(), + "--oidc-session-cache", "/path/to/cache/dir/sessions.yaml", + "--oidc-debug-session-cache", + "--oidc-request-audience", "test-audience", + "--skip-validation", + "--generated-name-suffix", "-sso", + "--credential-cache", "/path/to/cache/dir/credentials.yaml", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{{ - Type: configv1alpha1.KubeClusterSigningCertificateStrategyType, - Status: configv1alpha1.SuccessStrategyStatus, - Reason: configv1alpha1.FetchedKeyStrategyReason, - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, - TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ - Server: "https://concierge-endpoint.example.com", - CertificateAuthorityData: "dGVzdC10Y3ItYXBpLWNh", - }, - }, - }}, - }, - }, - &conciergev1alpha1.WebhookAuthenticator{ - ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, - }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + &conciergev1alpha1.WebhookAuthenticator{ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}}, + } + }, + oidcDiscoveryResponse: onlyIssuerOIDCDiscoveryResponse, + wantLogs: func(issuerCABundle string, issuerURL string) []string { return nil }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: %s + server: https://explicit-concierge-endpoint.example.com + name: kind-cluster-sso + contexts: + - context: + cluster: kind-cluster-sso + user: kind-user-sso + name: kind-context-sso + current-context: kind-context-sso + kind: Config + preferences: {} + users: + - name: kind-user-sso + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=tuna.io + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=webhook + - --concierge-endpoint=https://explicit-concierge-endpoint.example.com + - --concierge-ca-bundle-data=%s + - --credential-cache=/path/to/cache/dir/credentials.yaml + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --skip-browser + - --listen-port=1234 + - --ca-bundle-data=%s + - --session-cache=/path/to/cache/dir/sessions.yaml + - --debug-session-cache + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle)), + ) }, - wantLogs: nil, - wantStdout: here.Docf(` - apiVersion: v1 - clusters: - - cluster: - certificate-authority-data: %s - server: https://explicit-concierge-endpoint.example.com - name: kind-cluster-sso - contexts: - - context: - cluster: kind-cluster-sso - user: kind-user-sso - name: kind-context-sso - current-context: kind-context-sso - kind: Config - preferences: {} - users: - - name: kind-user-sso - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - args: - - login - - oidc - - --enable-concierge - - --concierge-api-group-suffix=tuna.io - - --concierge-authenticator-name=test-authenticator - - --concierge-authenticator-type=webhook - - --concierge-endpoint=https://explicit-concierge-endpoint.example.com - - --concierge-ca-bundle-data=%s - - --credential-cache=/path/to/cache/dir/credentials.yaml - - --issuer=https://example.com/issuer - - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience - - --skip-browser - - --listen-port=1234 - - --ca-bundle-data=%s - - --session-cache=/path/to/cache/dir/sessions.yaml - - --debug-session-cache - - --request-audience=test-audience - command: '.../path/to/pinniped' - env: [] - provideClusterInfo: true - `, - base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - base64.StdEncoding.EncodeToString(testOIDCCA.Bundle()), - ), wantAPIGroupSuffix: "tuna.io", }, { name: "configure impersonation proxy with autodiscovered JWT authenticator", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--concierge-mode", "ImpersonationProxy", - "--skip-validation", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--concierge-mode", "ImpersonationProxy", + "--skip-validation", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{ - // This TokenCredentialRequestAPI strategy would normally be chosen, but - // --concierge-mode=ImpersonationProxy should force it to be skipped. - { - Type: "SomeType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeReason", - Message: "Some message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, - TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ - Server: "https://token-credential-request-api-endpoint.test", - CertificateAuthorityData: "dGVzdC10Y3ItYXBpLWNh", + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, + Status: configv1alpha1.CredentialIssuerStatus{ + Strategies: []configv1alpha1.CredentialIssuerStrategy{ + // This TokenCredentialRequestAPI strategy would normally be chosen, but + // --concierge-mode=ImpersonationProxy should force it to be skipped. + { + Type: "SomeType", + Status: configv1alpha1.SuccessStrategyStatus, + Reason: "SomeReason", + Message: "Some message", + LastUpdateTime: metav1.Now(), + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: configv1alpha1.TokenCredentialRequestAPIFrontendType, + TokenCredentialRequestAPIInfo: &configv1alpha1.TokenCredentialRequestAPIInfo{ + Server: "https://token-credential-request-api-endpoint.test", + CertificateAuthorityData: "dGVzdC10Y3ItYXBpLWNh", + }, }, }, - }, - // The endpoint and CA from this impersonation proxy strategy should be autodiscovered. - { - Type: "SomeOtherType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeOtherReason", - Message: "Some other message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.ImpersonationProxyFrontendType, - ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ - Endpoint: "https://impersonation-proxy-endpoint.test", - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + // The endpoint and CA from this impersonation proxy strategy should be autodiscovered. + { + Type: "SomeOtherType", + Status: configv1alpha1.SuccessStrategyStatus, + Reason: "SomeOtherReason", + Message: "Some other message", + LastUpdateTime: metav1.Now(), + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: configv1alpha1.ImpersonationProxyFrontendType, + ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ + Endpoint: "https://impersonation-proxy-endpoint.test", + CertificateAuthorityData: base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + }, }, }, }, }, }, - }, - &conciergev1alpha1.JWTAuthenticator{ - ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, - Spec: conciergev1alpha1.JWTAuthenticatorSpec{ - Issuer: "https://example.com/issuer", - Audience: "test-audience", - TLS: &conciergev1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testOIDCCA.Bundle()), - }, - }, - }, + jwtAuthenticator(issuerCABundle, issuerURL), + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-proxy-endpoint.test"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=1`, - `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, - `"level"=0 "msg"="discovered OIDC issuer" "issuer"="https://example.com/issuer"`, - `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, - `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + oidcDiscoveryResponse: onlyIssuerOIDCDiscoveryResponse, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-proxy-endpoint.test"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=1`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: %s + server: https://impersonation-proxy-endpoint.test + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://impersonation-proxy-endpoint.test + - --concierge-ca-bundle-data=%s + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle)), + ) }, - wantStdout: here.Docf(` - apiVersion: v1 - clusters: - - cluster: - certificate-authority-data: %s - server: https://impersonation-proxy-endpoint.test - name: kind-cluster-pinniped - contexts: - - context: - cluster: kind-cluster-pinniped - user: kind-user-pinniped - name: kind-context-pinniped - current-context: kind-context-pinniped - kind: Config - preferences: {} - users: - - name: kind-user-pinniped - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - args: - - login - - oidc - - --enable-concierge - - --concierge-api-group-suffix=pinniped.dev - - --concierge-authenticator-name=test-authenticator - - --concierge-authenticator-type=jwt - - --concierge-endpoint=https://impersonation-proxy-endpoint.test - - --concierge-ca-bundle-data=%s - - --issuer=https://example.com/issuer - - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience - - --ca-bundle-data=%s - - --request-audience=test-audience - command: '.../path/to/pinniped' - env: [] - provideClusterInfo: true - `, - base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - base64.StdEncoding.EncodeToString(testConciergeCA.Bundle()), - base64.StdEncoding.EncodeToString(testOIDCCA.Bundle()), - ), }, { name: "autodetect impersonation proxy with autodiscovered JWT authenticator", - args: []string{ - "--kubeconfig", "./testdata/kubeconfig.yaml", - "--skip-validation", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } }, - conciergeObjects: []runtime.Object{ - &configv1alpha1.CredentialIssuer{ - ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, - Status: configv1alpha1.CredentialIssuerStatus{ - Strategies: []configv1alpha1.CredentialIssuerStrategy{ - { - Type: "SomeType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeReason", - Message: "Some message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.ImpersonationProxyFrontendType, - ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ - Endpoint: "https://impersonation-proxy-endpoint.test", - CertificateAuthorityData: "dGVzdC1jb25jaWVyZ2UtY2E=", + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + &configv1alpha1.CredentialIssuer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-credential-issuer"}, + Status: configv1alpha1.CredentialIssuerStatus{ + Strategies: []configv1alpha1.CredentialIssuerStrategy{ + { + Type: "SomeType", + Status: configv1alpha1.SuccessStrategyStatus, + Reason: "SomeReason", + Message: "Some message", + LastUpdateTime: metav1.Now(), + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: configv1alpha1.ImpersonationProxyFrontendType, + ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ + Endpoint: "https://impersonation-proxy-endpoint.test", + CertificateAuthorityData: "dGVzdC1jb25jaWVyZ2UtY2E=", + }, }, }, - }, - { - Type: "SomeOtherType", - Status: configv1alpha1.SuccessStrategyStatus, - Reason: "SomeOtherReason", - Message: "Some other message", - LastUpdateTime: metav1.Now(), - Frontend: &configv1alpha1.CredentialIssuerFrontend{ - Type: configv1alpha1.ImpersonationProxyFrontendType, - ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ - Endpoint: "https://some-other-impersonation-endpoint", - CertificateAuthorityData: "dGVzdC1jb25jaWVyZ2UtY2E=", + { + Type: "SomeOtherType", + Status: configv1alpha1.SuccessStrategyStatus, + Reason: "SomeOtherReason", + Message: "Some other message", + LastUpdateTime: metav1.Now(), + Frontend: &configv1alpha1.CredentialIssuerFrontend{ + Type: configv1alpha1.ImpersonationProxyFrontendType, + ImpersonationProxyInfo: &configv1alpha1.ImpersonationProxyInfo{ + Endpoint: "https://some-other-impersonation-endpoint", + CertificateAuthorityData: "dGVzdC1jb25jaWVyZ2UtY2E=", + }, }, }, }, }, }, - }, - &conciergev1alpha1.JWTAuthenticator{ - ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"}, - Spec: conciergev1alpha1.JWTAuthenticatorSpec{ - Issuer: "https://example.com/issuer", - Audience: "test-audience", - TLS: &conciergev1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(testOIDCCA.Bundle()), - }, - }, - }, + jwtAuthenticator(issuerCABundle, issuerURL), + } }, - wantLogs: []string{ - `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, - `"level"=0 "msg"="discovered Concierge operating in impersonation proxy mode"`, - `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-proxy-endpoint.test"`, - `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, - `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, - `"level"=0 "msg"="discovered OIDC issuer" "issuer"="https://example.com/issuer"`, - `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, - `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + oidcDiscoveryResponse: onlyIssuerOIDCDiscoveryResponse, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in impersonation proxy mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://impersonation-proxy-endpoint.test"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: dGVzdC1jb25jaWVyZ2UtY2E= + server: https://impersonation-proxy-endpoint.test + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://impersonation-proxy-endpoint.test + - --concierge-ca-bundle-data=dGVzdC1jb25jaWVyZ2UtY2E= + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "Find LDAP IDP in IDP discovery document, output ldap related flags", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap"} + ] + }`), + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + - --upstream-identity-provider-name=some-ldap-idp + - --upstream-identity-provider-type=ldap + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "Find OIDC IDP in IDP discovery document, output oidc related flags", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-oidc-idp", "type": "oidc"} + ] + }`), + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + - --upstream-identity-provider-name=some-oidc-idp + - --upstream-identity-provider-type=oidc + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "empty IDP list in IDP discovery document", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [] + }`), + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "Supervisor discovery section is not listed in OIDC discovery document", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: onlyIssuerOIDCDiscoveryResponse, + idpsDiscoveryStatusCode: http.StatusBadRequest, // IDPs endpoint shouldn't be called by this test + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "IDP discovery endpoint is not listed in OIDC discovery document within the Supervisor discovery section", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: func(issuerURL string) string { + return here.Docf(`{ + "issuer": "%s", + "discovery.supervisor.pinniped.dev/v1alpha1": { + "wrong-key": "some-value" + } + }`, issuerURL) + }, + idpsDiscoveryStatusCode: http.StatusBadRequest, // IDPs endpoint shouldn't be called by this test + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "when upstream idp related flags are sent, pass them through", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--upstream-identity-provider-name=some-oidc-idp", + "--upstream-identity-provider-type=oidc", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryStatusCode: http.StatusNotFound, + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + - --upstream-identity-provider-name=some-oidc-idp + - --upstream-identity-provider-type=oidc + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "when upstream IDP related flags are sent, pass them through even when IDP discovery shows a different IDP", + args: func(issuerCABundle string, issuerURL string) []string { + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--upstream-identity-provider-name=some-oidc-idp", + "--upstream-identity-provider-type=oidc", + } + }, + conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { + return []runtime.Object{ + credentialIssuer(), + jwtAuthenticator(issuerCABundle, issuerURL), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-other-ldap-idp", "type": "ldap"} + ] + }`), + wantLogs: func(issuerCABundle string, issuerURL string) []string { + return []string{ + `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, + `"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`, + `"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`, + `"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`, + `"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`, + fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL), + `"level"=0 "msg"="discovered OIDC audience" "audience"="test-audience"`, + `"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`, + } + }, + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --enable-concierge + - --concierge-api-group-suffix=pinniped.dev + - --concierge-authenticator-name=test-authenticator + - --concierge-authenticator-type=jwt + - --concierge-endpoint=https://fake-server-url-value + - --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --request-audience=test-audience + - --upstream-identity-provider-name=some-oidc-idp + - --upstream-identity-provider-type=oidc + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "supervisor upstream IDP discovery still works when --no-concierge is used", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap"} + ] + }`), + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --upstream-identity-provider-name=some-ldap-idp + - --upstream-identity-provider-type=ldap + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "supervisor upstream IDP discovery resolves ambiguity when type is specified but name is not", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + "--upstream-identity-provider-type", "ldap", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap"}, + {"name": "some-oidc-idp", "type": "oidc"}, + {"name": "some-other-oidc-idp", "type": "oidc"} + ] + }`), + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --upstream-identity-provider-name=some-ldap-idp + - --upstream-identity-provider-type=ldap + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "supervisor upstream IDP discovery resolves ambiguity when name is specified but type is not", + args: func(issuerCABundle string, issuerURL string) []string { + f := testutil.WriteStringToTempFile(t, "testca-*.pem", issuerCABundle) + return []string{ + "--kubeconfig", "./testdata/kubeconfig.yaml", + "--skip-validation", + "--no-concierge", + "--oidc-issuer", issuerURL, + "--oidc-ca-bundle", f.Name(), + "--upstream-identity-provider-name", "some-ldap-idp", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap"}, + {"name": "some-oidc-idp", "type": "oidc"}, + {"name": "some-other-oidc-idp", "type": "oidc"} + ] + }`), + wantStdout: func(issuerCABundle string, issuerURL string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== + server: https://fake-server-url-value + name: kind-cluster-pinniped + contexts: + - context: + cluster: kind-cluster-pinniped + user: kind-user-pinniped + name: kind-context-pinniped + current-context: kind-context-pinniped + kind: Config + preferences: {} + users: + - name: kind-user-pinniped + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - login + - oidc + - --issuer=%s + - --client-id=pinniped-cli + - --scopes=offline_access,openid,pinniped:request-audience + - --ca-bundle-data=%s + - --upstream-identity-provider-name=some-ldap-idp + - --upstream-identity-provider-type=ldap + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) }, - wantStdout: here.Docf(` - apiVersion: v1 - clusters: - - cluster: - certificate-authority-data: dGVzdC1jb25jaWVyZ2UtY2E= - server: https://impersonation-proxy-endpoint.test - name: kind-cluster-pinniped - contexts: - - context: - cluster: kind-cluster-pinniped - user: kind-user-pinniped - name: kind-context-pinniped - current-context: kind-context-pinniped - kind: Config - preferences: {} - users: - - name: kind-user-pinniped - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - args: - - login - - oidc - - --enable-concierge - - --concierge-api-group-suffix=pinniped.dev - - --concierge-authenticator-name=test-authenticator - - --concierge-authenticator-type=jwt - - --concierge-endpoint=https://impersonation-proxy-endpoint.test - - --concierge-ca-bundle-data=dGVzdC1jb25jaWVyZ2UtY2E= - - --issuer=https://example.com/issuer - - --client-id=pinniped-cli - - --scopes=offline_access,openid,pinniped:request-audience - - --ca-bundle-data=%s - - --request-audience=test-audience - command: '.../path/to/pinniped' - env: [] - provideClusterInfo: true - `, base64.StdEncoding.EncodeToString(testOIDCCA.Bundle())), }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + var issuerEndpointPtr *string + issuerCABundle, issuerEndpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + switch r.URL.Path { + case "/.well-known/openid-configuration": + jsonResponseBody := "{}" + if tt.oidcDiscoveryResponse != nil { + jsonResponseBody = tt.oidcDiscoveryResponse(*issuerEndpointPtr) + } + if tt.oidcDiscoveryStatusCode == 0 { + tt.oidcDiscoveryStatusCode = http.StatusOK + } + w.WriteHeader(tt.oidcDiscoveryStatusCode) + _, err = w.Write([]byte(jsonResponseBody)) + require.NoError(t, err) + case "/v1alpha1/pinniped_identity_providers": + jsonResponseBody := tt.idpsDiscoveryResponse + if tt.idpsDiscoveryResponse == "" { + jsonResponseBody = "{}" + } + if tt.idpsDiscoveryStatusCode == 0 { + tt.idpsDiscoveryStatusCode = http.StatusOK + } + w.WriteHeader(tt.idpsDiscoveryStatusCode) + _, err = w.Write([]byte(jsonResponseBody)) + require.NoError(t, err) + default: + t.Fatalf("tried to call issuer at a path that wasn't one of the expected discovery endpoints.") + } + }) + issuerEndpointPtr = &issuerEndpoint + testLog := testlogger.New(t) cmd := kubeconfigCommand(kubeconfigDeps{ getPathToSelf: func() (string, error) { @@ -1118,7 +2392,10 @@ func TestGetKubeconfig(t *testing.T) { if tt.getClientsetErr != nil { return nil, tt.getClientsetErr } - fake := fakeconciergeclientset.NewSimpleClientset(tt.conciergeObjects...) + fake := fakeconciergeclientset.NewSimpleClientset() + if tt.conciergeObjects != nil { + fake = fakeconciergeclientset.NewSimpleClientset(tt.conciergeObjects(issuerCABundle, issuerEndpoint)...) + } if len(tt.conciergeReactions) > 0 { fake.ReactionChain = append(tt.conciergeReactions, fake.ReactionChain...) } @@ -1131,16 +2408,33 @@ func TestGetKubeconfig(t *testing.T) { var stdout, stderr bytes.Buffer cmd.SetOut(&stdout) cmd.SetErr(&stderr) - cmd.SetArgs(tt.args) + + cmd.SetArgs(tt.args(issuerCABundle, issuerEndpoint)) + err := cmd.Execute() if tt.wantError { require.Error(t, err) } else { require.NoError(t, err) } - testLog.Expect(tt.wantLogs) - require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") - require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + + var expectedLogs []string + if tt.wantLogs != nil { + expectedLogs = tt.wantLogs(issuerCABundle, issuerEndpoint) + } + testLog.Expect(expectedLogs) + + expectedStdout := "" + if tt.wantStdout != nil { + expectedStdout = tt.wantStdout(issuerCABundle, issuerEndpoint) + } + require.Equal(t, expectedStdout, stdout.String(), "unexpected stdout") + + expectedStderr := "" + if tt.wantStderr != nil { + expectedStderr = tt.wantStderr(issuerCABundle, issuerEndpoint) + } + require.Equal(t, expectedStderr, stderr.String(), "unexpected stderr") }) } } diff --git a/cmd/pinniped/cmd/login.go b/cmd/pinniped/cmd/login.go index d1d1d151..95e2541d 100644 --- a/cmd/pinniped/cmd/login.go +++ b/cmd/pinniped/cmd/login.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 cmd diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 4c1620be..83542c01 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -54,23 +54,25 @@ func oidcLoginCommandRealDeps() oidcLoginCommandDeps { } type oidcLoginFlags struct { - issuer string - clientID string - listenPort uint16 - scopes []string - skipBrowser bool - sessionCachePath string - caBundlePaths []string - caBundleData []string - debugSessionCache bool - requestAudience string - conciergeEnabled bool - conciergeAuthenticatorType string - conciergeAuthenticatorName string - conciergeEndpoint string - conciergeCABundle string - conciergeAPIGroupSuffix string - credentialCachePath string + issuer string + clientID string + listenPort uint16 + scopes []string + skipBrowser bool + sessionCachePath string + caBundlePaths []string + caBundleData []string + debugSessionCache bool + requestAudience string + conciergeEnabled bool + conciergeAuthenticatorType string + conciergeAuthenticatorName string + conciergeEndpoint string + conciergeCABundle string + conciergeAPIGroupSuffix string + credentialCachePath string + upstreamIdentityProviderName string + upstreamIdentityProviderType string } func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { @@ -102,6 +104,8 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge") cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix") cmd.Flags().StringVar(&flags.credentialCachePath, "credential-cache", filepath.Join(mustGetConfigDir(), "credentials.yaml"), "Path to cluster-specific credentials cache (\"\" disables the cache)") + cmd.Flags().StringVar(&flags.upstreamIdentityProviderName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor") + cmd.Flags().StringVar(&flags.upstreamIdentityProviderType, "upstream-identity-provider-type", "oidc", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')") mustMarkHidden(cmd, "debug-session-cache") mustMarkRequired(cmd, "issuer") @@ -113,7 +117,7 @@ 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) @@ -147,6 +151,23 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin opts = append(opts, oidcclient.WithRequestAudience(flags.requestAudience)) } + if flags.upstreamIdentityProviderName != "" { + opts = append(opts, oidcclient.WithUpstreamIdentityProvider( + flags.upstreamIdentityProviderName, flags.upstreamIdentityProviderType)) + } + + switch flags.upstreamIdentityProviderType { + case "oidc": + // this is the default, so don't need to do anything + case "ldap": + opts = append(opts, oidcclient.WithCLISendingCredentials()) + default: + // Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236 + return fmt.Errorf( + "--upstream-identity-provider-type value not recognized: %s (supported values: oidc, ldap)", + flags.upstreamIdentityProviderType) + } + var concierge *conciergeclient.Client if flags.conciergeEnabled { var err error diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 68dc18d7..b31d7021 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -60,23 +60,25 @@ func TestLoginOIDCCommand(t *testing.T) { oidc --issuer ISSUER [flags] Flags: - --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated) - --ca-bundle-data strings Base64 encoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated) - --client-id string OpenID Connect client ID (default "pinniped-cli") - --concierge-api-group-suffix string Concierge API group suffix (default "pinniped.dev") - --concierge-authenticator-name string Concierge authenticator name - --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') - --concierge-ca-bundle-data string CA bundle to use when connecting to the Concierge - --concierge-endpoint string API base for the Concierge endpoint - --credential-cache string Path to cluster-specific credentials cache ("" disables the cache) (default "` + cfgDir + `/credentials.yaml") - --enable-concierge Use the Concierge to login - -h, --help help for oidc - --issuer string OpenID Connect issuer URL - --listen-port uint16 TCP port for localhost listener (authorization code flow only) - --request-audience string Request a token with an alternate audience using RFC8693 token exchange - --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience]) - --session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml") - --skip-browser Skip opening the browser (just print the URL) + --ca-bundle strings Path to TLS certificate authority bundle (PEM format, optional, can be repeated) + --ca-bundle-data strings Base64 encoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated) + --client-id string OpenID Connect client ID (default "pinniped-cli") + --concierge-api-group-suffix string Concierge API group suffix (default "pinniped.dev") + --concierge-authenticator-name string Concierge authenticator name + --concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt') + --concierge-ca-bundle-data string CA bundle to use when connecting to the Concierge + --concierge-endpoint string API base for the Concierge endpoint + --credential-cache string Path to cluster-specific credentials cache ("" disables the cache) (default "` + cfgDir + `/credentials.yaml") + --enable-concierge Use the Concierge to login + -h, --help help for oidc + --issuer string OpenID Connect issuer URL + --listen-port uint16 TCP port for localhost listener (authorization code flow only) + --request-audience string Request a token with an alternate audience using RFC8693 token exchange + --scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience]) + --session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml") + --skip-browser Skip opening the browser (just print the URL) + --upstream-identity-provider-name string The name of the upstream identity provider used during login with a Supervisor + --upstream-identity-provider-type string The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap') (default "oidc") `), }, { @@ -138,11 +140,45 @@ func TestLoginOIDCCommand(t *testing.T) { Error: invalid Concierge parameters: invalid API group suffix: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*') `), }, + { + name: "invalid upstream type", + args: []string{ + "--issuer", "test-issuer", + "--upstream-identity-provider-type", "invalid", + }, + wantError: true, + wantStderr: here.Doc(` + Error: --upstream-identity-provider-type value not recognized: invalid (supported values: oidc, ldap) + `), + }, + { + name: "oidc upstream type is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "oidc", + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution + }, + wantOptionsCount: 4, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", + }, + { + name: "ldap upstream type is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "ldap", + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution + }, + wantOptionsCount: 5, + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", + }, { name: "login error", args: []string{ "--client-id", "test-client-id", "--issuer", "test-issuer", + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, loginErr: fmt.Errorf("some login error"), wantOptionsCount: 4, @@ -160,6 +196,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", "test-authenticator", "--concierge-endpoint", "https://127.0.0.1:1234/", + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, conciergeErr: fmt.Errorf("some concierge error"), wantOptionsCount: 4, @@ -173,6 +210,7 @@ func TestLoginOIDCCommand(t *testing.T) { args: []string{ "--client-id", "test-client-id", "--issuer", "test-issuer", + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution }, env: map[string]string{"PINNIPED_DEBUG": "true"}, wantOptionsCount: 4, @@ -180,7 +218,6 @@ func TestLoginOIDCCommand(t *testing.T) { 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\"", - "\"level\"=0 \"msg\"=\"Pinniped login: caching cluster credential for future use.\"", }, }, { @@ -200,10 +237,12 @@ func TestLoginOIDCCommand(t *testing.T) { "--concierge-endpoint", "https://127.0.0.1:1234/", "--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()), "--concierge-api-group-suffix", "some.suffix.com", - "--credential-cache", testutil.TempDir(t) + "/credentials.yaml", + "--credential-cache", testutil.TempDir(t) + "/credentials.yaml", // must specify --credential-cache or else the cache file on disk causes test pollution + "--upstream-identity-provider-name", "some-upstream-name", + "--upstream-identity-provider-type", "ldap", }, env: map[string]string{"PINNIPED_DEBUG": "true"}, - wantOptionsCount: 8, + 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\"", diff --git a/deploy/supervisor/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml b/deploy/supervisor/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml new file mode 100644 index 00000000..d396129d --- /dev/null +++ b/deploy/supervisor/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml @@ -0,0 +1,234 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: ldapidentityproviders.idp.supervisor.pinniped.dev +spec: + group: idp.supervisor.pinniped.dev + names: + categories: + - pinniped + - pinniped-idp + - pinniped-idps + kind: LDAPIdentityProvider + listKind: LDAPIdentityProviderList + plural: ldapidentityproviders + singular: ldapidentityprovider + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.host + name: Host + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LDAPIdentityProvider describes the configuration of an upstream + Lightweight Directory Access Protocol (LDAP) identity provider. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec for configuring the identity provider. + properties: + bind: + description: Bind contains the configuration for how to provide access + credentials during an initial bind to the LDAP server to be allowed + to perform searches and binds to validate a user's credentials during + a user's authentication attempt. + properties: + secretName: + description: SecretName contains the name of a namespace-local + Secret object that provides the username and password for an + LDAP bind user. This account will be used to perform LDAP searches. + The Secret should be of type "kubernetes.io/basic-auth" which + includes "username" and "password" keys. The username value + should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + The password must be non-empty. + minLength: 1 + type: string + required: + - secretName + type: object + host: + description: 'Host is the hostname of this LDAP identity provider, + i.e., where to connect. For example: ldap.example.com:636.' + minLength: 1 + type: string + tls: + description: TLS contains the connection settings for how to establish + the connection to the Host. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object + userSearch: + description: UserSearch contains the configuration for searching for + a user by name in the LDAP provider. + properties: + attributes: + description: Attributes specifies how the user's information should + be read from the LDAP entry which was found as the result of + the user search. + properties: + uid: + description: UID specifies the name of the attribute in the + LDAP entry which whose value shall be used to uniquely identify + the user within this LDAP provider after a successful authentication. + E.g. "uidNumber" or "objectGUID". The value of this field + is case-sensitive and must match the case of the attribute + name returned by the LDAP server in the user's entry. Distinguished + names can be used by specifying lower-case "dn". + minLength: 1 + type: string + username: + description: Username specifies the name of attribute in the + LDAP entry which whose value shall become the username of + the user after a successful authentication. This would typically + be the same attribute name used in the user search filter, + although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + The value of this field is case-sensitive and must match + the case of the attribute name returned by the LDAP server + in the user's entry. Distinguished names can be used by + specifying lower-case "dn". When this field is set to "dn" + then the LDAPIdentityProviderUserSearch's Filter field cannot + be blank, since the default value of "dn={}" would not work. + minLength: 1 + type: string + type: object + base: + description: Base is the DN that should be used as the search + base when searching for users. E.g. "ou=users,dc=example,dc=com". + minLength: 1 + type: string + filter: + description: Filter is the LDAP search filter which should be + applied when searching for users. The pattern "{}" must occur + in the filter and will be dynamically replaced by the username + for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". + For more information about LDAP filters, see https://ldap.com/ldap-filters. + Note that the dn (distinguished name) is not an attribute of + an entry, so "dn={}" cannot be used. Optional. When not specified, + the default will act as if the Filter were specified as the + value from Attributes.Username appended by "={}". When the Attributes.Username + is set to "dn" then the Filter must be explicitly specified, + since the default value of "dn={}" would not work. + type: string + type: object + required: + - host + type: object + status: + description: Status of the identity provider. + properties: + conditions: + description: Represents the observations of an identity provider's + current state. + items: + description: Condition status of a resource (mirrored from the metav1.Condition + type added in Kubernetes 1.19). In a future API version we can + switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the LDAPIdentityProvider. + enum: + - Pending + - Ready + - Error + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/deploy/supervisor/rbac.yaml b/deploy/supervisor/rbac.yaml index cb84f342..60447f7c 100644 --- a/deploy/supervisor/rbac.yaml +++ b/deploy/supervisor/rbac.yaml @@ -32,6 +32,14 @@ rules: - #@ pinnipedDevAPIGroupWithPrefix("idp.supervisor") resources: [oidcidentityproviders/status] verbs: [get, patch, update] + - apiGroups: + - #@ pinnipedDevAPIGroupWithPrefix("idp.supervisor") + resources: [ldapidentityproviders] + verbs: [get, list, watch] + - apiGroups: + - #@ pinnipedDevAPIGroupWithPrefix("idp.supervisor") + resources: [ldapidentityproviders/status] + verbs: [get, patch, update] #! We want to be able to read pods/replicasets/deployment so we can learn who our deployment is to set #! as an owner reference. - apiGroups: [""] diff --git a/deploy/supervisor/z0_crd_overlay.yaml b/deploy/supervisor/z0_crd_overlay.yaml index c3bb8173..8e2dca11 100644 --- a/deploy/supervisor/z0_crd_overlay.yaml +++ b/deploy/supervisor/z0_crd_overlay.yaml @@ -22,3 +22,12 @@ metadata: name: #@ pinnipedDevAPIGroupWithPrefix("oidcidentityproviders.idp.supervisor") spec: group: #@ pinnipedDevAPIGroupWithPrefix("idp.supervisor") + +#@overlay/match by=overlay.subset({"kind": "CustomResourceDefinition", "metadata":{"name":"ldapidentityproviders.idp.supervisor.pinniped.dev"}}), expects=1 +--- +metadata: + #@overlay/match missing_ok=True + labels: #@ labels() + name: #@ pinnipedDevAPIGroupWithPrefix("ldapidentityproviders.idp.supervisor") +spec: + group: #@ pinnipedDevAPIGroupWithPrefix("idp.supervisor") diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc index 25267e8c..edda79aa 100644 --- a/generated/1.17/README.adoc +++ b/generated/1.17/README.adoc @@ -669,10 +669,11 @@ Package v1alpha1 is the v1alpha1 version of the Pinniped supervisor identity pro [id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-condition"] ==== Condition -Condition status of a resource (mirrored from the metav1.Condition type added in Kubernetes 1.19). In a future API version we can switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus[$$OIDCIdentityProviderStatus$$] **** @@ -688,6 +689,132 @@ Condition status of a resource (mirrored from the metav1.Condition type added in |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-conditionstatus"] +==== ConditionStatus (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] +**** + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityprovider"] +==== LDAPIdentityProvider + +LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access Protocol (LDAP) identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderlist[$$LDAPIdentityProviderList$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + +| *`spec`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$]__ | Spec for configuring the identity provider. +| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$]__ | Status of the identity provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind"] +==== LDAPIdentityProviderBind + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`secretName`* __string__ | SecretName contains the name of a namespace-local Secret object that provides the username and password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". The password must be non-empty. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec"] +==== LDAPIdentityProviderSpec + +Spec for configuring an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`host`* __string__ | Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS contains the connection settings for how to establish the connection to the Host. +| *`bind`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind[$$LDAPIdentityProviderBind$$]__ | Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. +| *`userSearch`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$]__ | UserSearch contains the configuration for searching for a user by name in the LDAP provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus"] +==== LDAPIdentityProviderStatus + +Status of an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`phase`* __LDAPIdentityProviderPhase__ | Phase summarizes the overall status of the LDAPIdentityProvider. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch"] +==== LDAPIdentityProviderUserSearch + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`base`* __string__ | Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". +| *`filter`* __string__ | Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. Optional. When not specified, the default will act as if the Filter were specified as the value from Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be explicitly specified, since the default value of "dn={}" would not work. +| *`attributes`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes[$$LDAPIdentityProviderUserSearchAttributes$$]__ | Attributes specifies how the user's information should be read from the LDAP entry which was found as the result of the user search. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes"] +==== LDAPIdentityProviderUserSearchAttributes + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username specifies the name of attribute in the LDAP entry which whose value shall become the username of the user after a successful authentication. This would typically be the same attribute name used in the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default value of "dn={}" would not work. +| *`uid`* __string__ | UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig"] ==== OIDCAuthorizationConfig @@ -797,7 +924,7 @@ Status of an OIDC identity provider. |=== | Field | Description | *`phase`* __OIDCIdentityProviderPhase__ | Phase summarizes the overall status of the OIDCIdentityProvider. -| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-condition[$$Condition$$]__ | Represents the observations of an identity provider's current state. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. |=== @@ -808,6 +935,7 @@ Status of an OIDC identity provider. .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec[$$OIDCIdentityProviderSpec$$] **** diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/register.go b/generated/1.17/apis/supervisor/idp/v1alpha1/register.go index c03b7dde..ddc9c360 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/register.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/register.go @@ -32,6 +32,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &OIDCIdentityProvider{}, &OIDCIdentityProviderList{}, + &LDAPIdentityProvider{}, + &LDAPIdentityProviderList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go b/generated/1.17/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go new file mode 100644 index 00000000..d718ba65 --- /dev/null +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go @@ -0,0 +1,132 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LDAPIdentityProviderPhase string + +const ( + // LDAPPhasePending is the default phase for newly-created LDAPIdentityProvider resources. + LDAPPhasePending LDAPIdentityProviderPhase = "Pending" + + // LDAPPhaseReady is the phase for an LDAPIdentityProvider resource in a healthy state. + LDAPPhaseReady LDAPIdentityProviderPhase = "Ready" + + // LDAPPhaseError is the phase for an LDAPIdentityProvider in an unhealthy state. + LDAPPhaseError LDAPIdentityProviderPhase = "Error" +) + +// Status of an LDAP identity provider. +type LDAPIdentityProviderStatus struct { + // Phase summarizes the overall status of the LDAPIdentityProvider. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase LDAPIdentityProviderPhase `json:"phase,omitempty"` + + // Represents the observations of an identity provider's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +type LDAPIdentityProviderBind struct { + // SecretName contains the name of a namespace-local Secret object that provides the username and + // password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be + // of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value + // should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + // The password must be non-empty. + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +type LDAPIdentityProviderUserSearchAttributes struct { + // Username specifies the name of attribute in the LDAP entry which whose value shall become the username + // of the user after a successful authentication. This would typically be the same attribute name used in + // the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field + // is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default + // value of "dn={}" would not work. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username,omitempty"` + + // UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely + // identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid,omitempty"` +} + +type LDAPIdentityProviderUserSearch struct { + // Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". + // +kubebuilder:validation:MinLength=1 + Base string `json:"base,omitempty"` + + // Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur + // in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" + // or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. + // Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. + // Optional. When not specified, the default will act as if the Filter were specified as the value from + // Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be + // explicitly specified, since the default value of "dn={}" would not work. + // +optional + Filter string `json:"filter,omitempty"` + + // Attributes specifies how the user's information should be read from the LDAP entry which was found as + // the result of the user search. + // +optional + Attributes LDAPIdentityProviderUserSearchAttributes `json:"attributes,omitempty"` +} + +// Spec for configuring an LDAP identity provider. +type LDAPIdentityProviderSpec struct { + // Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // TLS contains the connection settings for how to establish the connection to the Host. + TLS *TLSSpec `json:"tls,omitempty"` + + // Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server + // to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. + Bind LDAPIdentityProviderBind `json:"bind,omitempty"` + + // UserSearch contains the configuration for searching for a user by name in the LDAP provider. + UserSearch LDAPIdentityProviderUserSearch `json:"userSearch,omitempty"` +} + +// LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access +// Protocol (LDAP) identity provider. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:categories=pinniped;pinniped-idp;pinniped-idps +// +kubebuilder:printcolumn:name="Host",type=string,JSONPath=`.spec.host` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +type LDAPIdentityProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec for configuring the identity provider. + Spec LDAPIdentityProviderSpec `json:"spec"` + + // Status of the identity provider. + Status LDAPIdentityProviderStatus `json:"status,omitempty"` +} + +// List of LDAPIdentityProvider objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LDAPIdentityProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LDAPIdentityProvider `json:"items"` +} diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index b1f0447f..c48c570f 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -28,6 +28,162 @@ func (in *Condition) DeepCopy() *Condition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProvider) DeepCopyInto(out *LDAPIdentityProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProvider. +func (in *LDAPIdentityProvider) DeepCopy() *LDAPIdentityProvider { + if in == nil { + return nil + } + out := new(LDAPIdentityProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderBind) DeepCopyInto(out *LDAPIdentityProviderBind) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderBind. +func (in *LDAPIdentityProviderBind) DeepCopy() *LDAPIdentityProviderBind { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderBind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderList) DeepCopyInto(out *LDAPIdentityProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LDAPIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderList. +func (in *LDAPIdentityProviderList) DeepCopy() *LDAPIdentityProviderList { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderSpec) DeepCopyInto(out *LDAPIdentityProviderSpec) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } + out.Bind = in.Bind + out.UserSearch = in.UserSearch + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderSpec. +func (in *LDAPIdentityProviderSpec) DeepCopy() *LDAPIdentityProviderSpec { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderStatus) DeepCopyInto(out *LDAPIdentityProviderStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderStatus. +func (in *LDAPIdentityProviderStatus) DeepCopy() *LDAPIdentityProviderStatus { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearch) DeepCopyInto(out *LDAPIdentityProviderUserSearch) { + *out = *in + out.Attributes = in.Attributes + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearch. +func (in *LDAPIdentityProviderUserSearch) DeepCopy() *LDAPIdentityProviderUserSearch { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopyInto(out *LDAPIdentityProviderUserSearchAttributes) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearchAttributes. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopy() *LDAPIdentityProviderUserSearchAttributes { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearchAttributes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = *in diff --git a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go index 5f6bf990..23859d83 100644 --- a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go +++ b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go @@ -15,6 +15,10 @@ type FakeIDPV1alpha1 struct { *testing.Fake } +func (c *FakeIDPV1alpha1) LDAPIdentityProviders(namespace string) v1alpha1.LDAPIdentityProviderInterface { + return &FakeLDAPIdentityProviders{c, namespace} +} + func (c *FakeIDPV1alpha1) OIDCIdentityProviders(namespace string) v1alpha1.OIDCIdentityProviderInterface { return &FakeOIDCIdentityProviders{c, namespace} } diff --git a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go new file mode 100644 index 00000000..e78bc2df --- /dev/null +++ b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go @@ -0,0 +1,127 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "go.pinniped.dev/generated/1.17/apis/supervisor/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeLDAPIdentityProviders implements LDAPIdentityProviderInterface +type FakeLDAPIdentityProviders struct { + Fake *FakeIDPV1alpha1 + ns string +} + +var ldapidentityprovidersResource = schema.GroupVersionResource{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Resource: "ldapidentityproviders"} + +var ldapidentityprovidersKind = schema.GroupVersionKind{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Kind: "LDAPIdentityProvider"} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *FakeLDAPIdentityProviders) Get(name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *FakeLDAPIdentityProviders) List(opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(ldapidentityprovidersResource, ldapidentityprovidersKind, c.ns, opts), &v1alpha1.LDAPIdentityProviderList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.LDAPIdentityProviderList{ListMeta: obj.(*v1alpha1.LDAPIdentityProviderList).ListMeta} + for _, item := range obj.(*v1alpha1.LDAPIdentityProviderList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *FakeLDAPIdentityProviders) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(ldapidentityprovidersResource, c.ns, opts)) + +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Create(lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Update(lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeLDAPIdentityProviders) UpdateStatus(lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider) (*v1alpha1.LDAPIdentityProvider, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(ldapidentityprovidersResource, "status", c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *FakeLDAPIdentityProviders) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLDAPIdentityProviders) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(ldapidentityprovidersResource, c.ns, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha1.LDAPIdentityProviderList{}) + return err +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *FakeLDAPIdentityProviders) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(ldapidentityprovidersResource, c.ns, name, pt, data, subresources...), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} diff --git a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go index 79c7e697..137892f3 100644 --- a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go +++ b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go @@ -5,4 +5,6 @@ package v1alpha1 +type LDAPIdentityProviderExpansion interface{} + type OIDCIdentityProviderExpansion interface{} diff --git a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go index 85943b30..9176e752 100644 --- a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go +++ b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go @@ -13,6 +13,7 @@ import ( type IDPV1alpha1Interface interface { RESTClient() rest.Interface + LDAPIdentityProvidersGetter OIDCIdentityProvidersGetter } @@ -21,6 +22,10 @@ type IDPV1alpha1Client struct { restClient rest.Interface } +func (c *IDPV1alpha1Client) LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface { + return newLDAPIdentityProviders(c, namespace) +} + func (c *IDPV1alpha1Client) OIDCIdentityProviders(namespace string) OIDCIdentityProviderInterface { return newOIDCIdentityProviders(c, namespace) } diff --git a/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..4410da01 --- /dev/null +++ b/generated/1.17/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,178 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "time" + + v1alpha1 "go.pinniped.dev/generated/1.17/apis/supervisor/idp/v1alpha1" + scheme "go.pinniped.dev/generated/1.17/client/supervisor/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// LDAPIdentityProvidersGetter has a method to return a LDAPIdentityProviderInterface. +// A group's client should implement this interface. +type LDAPIdentityProvidersGetter interface { + LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface +} + +// LDAPIdentityProviderInterface has methods to work with LDAPIdentityProvider resources. +type LDAPIdentityProviderInterface interface { + Create(*v1alpha1.LDAPIdentityProvider) (*v1alpha1.LDAPIdentityProvider, error) + Update(*v1alpha1.LDAPIdentityProvider) (*v1alpha1.LDAPIdentityProvider, error) + UpdateStatus(*v1alpha1.LDAPIdentityProvider) (*v1alpha1.LDAPIdentityProvider, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha1.LDAPIdentityProvider, error) + List(opts v1.ListOptions) (*v1alpha1.LDAPIdentityProviderList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) + LDAPIdentityProviderExpansion +} + +// lDAPIdentityProviders implements LDAPIdentityProviderInterface +type lDAPIdentityProviders struct { + client rest.Interface + ns string +} + +// newLDAPIdentityProviders returns a LDAPIdentityProviders +func newLDAPIdentityProviders(c *IDPV1alpha1Client, namespace string) *lDAPIdentityProviders { + return &lDAPIdentityProviders{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *lDAPIdentityProviders) Get(name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *lDAPIdentityProviders) List(opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.LDAPIdentityProviderList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *lDAPIdentityProviders) Watch(opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch() +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Create(lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Post(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Body(lDAPIdentityProvider). + Do(). + Into(result) + return +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Update(lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + Body(lDAPIdentityProvider). + Do(). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + +func (c *lDAPIdentityProviders) UpdateStatus(lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + SubResource("status"). + Body(lDAPIdentityProvider). + Do(). + Into(result) + return +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *lDAPIdentityProviders) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *lDAPIdentityProviders) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + var timeout time.Duration + if listOptions.TimeoutSeconds != nil { + timeout = time.Duration(*listOptions.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Timeout(timeout). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *lDAPIdentityProviders) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("ldapidentityproviders"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/generated/1.17/client/supervisor/informers/externalversions/generic.go b/generated/1.17/client/supervisor/informers/externalversions/generic.go index 29fd9ef4..f65c952d 100644 --- a/generated/1.17/client/supervisor/informers/externalversions/generic.go +++ b/generated/1.17/client/supervisor/informers/externalversions/generic.go @@ -45,6 +45,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Config().V1alpha1().FederationDomains().Informer()}, nil // Group=idp.supervisor.pinniped.dev, Version=v1alpha1 + case idpv1alpha1.SchemeGroupVersion.WithResource("ldapidentityproviders"): + return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().LDAPIdentityProviders().Informer()}, nil case idpv1alpha1.SchemeGroupVersion.WithResource("oidcidentityproviders"): return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().OIDCIdentityProviders().Informer()}, nil diff --git a/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go b/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go index 533df089..b7677ddb 100644 --- a/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go +++ b/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go @@ -11,6 +11,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // LDAPIdentityProviders returns a LDAPIdentityProviderInformer. + LDAPIdentityProviders() LDAPIdentityProviderInformer // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. OIDCIdentityProviders() OIDCIdentityProviderInformer } @@ -26,6 +28,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// LDAPIdentityProviders returns a LDAPIdentityProviderInformer. +func (v *version) LDAPIdentityProviders() LDAPIdentityProviderInformer { + return &lDAPIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. func (v *version) OIDCIdentityProviders() OIDCIdentityProviderInformer { return &oIDCIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go b/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..af8c9e06 --- /dev/null +++ b/generated/1.17/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,76 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + time "time" + + idpv1alpha1 "go.pinniped.dev/generated/1.17/apis/supervisor/idp/v1alpha1" + versioned "go.pinniped.dev/generated/1.17/client/supervisor/clientset/versioned" + internalinterfaces "go.pinniped.dev/generated/1.17/client/supervisor/informers/externalversions/internalinterfaces" + v1alpha1 "go.pinniped.dev/generated/1.17/client/supervisor/listers/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderInformer provides access to a shared informer and lister for +// LDAPIdentityProviders. +type LDAPIdentityProviderInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.LDAPIdentityProviderLister +} + +type lDAPIdentityProviderInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).Watch(options) + }, + }, + &idpv1alpha1.LDAPIdentityProvider{}, + resyncPeriod, + indexers, + ) +} + +func (f *lDAPIdentityProviderInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *lDAPIdentityProviderInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&idpv1alpha1.LDAPIdentityProvider{}, f.defaultInformer) +} + +func (f *lDAPIdentityProviderInformer) Lister() v1alpha1.LDAPIdentityProviderLister { + return v1alpha1.NewLDAPIdentityProviderLister(f.Informer().GetIndexer()) +} diff --git a/generated/1.17/client/supervisor/listers/idp/v1alpha1/expansion_generated.go b/generated/1.17/client/supervisor/listers/idp/v1alpha1/expansion_generated.go index e754c985..28f41bd7 100644 --- a/generated/1.17/client/supervisor/listers/idp/v1alpha1/expansion_generated.go +++ b/generated/1.17/client/supervisor/listers/idp/v1alpha1/expansion_generated.go @@ -5,6 +5,14 @@ package v1alpha1 +// LDAPIdentityProviderListerExpansion allows custom methods to be added to +// LDAPIdentityProviderLister. +type LDAPIdentityProviderListerExpansion interface{} + +// LDAPIdentityProviderNamespaceListerExpansion allows custom methods to be added to +// LDAPIdentityProviderNamespaceLister. +type LDAPIdentityProviderNamespaceListerExpansion interface{} + // OIDCIdentityProviderListerExpansion allows custom methods to be added to // OIDCIdentityProviderLister. type OIDCIdentityProviderListerExpansion interface{} diff --git a/generated/1.17/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go b/generated/1.17/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..9544d75d --- /dev/null +++ b/generated/1.17/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,81 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "go.pinniped.dev/generated/1.17/apis/supervisor/idp/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderLister helps list LDAPIdentityProviders. +type LDAPIdentityProviderLister interface { + // List lists all LDAPIdentityProviders in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. + LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister + LDAPIdentityProviderListerExpansion +} + +// lDAPIdentityProviderLister implements the LDAPIdentityProviderLister interface. +type lDAPIdentityProviderLister struct { + indexer cache.Indexer +} + +// NewLDAPIdentityProviderLister returns a new LDAPIdentityProviderLister. +func NewLDAPIdentityProviderLister(indexer cache.Indexer) LDAPIdentityProviderLister { + return &lDAPIdentityProviderLister{indexer: indexer} +} + +// List lists all LDAPIdentityProviders in the indexer. +func (s *lDAPIdentityProviderLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. +func (s *lDAPIdentityProviderLister) LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister { + return lDAPIdentityProviderNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// LDAPIdentityProviderNamespaceLister helps list and get LDAPIdentityProviders. +type LDAPIdentityProviderNamespaceLister interface { + // List lists all LDAPIdentityProviders in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.LDAPIdentityProvider, error) + LDAPIdentityProviderNamespaceListerExpansion +} + +// lDAPIdentityProviderNamespaceLister implements the LDAPIdentityProviderNamespaceLister +// interface. +type lDAPIdentityProviderNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all LDAPIdentityProviders in the indexer for a given namespace. +func (s lDAPIdentityProviderNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. +func (s lDAPIdentityProviderNamespaceLister) Get(name string) (*v1alpha1.LDAPIdentityProvider, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("ldapidentityprovider"), name) + } + return obj.(*v1alpha1.LDAPIdentityProvider), nil +} diff --git a/generated/1.17/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml b/generated/1.17/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml new file mode 100644 index 00000000..d396129d --- /dev/null +++ b/generated/1.17/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml @@ -0,0 +1,234 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: ldapidentityproviders.idp.supervisor.pinniped.dev +spec: + group: idp.supervisor.pinniped.dev + names: + categories: + - pinniped + - pinniped-idp + - pinniped-idps + kind: LDAPIdentityProvider + listKind: LDAPIdentityProviderList + plural: ldapidentityproviders + singular: ldapidentityprovider + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.host + name: Host + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LDAPIdentityProvider describes the configuration of an upstream + Lightweight Directory Access Protocol (LDAP) identity provider. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec for configuring the identity provider. + properties: + bind: + description: Bind contains the configuration for how to provide access + credentials during an initial bind to the LDAP server to be allowed + to perform searches and binds to validate a user's credentials during + a user's authentication attempt. + properties: + secretName: + description: SecretName contains the name of a namespace-local + Secret object that provides the username and password for an + LDAP bind user. This account will be used to perform LDAP searches. + The Secret should be of type "kubernetes.io/basic-auth" which + includes "username" and "password" keys. The username value + should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + The password must be non-empty. + minLength: 1 + type: string + required: + - secretName + type: object + host: + description: 'Host is the hostname of this LDAP identity provider, + i.e., where to connect. For example: ldap.example.com:636.' + minLength: 1 + type: string + tls: + description: TLS contains the connection settings for how to establish + the connection to the Host. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object + userSearch: + description: UserSearch contains the configuration for searching for + a user by name in the LDAP provider. + properties: + attributes: + description: Attributes specifies how the user's information should + be read from the LDAP entry which was found as the result of + the user search. + properties: + uid: + description: UID specifies the name of the attribute in the + LDAP entry which whose value shall be used to uniquely identify + the user within this LDAP provider after a successful authentication. + E.g. "uidNumber" or "objectGUID". The value of this field + is case-sensitive and must match the case of the attribute + name returned by the LDAP server in the user's entry. Distinguished + names can be used by specifying lower-case "dn". + minLength: 1 + type: string + username: + description: Username specifies the name of attribute in the + LDAP entry which whose value shall become the username of + the user after a successful authentication. This would typically + be the same attribute name used in the user search filter, + although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + The value of this field is case-sensitive and must match + the case of the attribute name returned by the LDAP server + in the user's entry. Distinguished names can be used by + specifying lower-case "dn". When this field is set to "dn" + then the LDAPIdentityProviderUserSearch's Filter field cannot + be blank, since the default value of "dn={}" would not work. + minLength: 1 + type: string + type: object + base: + description: Base is the DN that should be used as the search + base when searching for users. E.g. "ou=users,dc=example,dc=com". + minLength: 1 + type: string + filter: + description: Filter is the LDAP search filter which should be + applied when searching for users. The pattern "{}" must occur + in the filter and will be dynamically replaced by the username + for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". + For more information about LDAP filters, see https://ldap.com/ldap-filters. + Note that the dn (distinguished name) is not an attribute of + an entry, so "dn={}" cannot be used. Optional. When not specified, + the default will act as if the Filter were specified as the + value from Attributes.Username appended by "={}". When the Attributes.Username + is set to "dn" then the Filter must be explicitly specified, + since the default value of "dn={}" would not work. + type: string + type: object + required: + - host + type: object + status: + description: Status of the identity provider. + properties: + conditions: + description: Represents the observations of an identity provider's + current state. + items: + description: Condition status of a resource (mirrored from the metav1.Condition + type added in Kubernetes 1.19). In a future API version we can + switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the LDAPIdentityProvider. + enum: + - Pending + - Ready + - Error + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc index 2c01f927..cc33968f 100644 --- a/generated/1.18/README.adoc +++ b/generated/1.18/README.adoc @@ -669,10 +669,11 @@ Package v1alpha1 is the v1alpha1 version of the Pinniped supervisor identity pro [id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-condition"] ==== Condition -Condition status of a resource (mirrored from the metav1.Condition type added in Kubernetes 1.19). In a future API version we can switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus[$$OIDCIdentityProviderStatus$$] **** @@ -688,6 +689,132 @@ Condition status of a resource (mirrored from the metav1.Condition type added in |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-conditionstatus"] +==== ConditionStatus (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] +**** + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityprovider"] +==== LDAPIdentityProvider + +LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access Protocol (LDAP) identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderlist[$$LDAPIdentityProviderList$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + +| *`spec`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$]__ | Spec for configuring the identity provider. +| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$]__ | Status of the identity provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind"] +==== LDAPIdentityProviderBind + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`secretName`* __string__ | SecretName contains the name of a namespace-local Secret object that provides the username and password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". The password must be non-empty. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec"] +==== LDAPIdentityProviderSpec + +Spec for configuring an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`host`* __string__ | Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS contains the connection settings for how to establish the connection to the Host. +| *`bind`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind[$$LDAPIdentityProviderBind$$]__ | Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. +| *`userSearch`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$]__ | UserSearch contains the configuration for searching for a user by name in the LDAP provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus"] +==== LDAPIdentityProviderStatus + +Status of an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`phase`* __LDAPIdentityProviderPhase__ | Phase summarizes the overall status of the LDAPIdentityProvider. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch"] +==== LDAPIdentityProviderUserSearch + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`base`* __string__ | Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". +| *`filter`* __string__ | Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. Optional. When not specified, the default will act as if the Filter were specified as the value from Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be explicitly specified, since the default value of "dn={}" would not work. +| *`attributes`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes[$$LDAPIdentityProviderUserSearchAttributes$$]__ | Attributes specifies how the user's information should be read from the LDAP entry which was found as the result of the user search. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes"] +==== LDAPIdentityProviderUserSearchAttributes + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username specifies the name of attribute in the LDAP entry which whose value shall become the username of the user after a successful authentication. This would typically be the same attribute name used in the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default value of "dn={}" would not work. +| *`uid`* __string__ | UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig"] ==== OIDCAuthorizationConfig @@ -797,7 +924,7 @@ Status of an OIDC identity provider. |=== | Field | Description | *`phase`* __OIDCIdentityProviderPhase__ | Phase summarizes the overall status of the OIDCIdentityProvider. -| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-condition[$$Condition$$]__ | Represents the observations of an identity provider's current state. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. |=== @@ -808,6 +935,7 @@ Status of an OIDC identity provider. .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec[$$OIDCIdentityProviderSpec$$] **** diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/register.go b/generated/1.18/apis/supervisor/idp/v1alpha1/register.go index c03b7dde..ddc9c360 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/register.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/register.go @@ -32,6 +32,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &OIDCIdentityProvider{}, &OIDCIdentityProviderList{}, + &LDAPIdentityProvider{}, + &LDAPIdentityProviderList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go b/generated/1.18/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go new file mode 100644 index 00000000..d718ba65 --- /dev/null +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go @@ -0,0 +1,132 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LDAPIdentityProviderPhase string + +const ( + // LDAPPhasePending is the default phase for newly-created LDAPIdentityProvider resources. + LDAPPhasePending LDAPIdentityProviderPhase = "Pending" + + // LDAPPhaseReady is the phase for an LDAPIdentityProvider resource in a healthy state. + LDAPPhaseReady LDAPIdentityProviderPhase = "Ready" + + // LDAPPhaseError is the phase for an LDAPIdentityProvider in an unhealthy state. + LDAPPhaseError LDAPIdentityProviderPhase = "Error" +) + +// Status of an LDAP identity provider. +type LDAPIdentityProviderStatus struct { + // Phase summarizes the overall status of the LDAPIdentityProvider. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase LDAPIdentityProviderPhase `json:"phase,omitempty"` + + // Represents the observations of an identity provider's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +type LDAPIdentityProviderBind struct { + // SecretName contains the name of a namespace-local Secret object that provides the username and + // password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be + // of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value + // should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + // The password must be non-empty. + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +type LDAPIdentityProviderUserSearchAttributes struct { + // Username specifies the name of attribute in the LDAP entry which whose value shall become the username + // of the user after a successful authentication. This would typically be the same attribute name used in + // the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field + // is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default + // value of "dn={}" would not work. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username,omitempty"` + + // UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely + // identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid,omitempty"` +} + +type LDAPIdentityProviderUserSearch struct { + // Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". + // +kubebuilder:validation:MinLength=1 + Base string `json:"base,omitempty"` + + // Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur + // in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" + // or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. + // Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. + // Optional. When not specified, the default will act as if the Filter were specified as the value from + // Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be + // explicitly specified, since the default value of "dn={}" would not work. + // +optional + Filter string `json:"filter,omitempty"` + + // Attributes specifies how the user's information should be read from the LDAP entry which was found as + // the result of the user search. + // +optional + Attributes LDAPIdentityProviderUserSearchAttributes `json:"attributes,omitempty"` +} + +// Spec for configuring an LDAP identity provider. +type LDAPIdentityProviderSpec struct { + // Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // TLS contains the connection settings for how to establish the connection to the Host. + TLS *TLSSpec `json:"tls,omitempty"` + + // Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server + // to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. + Bind LDAPIdentityProviderBind `json:"bind,omitempty"` + + // UserSearch contains the configuration for searching for a user by name in the LDAP provider. + UserSearch LDAPIdentityProviderUserSearch `json:"userSearch,omitempty"` +} + +// LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access +// Protocol (LDAP) identity provider. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:categories=pinniped;pinniped-idp;pinniped-idps +// +kubebuilder:printcolumn:name="Host",type=string,JSONPath=`.spec.host` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +type LDAPIdentityProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec for configuring the identity provider. + Spec LDAPIdentityProviderSpec `json:"spec"` + + // Status of the identity provider. + Status LDAPIdentityProviderStatus `json:"status,omitempty"` +} + +// List of LDAPIdentityProvider objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LDAPIdentityProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LDAPIdentityProvider `json:"items"` +} diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index b1f0447f..c48c570f 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -28,6 +28,162 @@ func (in *Condition) DeepCopy() *Condition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProvider) DeepCopyInto(out *LDAPIdentityProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProvider. +func (in *LDAPIdentityProvider) DeepCopy() *LDAPIdentityProvider { + if in == nil { + return nil + } + out := new(LDAPIdentityProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderBind) DeepCopyInto(out *LDAPIdentityProviderBind) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderBind. +func (in *LDAPIdentityProviderBind) DeepCopy() *LDAPIdentityProviderBind { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderBind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderList) DeepCopyInto(out *LDAPIdentityProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LDAPIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderList. +func (in *LDAPIdentityProviderList) DeepCopy() *LDAPIdentityProviderList { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderSpec) DeepCopyInto(out *LDAPIdentityProviderSpec) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } + out.Bind = in.Bind + out.UserSearch = in.UserSearch + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderSpec. +func (in *LDAPIdentityProviderSpec) DeepCopy() *LDAPIdentityProviderSpec { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderStatus) DeepCopyInto(out *LDAPIdentityProviderStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderStatus. +func (in *LDAPIdentityProviderStatus) DeepCopy() *LDAPIdentityProviderStatus { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearch) DeepCopyInto(out *LDAPIdentityProviderUserSearch) { + *out = *in + out.Attributes = in.Attributes + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearch. +func (in *LDAPIdentityProviderUserSearch) DeepCopy() *LDAPIdentityProviderUserSearch { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopyInto(out *LDAPIdentityProviderUserSearchAttributes) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearchAttributes. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopy() *LDAPIdentityProviderUserSearchAttributes { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearchAttributes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = *in diff --git a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go index 8a54b83d..2c419ceb 100644 --- a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go +++ b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go @@ -15,6 +15,10 @@ type FakeIDPV1alpha1 struct { *testing.Fake } +func (c *FakeIDPV1alpha1) LDAPIdentityProviders(namespace string) v1alpha1.LDAPIdentityProviderInterface { + return &FakeLDAPIdentityProviders{c, namespace} +} + func (c *FakeIDPV1alpha1) OIDCIdentityProviders(namespace string) v1alpha1.OIDCIdentityProviderInterface { return &FakeOIDCIdentityProviders{c, namespace} } diff --git a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go new file mode 100644 index 00000000..26a4b77f --- /dev/null +++ b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go @@ -0,0 +1,129 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1alpha1 "go.pinniped.dev/generated/1.18/apis/supervisor/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeLDAPIdentityProviders implements LDAPIdentityProviderInterface +type FakeLDAPIdentityProviders struct { + Fake *FakeIDPV1alpha1 + ns string +} + +var ldapidentityprovidersResource = schema.GroupVersionResource{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Resource: "ldapidentityproviders"} + +var ldapidentityprovidersKind = schema.GroupVersionKind{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Kind: "LDAPIdentityProvider"} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *FakeLDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *FakeLDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(ldapidentityprovidersResource, ldapidentityprovidersKind, c.ns, opts), &v1alpha1.LDAPIdentityProviderList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.LDAPIdentityProviderList{ListMeta: obj.(*v1alpha1.LDAPIdentityProviderList).ListMeta} + for _, item := range obj.(*v1alpha1.LDAPIdentityProviderList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *FakeLDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(ldapidentityprovidersResource, c.ns, opts)) + +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeLDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(ldapidentityprovidersResource, "status", c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *FakeLDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(ldapidentityprovidersResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v1alpha1.LDAPIdentityProviderList{}) + return err +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *FakeLDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(ldapidentityprovidersResource, c.ns, name, pt, data, subresources...), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} diff --git a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go index 79c7e697..137892f3 100644 --- a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go +++ b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go @@ -5,4 +5,6 @@ package v1alpha1 +type LDAPIdentityProviderExpansion interface{} + type OIDCIdentityProviderExpansion interface{} diff --git a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go index b6f7504a..fdb10351 100644 --- a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go +++ b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go @@ -13,6 +13,7 @@ import ( type IDPV1alpha1Interface interface { RESTClient() rest.Interface + LDAPIdentityProvidersGetter OIDCIdentityProvidersGetter } @@ -21,6 +22,10 @@ type IDPV1alpha1Client struct { restClient rest.Interface } +func (c *IDPV1alpha1Client) LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface { + return newLDAPIdentityProviders(c, namespace) +} + func (c *IDPV1alpha1Client) OIDCIdentityProviders(namespace string) OIDCIdentityProviderInterface { return newOIDCIdentityProviders(c, namespace) } diff --git a/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..314b5d65 --- /dev/null +++ b/generated/1.18/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,182 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + v1alpha1 "go.pinniped.dev/generated/1.18/apis/supervisor/idp/v1alpha1" + scheme "go.pinniped.dev/generated/1.18/client/supervisor/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// LDAPIdentityProvidersGetter has a method to return a LDAPIdentityProviderInterface. +// A group's client should implement this interface. +type LDAPIdentityProvidersGetter interface { + LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface +} + +// LDAPIdentityProviderInterface has methods to work with LDAPIdentityProvider resources. +type LDAPIdentityProviderInterface interface { + Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.LDAPIdentityProvider, error) + List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.LDAPIdentityProviderList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) + LDAPIdentityProviderExpansion +} + +// lDAPIdentityProviders implements LDAPIdentityProviderInterface +type lDAPIdentityProviders struct { + client rest.Interface + ns string +} + +// newLDAPIdentityProviders returns a LDAPIdentityProviders +func newLDAPIdentityProviders(c *IDPV1alpha1Client, namespace string) *lDAPIdentityProviders { + return &lDAPIdentityProviders{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *lDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *lDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.LDAPIdentityProviderList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *lDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Post(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *lDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + SubResource("status"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *lDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *lDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *lDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/generated/1.18/client/supervisor/informers/externalversions/generic.go b/generated/1.18/client/supervisor/informers/externalversions/generic.go index be9458cf..9d15fcd2 100644 --- a/generated/1.18/client/supervisor/informers/externalversions/generic.go +++ b/generated/1.18/client/supervisor/informers/externalversions/generic.go @@ -45,6 +45,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Config().V1alpha1().FederationDomains().Informer()}, nil // Group=idp.supervisor.pinniped.dev, Version=v1alpha1 + case idpv1alpha1.SchemeGroupVersion.WithResource("ldapidentityproviders"): + return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().LDAPIdentityProviders().Informer()}, nil case idpv1alpha1.SchemeGroupVersion.WithResource("oidcidentityproviders"): return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().OIDCIdentityProviders().Informer()}, nil diff --git a/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go b/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go index 1b7936aa..009ad89c 100644 --- a/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go +++ b/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go @@ -11,6 +11,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // LDAPIdentityProviders returns a LDAPIdentityProviderInformer. + LDAPIdentityProviders() LDAPIdentityProviderInformer // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. OIDCIdentityProviders() OIDCIdentityProviderInformer } @@ -26,6 +28,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// LDAPIdentityProviders returns a LDAPIdentityProviderInformer. +func (v *version) LDAPIdentityProviders() LDAPIdentityProviderInformer { + return &lDAPIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. func (v *version) OIDCIdentityProviders() OIDCIdentityProviderInformer { return &oIDCIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go b/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..a9151158 --- /dev/null +++ b/generated/1.18/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,77 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + time "time" + + idpv1alpha1 "go.pinniped.dev/generated/1.18/apis/supervisor/idp/v1alpha1" + versioned "go.pinniped.dev/generated/1.18/client/supervisor/clientset/versioned" + internalinterfaces "go.pinniped.dev/generated/1.18/client/supervisor/informers/externalversions/internalinterfaces" + v1alpha1 "go.pinniped.dev/generated/1.18/client/supervisor/listers/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderInformer provides access to a shared informer and lister for +// LDAPIdentityProviders. +type LDAPIdentityProviderInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.LDAPIdentityProviderLister +} + +type lDAPIdentityProviderInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).Watch(context.TODO(), options) + }, + }, + &idpv1alpha1.LDAPIdentityProvider{}, + resyncPeriod, + indexers, + ) +} + +func (f *lDAPIdentityProviderInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *lDAPIdentityProviderInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&idpv1alpha1.LDAPIdentityProvider{}, f.defaultInformer) +} + +func (f *lDAPIdentityProviderInformer) Lister() v1alpha1.LDAPIdentityProviderLister { + return v1alpha1.NewLDAPIdentityProviderLister(f.Informer().GetIndexer()) +} diff --git a/generated/1.18/client/supervisor/listers/idp/v1alpha1/expansion_generated.go b/generated/1.18/client/supervisor/listers/idp/v1alpha1/expansion_generated.go index e754c985..28f41bd7 100644 --- a/generated/1.18/client/supervisor/listers/idp/v1alpha1/expansion_generated.go +++ b/generated/1.18/client/supervisor/listers/idp/v1alpha1/expansion_generated.go @@ -5,6 +5,14 @@ package v1alpha1 +// LDAPIdentityProviderListerExpansion allows custom methods to be added to +// LDAPIdentityProviderLister. +type LDAPIdentityProviderListerExpansion interface{} + +// LDAPIdentityProviderNamespaceListerExpansion allows custom methods to be added to +// LDAPIdentityProviderNamespaceLister. +type LDAPIdentityProviderNamespaceListerExpansion interface{} + // OIDCIdentityProviderListerExpansion allows custom methods to be added to // OIDCIdentityProviderLister. type OIDCIdentityProviderListerExpansion interface{} diff --git a/generated/1.18/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go b/generated/1.18/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..d7957df2 --- /dev/null +++ b/generated/1.18/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,81 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "go.pinniped.dev/generated/1.18/apis/supervisor/idp/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderLister helps list LDAPIdentityProviders. +type LDAPIdentityProviderLister interface { + // List lists all LDAPIdentityProviders in the indexer. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. + LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister + LDAPIdentityProviderListerExpansion +} + +// lDAPIdentityProviderLister implements the LDAPIdentityProviderLister interface. +type lDAPIdentityProviderLister struct { + indexer cache.Indexer +} + +// NewLDAPIdentityProviderLister returns a new LDAPIdentityProviderLister. +func NewLDAPIdentityProviderLister(indexer cache.Indexer) LDAPIdentityProviderLister { + return &lDAPIdentityProviderLister{indexer: indexer} +} + +// List lists all LDAPIdentityProviders in the indexer. +func (s *lDAPIdentityProviderLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. +func (s *lDAPIdentityProviderLister) LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister { + return lDAPIdentityProviderNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// LDAPIdentityProviderNamespaceLister helps list and get LDAPIdentityProviders. +type LDAPIdentityProviderNamespaceLister interface { + // List lists all LDAPIdentityProviders in the indexer for a given namespace. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. + Get(name string) (*v1alpha1.LDAPIdentityProvider, error) + LDAPIdentityProviderNamespaceListerExpansion +} + +// lDAPIdentityProviderNamespaceLister implements the LDAPIdentityProviderNamespaceLister +// interface. +type lDAPIdentityProviderNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all LDAPIdentityProviders in the indexer for a given namespace. +func (s lDAPIdentityProviderNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. +func (s lDAPIdentityProviderNamespaceLister) Get(name string) (*v1alpha1.LDAPIdentityProvider, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("ldapidentityprovider"), name) + } + return obj.(*v1alpha1.LDAPIdentityProvider), nil +} diff --git a/generated/1.18/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml b/generated/1.18/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml new file mode 100644 index 00000000..d396129d --- /dev/null +++ b/generated/1.18/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml @@ -0,0 +1,234 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: ldapidentityproviders.idp.supervisor.pinniped.dev +spec: + group: idp.supervisor.pinniped.dev + names: + categories: + - pinniped + - pinniped-idp + - pinniped-idps + kind: LDAPIdentityProvider + listKind: LDAPIdentityProviderList + plural: ldapidentityproviders + singular: ldapidentityprovider + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.host + name: Host + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LDAPIdentityProvider describes the configuration of an upstream + Lightweight Directory Access Protocol (LDAP) identity provider. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec for configuring the identity provider. + properties: + bind: + description: Bind contains the configuration for how to provide access + credentials during an initial bind to the LDAP server to be allowed + to perform searches and binds to validate a user's credentials during + a user's authentication attempt. + properties: + secretName: + description: SecretName contains the name of a namespace-local + Secret object that provides the username and password for an + LDAP bind user. This account will be used to perform LDAP searches. + The Secret should be of type "kubernetes.io/basic-auth" which + includes "username" and "password" keys. The username value + should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + The password must be non-empty. + minLength: 1 + type: string + required: + - secretName + type: object + host: + description: 'Host is the hostname of this LDAP identity provider, + i.e., where to connect. For example: ldap.example.com:636.' + minLength: 1 + type: string + tls: + description: TLS contains the connection settings for how to establish + the connection to the Host. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object + userSearch: + description: UserSearch contains the configuration for searching for + a user by name in the LDAP provider. + properties: + attributes: + description: Attributes specifies how the user's information should + be read from the LDAP entry which was found as the result of + the user search. + properties: + uid: + description: UID specifies the name of the attribute in the + LDAP entry which whose value shall be used to uniquely identify + the user within this LDAP provider after a successful authentication. + E.g. "uidNumber" or "objectGUID". The value of this field + is case-sensitive and must match the case of the attribute + name returned by the LDAP server in the user's entry. Distinguished + names can be used by specifying lower-case "dn". + minLength: 1 + type: string + username: + description: Username specifies the name of attribute in the + LDAP entry which whose value shall become the username of + the user after a successful authentication. This would typically + be the same attribute name used in the user search filter, + although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + The value of this field is case-sensitive and must match + the case of the attribute name returned by the LDAP server + in the user's entry. Distinguished names can be used by + specifying lower-case "dn". When this field is set to "dn" + then the LDAPIdentityProviderUserSearch's Filter field cannot + be blank, since the default value of "dn={}" would not work. + minLength: 1 + type: string + type: object + base: + description: Base is the DN that should be used as the search + base when searching for users. E.g. "ou=users,dc=example,dc=com". + minLength: 1 + type: string + filter: + description: Filter is the LDAP search filter which should be + applied when searching for users. The pattern "{}" must occur + in the filter and will be dynamically replaced by the username + for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". + For more information about LDAP filters, see https://ldap.com/ldap-filters. + Note that the dn (distinguished name) is not an attribute of + an entry, so "dn={}" cannot be used. Optional. When not specified, + the default will act as if the Filter were specified as the + value from Attributes.Username appended by "={}". When the Attributes.Username + is set to "dn" then the Filter must be explicitly specified, + since the default value of "dn={}" would not work. + type: string + type: object + required: + - host + type: object + status: + description: Status of the identity provider. + properties: + conditions: + description: Represents the observations of an identity provider's + current state. + items: + description: Condition status of a resource (mirrored from the metav1.Condition + type added in Kubernetes 1.19). In a future API version we can + switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the LDAPIdentityProvider. + enum: + - Pending + - Ready + - Error + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc index 0c2527ae..d0effe28 100644 --- a/generated/1.19/README.adoc +++ b/generated/1.19/README.adoc @@ -669,10 +669,11 @@ Package v1alpha1 is the v1alpha1 version of the Pinniped supervisor identity pro [id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-condition"] ==== Condition -Condition status of a resource (mirrored from the metav1.Condition type added in Kubernetes 1.19). In a future API version we can switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus[$$OIDCIdentityProviderStatus$$] **** @@ -688,6 +689,132 @@ Condition status of a resource (mirrored from the metav1.Condition type added in |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-conditionstatus"] +==== ConditionStatus (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] +**** + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityprovider"] +==== LDAPIdentityProvider + +LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access Protocol (LDAP) identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderlist[$$LDAPIdentityProviderList$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + +| *`spec`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$]__ | Spec for configuring the identity provider. +| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$]__ | Status of the identity provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind"] +==== LDAPIdentityProviderBind + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`secretName`* __string__ | SecretName contains the name of a namespace-local Secret object that provides the username and password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". The password must be non-empty. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec"] +==== LDAPIdentityProviderSpec + +Spec for configuring an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`host`* __string__ | Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS contains the connection settings for how to establish the connection to the Host. +| *`bind`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind[$$LDAPIdentityProviderBind$$]__ | Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. +| *`userSearch`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$]__ | UserSearch contains the configuration for searching for a user by name in the LDAP provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus"] +==== LDAPIdentityProviderStatus + +Status of an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`phase`* __LDAPIdentityProviderPhase__ | Phase summarizes the overall status of the LDAPIdentityProvider. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch"] +==== LDAPIdentityProviderUserSearch + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`base`* __string__ | Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". +| *`filter`* __string__ | Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. Optional. When not specified, the default will act as if the Filter were specified as the value from Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be explicitly specified, since the default value of "dn={}" would not work. +| *`attributes`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes[$$LDAPIdentityProviderUserSearchAttributes$$]__ | Attributes specifies how the user's information should be read from the LDAP entry which was found as the result of the user search. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes"] +==== LDAPIdentityProviderUserSearchAttributes + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username specifies the name of attribute in the LDAP entry which whose value shall become the username of the user after a successful authentication. This would typically be the same attribute name used in the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default value of "dn={}" would not work. +| *`uid`* __string__ | UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig"] ==== OIDCAuthorizationConfig @@ -797,7 +924,7 @@ Status of an OIDC identity provider. |=== | Field | Description | *`phase`* __OIDCIdentityProviderPhase__ | Phase summarizes the overall status of the OIDCIdentityProvider. -| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-condition[$$Condition$$]__ | Represents the observations of an identity provider's current state. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. |=== @@ -808,6 +935,7 @@ Status of an OIDC identity provider. .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec[$$OIDCIdentityProviderSpec$$] **** diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/register.go b/generated/1.19/apis/supervisor/idp/v1alpha1/register.go index c03b7dde..ddc9c360 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/register.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/register.go @@ -32,6 +32,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &OIDCIdentityProvider{}, &OIDCIdentityProviderList{}, + &LDAPIdentityProvider{}, + &LDAPIdentityProviderList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go b/generated/1.19/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go new file mode 100644 index 00000000..d718ba65 --- /dev/null +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go @@ -0,0 +1,132 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LDAPIdentityProviderPhase string + +const ( + // LDAPPhasePending is the default phase for newly-created LDAPIdentityProvider resources. + LDAPPhasePending LDAPIdentityProviderPhase = "Pending" + + // LDAPPhaseReady is the phase for an LDAPIdentityProvider resource in a healthy state. + LDAPPhaseReady LDAPIdentityProviderPhase = "Ready" + + // LDAPPhaseError is the phase for an LDAPIdentityProvider in an unhealthy state. + LDAPPhaseError LDAPIdentityProviderPhase = "Error" +) + +// Status of an LDAP identity provider. +type LDAPIdentityProviderStatus struct { + // Phase summarizes the overall status of the LDAPIdentityProvider. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase LDAPIdentityProviderPhase `json:"phase,omitempty"` + + // Represents the observations of an identity provider's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +type LDAPIdentityProviderBind struct { + // SecretName contains the name of a namespace-local Secret object that provides the username and + // password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be + // of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value + // should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + // The password must be non-empty. + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +type LDAPIdentityProviderUserSearchAttributes struct { + // Username specifies the name of attribute in the LDAP entry which whose value shall become the username + // of the user after a successful authentication. This would typically be the same attribute name used in + // the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field + // is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default + // value of "dn={}" would not work. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username,omitempty"` + + // UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely + // identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid,omitempty"` +} + +type LDAPIdentityProviderUserSearch struct { + // Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". + // +kubebuilder:validation:MinLength=1 + Base string `json:"base,omitempty"` + + // Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur + // in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" + // or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. + // Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. + // Optional. When not specified, the default will act as if the Filter were specified as the value from + // Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be + // explicitly specified, since the default value of "dn={}" would not work. + // +optional + Filter string `json:"filter,omitempty"` + + // Attributes specifies how the user's information should be read from the LDAP entry which was found as + // the result of the user search. + // +optional + Attributes LDAPIdentityProviderUserSearchAttributes `json:"attributes,omitempty"` +} + +// Spec for configuring an LDAP identity provider. +type LDAPIdentityProviderSpec struct { + // Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // TLS contains the connection settings for how to establish the connection to the Host. + TLS *TLSSpec `json:"tls,omitempty"` + + // Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server + // to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. + Bind LDAPIdentityProviderBind `json:"bind,omitempty"` + + // UserSearch contains the configuration for searching for a user by name in the LDAP provider. + UserSearch LDAPIdentityProviderUserSearch `json:"userSearch,omitempty"` +} + +// LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access +// Protocol (LDAP) identity provider. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:categories=pinniped;pinniped-idp;pinniped-idps +// +kubebuilder:printcolumn:name="Host",type=string,JSONPath=`.spec.host` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +type LDAPIdentityProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec for configuring the identity provider. + Spec LDAPIdentityProviderSpec `json:"spec"` + + // Status of the identity provider. + Status LDAPIdentityProviderStatus `json:"status,omitempty"` +} + +// List of LDAPIdentityProvider objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LDAPIdentityProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LDAPIdentityProvider `json:"items"` +} diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index b1f0447f..c48c570f 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -28,6 +28,162 @@ func (in *Condition) DeepCopy() *Condition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProvider) DeepCopyInto(out *LDAPIdentityProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProvider. +func (in *LDAPIdentityProvider) DeepCopy() *LDAPIdentityProvider { + if in == nil { + return nil + } + out := new(LDAPIdentityProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderBind) DeepCopyInto(out *LDAPIdentityProviderBind) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderBind. +func (in *LDAPIdentityProviderBind) DeepCopy() *LDAPIdentityProviderBind { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderBind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderList) DeepCopyInto(out *LDAPIdentityProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LDAPIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderList. +func (in *LDAPIdentityProviderList) DeepCopy() *LDAPIdentityProviderList { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderSpec) DeepCopyInto(out *LDAPIdentityProviderSpec) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } + out.Bind = in.Bind + out.UserSearch = in.UserSearch + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderSpec. +func (in *LDAPIdentityProviderSpec) DeepCopy() *LDAPIdentityProviderSpec { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderStatus) DeepCopyInto(out *LDAPIdentityProviderStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderStatus. +func (in *LDAPIdentityProviderStatus) DeepCopy() *LDAPIdentityProviderStatus { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearch) DeepCopyInto(out *LDAPIdentityProviderUserSearch) { + *out = *in + out.Attributes = in.Attributes + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearch. +func (in *LDAPIdentityProviderUserSearch) DeepCopy() *LDAPIdentityProviderUserSearch { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopyInto(out *LDAPIdentityProviderUserSearchAttributes) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearchAttributes. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopy() *LDAPIdentityProviderUserSearchAttributes { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearchAttributes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = *in diff --git a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go index 31ba495f..28e3d63f 100644 --- a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go +++ b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go @@ -15,6 +15,10 @@ type FakeIDPV1alpha1 struct { *testing.Fake } +func (c *FakeIDPV1alpha1) LDAPIdentityProviders(namespace string) v1alpha1.LDAPIdentityProviderInterface { + return &FakeLDAPIdentityProviders{c, namespace} +} + func (c *FakeIDPV1alpha1) OIDCIdentityProviders(namespace string) v1alpha1.OIDCIdentityProviderInterface { return &FakeOIDCIdentityProviders{c, namespace} } diff --git a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go new file mode 100644 index 00000000..84f3f24f --- /dev/null +++ b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go @@ -0,0 +1,129 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeLDAPIdentityProviders implements LDAPIdentityProviderInterface +type FakeLDAPIdentityProviders struct { + Fake *FakeIDPV1alpha1 + ns string +} + +var ldapidentityprovidersResource = schema.GroupVersionResource{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Resource: "ldapidentityproviders"} + +var ldapidentityprovidersKind = schema.GroupVersionKind{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Kind: "LDAPIdentityProvider"} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *FakeLDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *FakeLDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(ldapidentityprovidersResource, ldapidentityprovidersKind, c.ns, opts), &v1alpha1.LDAPIdentityProviderList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.LDAPIdentityProviderList{ListMeta: obj.(*v1alpha1.LDAPIdentityProviderList).ListMeta} + for _, item := range obj.(*v1alpha1.LDAPIdentityProviderList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *FakeLDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(ldapidentityprovidersResource, c.ns, opts)) + +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeLDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(ldapidentityprovidersResource, "status", c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *FakeLDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(ldapidentityprovidersResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v1alpha1.LDAPIdentityProviderList{}) + return err +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *FakeLDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(ldapidentityprovidersResource, c.ns, name, pt, data, subresources...), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} diff --git a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go index 79c7e697..137892f3 100644 --- a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go +++ b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go @@ -5,4 +5,6 @@ package v1alpha1 +type LDAPIdentityProviderExpansion interface{} + type OIDCIdentityProviderExpansion interface{} diff --git a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go index a8c21075..213c0601 100644 --- a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go +++ b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go @@ -13,6 +13,7 @@ import ( type IDPV1alpha1Interface interface { RESTClient() rest.Interface + LDAPIdentityProvidersGetter OIDCIdentityProvidersGetter } @@ -21,6 +22,10 @@ type IDPV1alpha1Client struct { restClient rest.Interface } +func (c *IDPV1alpha1Client) LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface { + return newLDAPIdentityProviders(c, namespace) +} + func (c *IDPV1alpha1Client) OIDCIdentityProviders(namespace string) OIDCIdentityProviderInterface { return newOIDCIdentityProviders(c, namespace) } diff --git a/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..ee93d165 --- /dev/null +++ b/generated/1.19/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,182 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + v1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" + scheme "go.pinniped.dev/generated/1.19/client/supervisor/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// LDAPIdentityProvidersGetter has a method to return a LDAPIdentityProviderInterface. +// A group's client should implement this interface. +type LDAPIdentityProvidersGetter interface { + LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface +} + +// LDAPIdentityProviderInterface has methods to work with LDAPIdentityProvider resources. +type LDAPIdentityProviderInterface interface { + Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.LDAPIdentityProvider, error) + List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.LDAPIdentityProviderList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) + LDAPIdentityProviderExpansion +} + +// lDAPIdentityProviders implements LDAPIdentityProviderInterface +type lDAPIdentityProviders struct { + client rest.Interface + ns string +} + +// newLDAPIdentityProviders returns a LDAPIdentityProviders +func newLDAPIdentityProviders(c *IDPV1alpha1Client, namespace string) *lDAPIdentityProviders { + return &lDAPIdentityProviders{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *lDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *lDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.LDAPIdentityProviderList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *lDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Post(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *lDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + SubResource("status"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *lDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *lDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *lDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/generated/1.19/client/supervisor/informers/externalversions/generic.go b/generated/1.19/client/supervisor/informers/externalversions/generic.go index 4f7a345d..8308f3a9 100644 --- a/generated/1.19/client/supervisor/informers/externalversions/generic.go +++ b/generated/1.19/client/supervisor/informers/externalversions/generic.go @@ -45,6 +45,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Config().V1alpha1().FederationDomains().Informer()}, nil // Group=idp.supervisor.pinniped.dev, Version=v1alpha1 + case idpv1alpha1.SchemeGroupVersion.WithResource("ldapidentityproviders"): + return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().LDAPIdentityProviders().Informer()}, nil case idpv1alpha1.SchemeGroupVersion.WithResource("oidcidentityproviders"): return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().OIDCIdentityProviders().Informer()}, nil diff --git a/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go b/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go index a7754336..1b3a9f24 100644 --- a/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go +++ b/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go @@ -11,6 +11,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // LDAPIdentityProviders returns a LDAPIdentityProviderInformer. + LDAPIdentityProviders() LDAPIdentityProviderInformer // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. OIDCIdentityProviders() OIDCIdentityProviderInformer } @@ -26,6 +28,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// LDAPIdentityProviders returns a LDAPIdentityProviderInformer. +func (v *version) LDAPIdentityProviders() LDAPIdentityProviderInformer { + return &lDAPIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. func (v *version) OIDCIdentityProviders() OIDCIdentityProviderInformer { return &oIDCIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go b/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..c9c548d7 --- /dev/null +++ b/generated/1.19/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,77 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + time "time" + + idpv1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" + versioned "go.pinniped.dev/generated/1.19/client/supervisor/clientset/versioned" + internalinterfaces "go.pinniped.dev/generated/1.19/client/supervisor/informers/externalversions/internalinterfaces" + v1alpha1 "go.pinniped.dev/generated/1.19/client/supervisor/listers/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderInformer provides access to a shared informer and lister for +// LDAPIdentityProviders. +type LDAPIdentityProviderInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.LDAPIdentityProviderLister +} + +type lDAPIdentityProviderInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).Watch(context.TODO(), options) + }, + }, + &idpv1alpha1.LDAPIdentityProvider{}, + resyncPeriod, + indexers, + ) +} + +func (f *lDAPIdentityProviderInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *lDAPIdentityProviderInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&idpv1alpha1.LDAPIdentityProvider{}, f.defaultInformer) +} + +func (f *lDAPIdentityProviderInformer) Lister() v1alpha1.LDAPIdentityProviderLister { + return v1alpha1.NewLDAPIdentityProviderLister(f.Informer().GetIndexer()) +} diff --git a/generated/1.19/client/supervisor/listers/idp/v1alpha1/expansion_generated.go b/generated/1.19/client/supervisor/listers/idp/v1alpha1/expansion_generated.go index e754c985..28f41bd7 100644 --- a/generated/1.19/client/supervisor/listers/idp/v1alpha1/expansion_generated.go +++ b/generated/1.19/client/supervisor/listers/idp/v1alpha1/expansion_generated.go @@ -5,6 +5,14 @@ package v1alpha1 +// LDAPIdentityProviderListerExpansion allows custom methods to be added to +// LDAPIdentityProviderLister. +type LDAPIdentityProviderListerExpansion interface{} + +// LDAPIdentityProviderNamespaceListerExpansion allows custom methods to be added to +// LDAPIdentityProviderNamespaceLister. +type LDAPIdentityProviderNamespaceListerExpansion interface{} + // OIDCIdentityProviderListerExpansion allows custom methods to be added to // OIDCIdentityProviderLister. type OIDCIdentityProviderListerExpansion interface{} diff --git a/generated/1.19/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go b/generated/1.19/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..37e5406c --- /dev/null +++ b/generated/1.19/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,86 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "go.pinniped.dev/generated/1.19/apis/supervisor/idp/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderLister helps list LDAPIdentityProviders. +// All objects returned here must be treated as read-only. +type LDAPIdentityProviderLister interface { + // List lists all LDAPIdentityProviders in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. + LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister + LDAPIdentityProviderListerExpansion +} + +// lDAPIdentityProviderLister implements the LDAPIdentityProviderLister interface. +type lDAPIdentityProviderLister struct { + indexer cache.Indexer +} + +// NewLDAPIdentityProviderLister returns a new LDAPIdentityProviderLister. +func NewLDAPIdentityProviderLister(indexer cache.Indexer) LDAPIdentityProviderLister { + return &lDAPIdentityProviderLister{indexer: indexer} +} + +// List lists all LDAPIdentityProviders in the indexer. +func (s *lDAPIdentityProviderLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. +func (s *lDAPIdentityProviderLister) LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister { + return lDAPIdentityProviderNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// LDAPIdentityProviderNamespaceLister helps list and get LDAPIdentityProviders. +// All objects returned here must be treated as read-only. +type LDAPIdentityProviderNamespaceLister interface { + // List lists all LDAPIdentityProviders in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v1alpha1.LDAPIdentityProvider, error) + LDAPIdentityProviderNamespaceListerExpansion +} + +// lDAPIdentityProviderNamespaceLister implements the LDAPIdentityProviderNamespaceLister +// interface. +type lDAPIdentityProviderNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all LDAPIdentityProviders in the indexer for a given namespace. +func (s lDAPIdentityProviderNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. +func (s lDAPIdentityProviderNamespaceLister) Get(name string) (*v1alpha1.LDAPIdentityProvider, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("ldapidentityprovider"), name) + } + return obj.(*v1alpha1.LDAPIdentityProvider), nil +} diff --git a/generated/1.19/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml b/generated/1.19/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml new file mode 100644 index 00000000..d396129d --- /dev/null +++ b/generated/1.19/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml @@ -0,0 +1,234 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: ldapidentityproviders.idp.supervisor.pinniped.dev +spec: + group: idp.supervisor.pinniped.dev + names: + categories: + - pinniped + - pinniped-idp + - pinniped-idps + kind: LDAPIdentityProvider + listKind: LDAPIdentityProviderList + plural: ldapidentityproviders + singular: ldapidentityprovider + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.host + name: Host + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LDAPIdentityProvider describes the configuration of an upstream + Lightweight Directory Access Protocol (LDAP) identity provider. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec for configuring the identity provider. + properties: + bind: + description: Bind contains the configuration for how to provide access + credentials during an initial bind to the LDAP server to be allowed + to perform searches and binds to validate a user's credentials during + a user's authentication attempt. + properties: + secretName: + description: SecretName contains the name of a namespace-local + Secret object that provides the username and password for an + LDAP bind user. This account will be used to perform LDAP searches. + The Secret should be of type "kubernetes.io/basic-auth" which + includes "username" and "password" keys. The username value + should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + The password must be non-empty. + minLength: 1 + type: string + required: + - secretName + type: object + host: + description: 'Host is the hostname of this LDAP identity provider, + i.e., where to connect. For example: ldap.example.com:636.' + minLength: 1 + type: string + tls: + description: TLS contains the connection settings for how to establish + the connection to the Host. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object + userSearch: + description: UserSearch contains the configuration for searching for + a user by name in the LDAP provider. + properties: + attributes: + description: Attributes specifies how the user's information should + be read from the LDAP entry which was found as the result of + the user search. + properties: + uid: + description: UID specifies the name of the attribute in the + LDAP entry which whose value shall be used to uniquely identify + the user within this LDAP provider after a successful authentication. + E.g. "uidNumber" or "objectGUID". The value of this field + is case-sensitive and must match the case of the attribute + name returned by the LDAP server in the user's entry. Distinguished + names can be used by specifying lower-case "dn". + minLength: 1 + type: string + username: + description: Username specifies the name of attribute in the + LDAP entry which whose value shall become the username of + the user after a successful authentication. This would typically + be the same attribute name used in the user search filter, + although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + The value of this field is case-sensitive and must match + the case of the attribute name returned by the LDAP server + in the user's entry. Distinguished names can be used by + specifying lower-case "dn". When this field is set to "dn" + then the LDAPIdentityProviderUserSearch's Filter field cannot + be blank, since the default value of "dn={}" would not work. + minLength: 1 + type: string + type: object + base: + description: Base is the DN that should be used as the search + base when searching for users. E.g. "ou=users,dc=example,dc=com". + minLength: 1 + type: string + filter: + description: Filter is the LDAP search filter which should be + applied when searching for users. The pattern "{}" must occur + in the filter and will be dynamically replaced by the username + for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". + For more information about LDAP filters, see https://ldap.com/ldap-filters. + Note that the dn (distinguished name) is not an attribute of + an entry, so "dn={}" cannot be used. Optional. When not specified, + the default will act as if the Filter were specified as the + value from Attributes.Username appended by "={}". When the Attributes.Username + is set to "dn" then the Filter must be explicitly specified, + since the default value of "dn={}" would not work. + type: string + type: object + required: + - host + type: object + status: + description: Status of the identity provider. + properties: + conditions: + description: Represents the observations of an identity provider's + current state. + items: + description: Condition status of a resource (mirrored from the metav1.Condition + type added in Kubernetes 1.19). In a future API version we can + switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the LDAPIdentityProvider. + enum: + - Pending + - Ready + - Error + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/generated/1.20/README.adoc b/generated/1.20/README.adoc index 91b4eef6..348c55d0 100644 --- a/generated/1.20/README.adoc +++ b/generated/1.20/README.adoc @@ -669,10 +669,11 @@ Package v1alpha1 is the v1alpha1 version of the Pinniped supervisor identity pro [id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-condition"] ==== Condition -Condition status of a resource (mirrored from the metav1.Condition type added in Kubernetes 1.19). In a future API version we can switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus[$$OIDCIdentityProviderStatus$$] **** @@ -688,6 +689,132 @@ Condition status of a resource (mirrored from the metav1.Condition type added in |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-conditionstatus"] +==== ConditionStatus (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] +**** + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityprovider"] +==== LDAPIdentityProvider + +LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access Protocol (LDAP) identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderlist[$$LDAPIdentityProviderList$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.2/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`. + +| *`spec`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$]__ | Spec for configuring the identity provider. +| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus[$$LDAPIdentityProviderStatus$$]__ | Status of the identity provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind"] +==== LDAPIdentityProviderBind + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`secretName`* __string__ | SecretName contains the name of a namespace-local Secret object that provides the username and password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". The password must be non-empty. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec"] +==== LDAPIdentityProviderSpec + +Spec for configuring an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`host`* __string__ | Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-tlsspec[$$TLSSpec$$]__ | TLS contains the connection settings for how to establish the connection to the Host. +| *`bind`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderbind[$$LDAPIdentityProviderBind$$]__ | Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. +| *`userSearch`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$]__ | UserSearch contains the configuration for searching for a user by name in the LDAP provider. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderstatus"] +==== LDAPIdentityProviderStatus + +Status of an LDAP identity provider. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityprovider[$$LDAPIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`phase`* __LDAPIdentityProviderPhase__ | Phase summarizes the overall status of the LDAPIdentityProvider. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch"] +==== LDAPIdentityProviderUserSearch + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`base`* __string__ | Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". +| *`filter`* __string__ | Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. Optional. When not specified, the default will act as if the Filter were specified as the value from Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be explicitly specified, since the default value of "dn={}" would not work. +| *`attributes`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes[$$LDAPIdentityProviderUserSearchAttributes$$]__ | Attributes specifies how the user's information should be read from the LDAP entry which was found as the result of the user search. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearchattributes"] +==== LDAPIdentityProviderUserSearchAttributes + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderusersearch[$$LDAPIdentityProviderUserSearch$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username specifies the name of attribute in the LDAP entry which whose value shall become the username of the user after a successful authentication. This would typically be the same attribute name used in the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default value of "dn={}" would not work. +| *`uid`* __string__ | UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP server in the user's entry. Distinguished names can be used by specifying lower-case "dn". +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig"] ==== OIDCAuthorizationConfig @@ -797,7 +924,7 @@ Status of an OIDC identity provider. |=== | Field | Description | *`phase`* __OIDCIdentityProviderPhase__ | Phase summarizes the overall status of the OIDCIdentityProvider. -| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-condition[$$Condition$$]__ | Represents the observations of an identity provider's current state. +| *`conditions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-condition[$$Condition$$] array__ | Represents the observations of an identity provider's current state. |=== @@ -808,6 +935,7 @@ Status of an OIDC identity provider. .Appears In: **** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-ldapidentityproviderspec[$$LDAPIdentityProviderSpec$$] - xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec[$$OIDCIdentityProviderSpec$$] **** diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/register.go b/generated/1.20/apis/supervisor/idp/v1alpha1/register.go index c03b7dde..ddc9c360 100644 --- a/generated/1.20/apis/supervisor/idp/v1alpha1/register.go +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/register.go @@ -32,6 +32,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &OIDCIdentityProvider{}, &OIDCIdentityProviderList{}, + &LDAPIdentityProvider{}, + &LDAPIdentityProviderList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go b/generated/1.20/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go new file mode 100644 index 00000000..d718ba65 --- /dev/null +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go @@ -0,0 +1,132 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LDAPIdentityProviderPhase string + +const ( + // LDAPPhasePending is the default phase for newly-created LDAPIdentityProvider resources. + LDAPPhasePending LDAPIdentityProviderPhase = "Pending" + + // LDAPPhaseReady is the phase for an LDAPIdentityProvider resource in a healthy state. + LDAPPhaseReady LDAPIdentityProviderPhase = "Ready" + + // LDAPPhaseError is the phase for an LDAPIdentityProvider in an unhealthy state. + LDAPPhaseError LDAPIdentityProviderPhase = "Error" +) + +// Status of an LDAP identity provider. +type LDAPIdentityProviderStatus struct { + // Phase summarizes the overall status of the LDAPIdentityProvider. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase LDAPIdentityProviderPhase `json:"phase,omitempty"` + + // Represents the observations of an identity provider's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +type LDAPIdentityProviderBind struct { + // SecretName contains the name of a namespace-local Secret object that provides the username and + // password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be + // of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value + // should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + // The password must be non-empty. + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +type LDAPIdentityProviderUserSearchAttributes struct { + // Username specifies the name of attribute in the LDAP entry which whose value shall become the username + // of the user after a successful authentication. This would typically be the same attribute name used in + // the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field + // is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default + // value of "dn={}" would not work. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username,omitempty"` + + // UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely + // identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid,omitempty"` +} + +type LDAPIdentityProviderUserSearch struct { + // Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". + // +kubebuilder:validation:MinLength=1 + Base string `json:"base,omitempty"` + + // Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur + // in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" + // or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. + // Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. + // Optional. When not specified, the default will act as if the Filter were specified as the value from + // Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be + // explicitly specified, since the default value of "dn={}" would not work. + // +optional + Filter string `json:"filter,omitempty"` + + // Attributes specifies how the user's information should be read from the LDAP entry which was found as + // the result of the user search. + // +optional + Attributes LDAPIdentityProviderUserSearchAttributes `json:"attributes,omitempty"` +} + +// Spec for configuring an LDAP identity provider. +type LDAPIdentityProviderSpec struct { + // Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // TLS contains the connection settings for how to establish the connection to the Host. + TLS *TLSSpec `json:"tls,omitempty"` + + // Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server + // to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. + Bind LDAPIdentityProviderBind `json:"bind,omitempty"` + + // UserSearch contains the configuration for searching for a user by name in the LDAP provider. + UserSearch LDAPIdentityProviderUserSearch `json:"userSearch,omitempty"` +} + +// LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access +// Protocol (LDAP) identity provider. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:categories=pinniped;pinniped-idp;pinniped-idps +// +kubebuilder:printcolumn:name="Host",type=string,JSONPath=`.spec.host` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +type LDAPIdentityProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec for configuring the identity provider. + Spec LDAPIdentityProviderSpec `json:"spec"` + + // Status of the identity provider. + Status LDAPIdentityProviderStatus `json:"status,omitempty"` +} + +// List of LDAPIdentityProvider objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LDAPIdentityProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LDAPIdentityProvider `json:"items"` +} diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index b1f0447f..c48c570f 100644 --- a/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -28,6 +28,162 @@ func (in *Condition) DeepCopy() *Condition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProvider) DeepCopyInto(out *LDAPIdentityProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProvider. +func (in *LDAPIdentityProvider) DeepCopy() *LDAPIdentityProvider { + if in == nil { + return nil + } + out := new(LDAPIdentityProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderBind) DeepCopyInto(out *LDAPIdentityProviderBind) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderBind. +func (in *LDAPIdentityProviderBind) DeepCopy() *LDAPIdentityProviderBind { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderBind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderList) DeepCopyInto(out *LDAPIdentityProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LDAPIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderList. +func (in *LDAPIdentityProviderList) DeepCopy() *LDAPIdentityProviderList { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderSpec) DeepCopyInto(out *LDAPIdentityProviderSpec) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } + out.Bind = in.Bind + out.UserSearch = in.UserSearch + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderSpec. +func (in *LDAPIdentityProviderSpec) DeepCopy() *LDAPIdentityProviderSpec { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderStatus) DeepCopyInto(out *LDAPIdentityProviderStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderStatus. +func (in *LDAPIdentityProviderStatus) DeepCopy() *LDAPIdentityProviderStatus { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearch) DeepCopyInto(out *LDAPIdentityProviderUserSearch) { + *out = *in + out.Attributes = in.Attributes + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearch. +func (in *LDAPIdentityProviderUserSearch) DeepCopy() *LDAPIdentityProviderUserSearch { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopyInto(out *LDAPIdentityProviderUserSearchAttributes) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearchAttributes. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopy() *LDAPIdentityProviderUserSearchAttributes { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearchAttributes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = *in diff --git a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go index a7e12c22..5e927831 100644 --- a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go +++ b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go @@ -15,6 +15,10 @@ type FakeIDPV1alpha1 struct { *testing.Fake } +func (c *FakeIDPV1alpha1) LDAPIdentityProviders(namespace string) v1alpha1.LDAPIdentityProviderInterface { + return &FakeLDAPIdentityProviders{c, namespace} +} + func (c *FakeIDPV1alpha1) OIDCIdentityProviders(namespace string) v1alpha1.OIDCIdentityProviderInterface { return &FakeOIDCIdentityProviders{c, namespace} } diff --git a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go new file mode 100644 index 00000000..103c22db --- /dev/null +++ b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go @@ -0,0 +1,129 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1alpha1 "go.pinniped.dev/generated/1.20/apis/supervisor/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeLDAPIdentityProviders implements LDAPIdentityProviderInterface +type FakeLDAPIdentityProviders struct { + Fake *FakeIDPV1alpha1 + ns string +} + +var ldapidentityprovidersResource = schema.GroupVersionResource{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Resource: "ldapidentityproviders"} + +var ldapidentityprovidersKind = schema.GroupVersionKind{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Kind: "LDAPIdentityProvider"} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *FakeLDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *FakeLDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(ldapidentityprovidersResource, ldapidentityprovidersKind, c.ns, opts), &v1alpha1.LDAPIdentityProviderList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.LDAPIdentityProviderList{ListMeta: obj.(*v1alpha1.LDAPIdentityProviderList).ListMeta} + for _, item := range obj.(*v1alpha1.LDAPIdentityProviderList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *FakeLDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(ldapidentityprovidersResource, c.ns, opts)) + +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeLDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(ldapidentityprovidersResource, "status", c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *FakeLDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(ldapidentityprovidersResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v1alpha1.LDAPIdentityProviderList{}) + return err +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *FakeLDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(ldapidentityprovidersResource, c.ns, name, pt, data, subresources...), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} diff --git a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go index 79c7e697..137892f3 100644 --- a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go +++ b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go @@ -5,4 +5,6 @@ package v1alpha1 +type LDAPIdentityProviderExpansion interface{} + type OIDCIdentityProviderExpansion interface{} diff --git a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go index a5d2d5e7..900f258a 100644 --- a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go +++ b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go @@ -13,6 +13,7 @@ import ( type IDPV1alpha1Interface interface { RESTClient() rest.Interface + LDAPIdentityProvidersGetter OIDCIdentityProvidersGetter } @@ -21,6 +22,10 @@ type IDPV1alpha1Client struct { restClient rest.Interface } +func (c *IDPV1alpha1Client) LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface { + return newLDAPIdentityProviders(c, namespace) +} + func (c *IDPV1alpha1Client) OIDCIdentityProviders(namespace string) OIDCIdentityProviderInterface { return newOIDCIdentityProviders(c, namespace) } diff --git a/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..73a20836 --- /dev/null +++ b/generated/1.20/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,182 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + v1alpha1 "go.pinniped.dev/generated/1.20/apis/supervisor/idp/v1alpha1" + scheme "go.pinniped.dev/generated/1.20/client/supervisor/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// LDAPIdentityProvidersGetter has a method to return a LDAPIdentityProviderInterface. +// A group's client should implement this interface. +type LDAPIdentityProvidersGetter interface { + LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface +} + +// LDAPIdentityProviderInterface has methods to work with LDAPIdentityProvider resources. +type LDAPIdentityProviderInterface interface { + Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.LDAPIdentityProvider, error) + List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.LDAPIdentityProviderList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) + LDAPIdentityProviderExpansion +} + +// lDAPIdentityProviders implements LDAPIdentityProviderInterface +type lDAPIdentityProviders struct { + client rest.Interface + ns string +} + +// newLDAPIdentityProviders returns a LDAPIdentityProviders +func newLDAPIdentityProviders(c *IDPV1alpha1Client, namespace string) *lDAPIdentityProviders { + return &lDAPIdentityProviders{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *lDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *lDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.LDAPIdentityProviderList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *lDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Post(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *lDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + SubResource("status"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *lDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *lDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *lDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/generated/1.20/client/supervisor/informers/externalversions/generic.go b/generated/1.20/client/supervisor/informers/externalversions/generic.go index 5d296a37..b7821644 100644 --- a/generated/1.20/client/supervisor/informers/externalversions/generic.go +++ b/generated/1.20/client/supervisor/informers/externalversions/generic.go @@ -45,6 +45,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Config().V1alpha1().FederationDomains().Informer()}, nil // Group=idp.supervisor.pinniped.dev, Version=v1alpha1 + case idpv1alpha1.SchemeGroupVersion.WithResource("ldapidentityproviders"): + return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().LDAPIdentityProviders().Informer()}, nil case idpv1alpha1.SchemeGroupVersion.WithResource("oidcidentityproviders"): return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().OIDCIdentityProviders().Informer()}, nil diff --git a/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go b/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go index d7ceb4c1..34f8361f 100644 --- a/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go +++ b/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go @@ -11,6 +11,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // LDAPIdentityProviders returns a LDAPIdentityProviderInformer. + LDAPIdentityProviders() LDAPIdentityProviderInformer // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. OIDCIdentityProviders() OIDCIdentityProviderInformer } @@ -26,6 +28,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// LDAPIdentityProviders returns a LDAPIdentityProviderInformer. +func (v *version) LDAPIdentityProviders() LDAPIdentityProviderInformer { + return &lDAPIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. func (v *version) OIDCIdentityProviders() OIDCIdentityProviderInformer { return &oIDCIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go b/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..9dfcfc5a --- /dev/null +++ b/generated/1.20/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,77 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + time "time" + + idpv1alpha1 "go.pinniped.dev/generated/1.20/apis/supervisor/idp/v1alpha1" + versioned "go.pinniped.dev/generated/1.20/client/supervisor/clientset/versioned" + internalinterfaces "go.pinniped.dev/generated/1.20/client/supervisor/informers/externalversions/internalinterfaces" + v1alpha1 "go.pinniped.dev/generated/1.20/client/supervisor/listers/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderInformer provides access to a shared informer and lister for +// LDAPIdentityProviders. +type LDAPIdentityProviderInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.LDAPIdentityProviderLister +} + +type lDAPIdentityProviderInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).Watch(context.TODO(), options) + }, + }, + &idpv1alpha1.LDAPIdentityProvider{}, + resyncPeriod, + indexers, + ) +} + +func (f *lDAPIdentityProviderInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *lDAPIdentityProviderInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&idpv1alpha1.LDAPIdentityProvider{}, f.defaultInformer) +} + +func (f *lDAPIdentityProviderInformer) Lister() v1alpha1.LDAPIdentityProviderLister { + return v1alpha1.NewLDAPIdentityProviderLister(f.Informer().GetIndexer()) +} diff --git a/generated/1.20/client/supervisor/listers/idp/v1alpha1/expansion_generated.go b/generated/1.20/client/supervisor/listers/idp/v1alpha1/expansion_generated.go index e754c985..28f41bd7 100644 --- a/generated/1.20/client/supervisor/listers/idp/v1alpha1/expansion_generated.go +++ b/generated/1.20/client/supervisor/listers/idp/v1alpha1/expansion_generated.go @@ -5,6 +5,14 @@ package v1alpha1 +// LDAPIdentityProviderListerExpansion allows custom methods to be added to +// LDAPIdentityProviderLister. +type LDAPIdentityProviderListerExpansion interface{} + +// LDAPIdentityProviderNamespaceListerExpansion allows custom methods to be added to +// LDAPIdentityProviderNamespaceLister. +type LDAPIdentityProviderNamespaceListerExpansion interface{} + // OIDCIdentityProviderListerExpansion allows custom methods to be added to // OIDCIdentityProviderLister. type OIDCIdentityProviderListerExpansion interface{} diff --git a/generated/1.20/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go b/generated/1.20/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..c8215897 --- /dev/null +++ b/generated/1.20/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,86 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "go.pinniped.dev/generated/1.20/apis/supervisor/idp/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderLister helps list LDAPIdentityProviders. +// All objects returned here must be treated as read-only. +type LDAPIdentityProviderLister interface { + // List lists all LDAPIdentityProviders in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. + LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister + LDAPIdentityProviderListerExpansion +} + +// lDAPIdentityProviderLister implements the LDAPIdentityProviderLister interface. +type lDAPIdentityProviderLister struct { + indexer cache.Indexer +} + +// NewLDAPIdentityProviderLister returns a new LDAPIdentityProviderLister. +func NewLDAPIdentityProviderLister(indexer cache.Indexer) LDAPIdentityProviderLister { + return &lDAPIdentityProviderLister{indexer: indexer} +} + +// List lists all LDAPIdentityProviders in the indexer. +func (s *lDAPIdentityProviderLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. +func (s *lDAPIdentityProviderLister) LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister { + return lDAPIdentityProviderNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// LDAPIdentityProviderNamespaceLister helps list and get LDAPIdentityProviders. +// All objects returned here must be treated as read-only. +type LDAPIdentityProviderNamespaceLister interface { + // List lists all LDAPIdentityProviders in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v1alpha1.LDAPIdentityProvider, error) + LDAPIdentityProviderNamespaceListerExpansion +} + +// lDAPIdentityProviderNamespaceLister implements the LDAPIdentityProviderNamespaceLister +// interface. +type lDAPIdentityProviderNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all LDAPIdentityProviders in the indexer for a given namespace. +func (s lDAPIdentityProviderNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. +func (s lDAPIdentityProviderNamespaceLister) Get(name string) (*v1alpha1.LDAPIdentityProvider, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("ldapidentityprovider"), name) + } + return obj.(*v1alpha1.LDAPIdentityProvider), nil +} diff --git a/generated/1.20/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml b/generated/1.20/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml new file mode 100644 index 00000000..d396129d --- /dev/null +++ b/generated/1.20/crds/idp.supervisor.pinniped.dev_ldapidentityproviders.yaml @@ -0,0 +1,234 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.0 + creationTimestamp: null + name: ldapidentityproviders.idp.supervisor.pinniped.dev +spec: + group: idp.supervisor.pinniped.dev + names: + categories: + - pinniped + - pinniped-idp + - pinniped-idps + kind: LDAPIdentityProvider + listKind: LDAPIdentityProviderList + plural: ldapidentityproviders + singular: ldapidentityprovider + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.host + name: Host + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LDAPIdentityProvider describes the configuration of an upstream + Lightweight Directory Access Protocol (LDAP) identity provider. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec for configuring the identity provider. + properties: + bind: + description: Bind contains the configuration for how to provide access + credentials during an initial bind to the LDAP server to be allowed + to perform searches and binds to validate a user's credentials during + a user's authentication attempt. + properties: + secretName: + description: SecretName contains the name of a namespace-local + Secret object that provides the username and password for an + LDAP bind user. This account will be used to perform LDAP searches. + The Secret should be of type "kubernetes.io/basic-auth" which + includes "username" and "password" keys. The username value + should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + The password must be non-empty. + minLength: 1 + type: string + required: + - secretName + type: object + host: + description: 'Host is the hostname of this LDAP identity provider, + i.e., where to connect. For example: ldap.example.com:636.' + minLength: 1 + type: string + tls: + description: TLS contains the connection settings for how to establish + the connection to the Host. + properties: + certificateAuthorityData: + description: X.509 Certificate Authority (base64-encoded PEM bundle). + If omitted, a default set of system roots will be trusted. + type: string + type: object + userSearch: + description: UserSearch contains the configuration for searching for + a user by name in the LDAP provider. + properties: + attributes: + description: Attributes specifies how the user's information should + be read from the LDAP entry which was found as the result of + the user search. + properties: + uid: + description: UID specifies the name of the attribute in the + LDAP entry which whose value shall be used to uniquely identify + the user within this LDAP provider after a successful authentication. + E.g. "uidNumber" or "objectGUID". The value of this field + is case-sensitive and must match the case of the attribute + name returned by the LDAP server in the user's entry. Distinguished + names can be used by specifying lower-case "dn". + minLength: 1 + type: string + username: + description: Username specifies the name of attribute in the + LDAP entry which whose value shall become the username of + the user after a successful authentication. This would typically + be the same attribute name used in the user search filter, + although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + The value of this field is case-sensitive and must match + the case of the attribute name returned by the LDAP server + in the user's entry. Distinguished names can be used by + specifying lower-case "dn". When this field is set to "dn" + then the LDAPIdentityProviderUserSearch's Filter field cannot + be blank, since the default value of "dn={}" would not work. + minLength: 1 + type: string + type: object + base: + description: Base is the DN that should be used as the search + base when searching for users. E.g. "ou=users,dc=example,dc=com". + minLength: 1 + type: string + filter: + description: Filter is the LDAP search filter which should be + applied when searching for users. The pattern "{}" must occur + in the filter and will be dynamically replaced by the username + for which the search is being run. E.g. "mail={}" or "&(objectClass=person)(uid={})". + For more information about LDAP filters, see https://ldap.com/ldap-filters. + Note that the dn (distinguished name) is not an attribute of + an entry, so "dn={}" cannot be used. Optional. When not specified, + the default will act as if the Filter were specified as the + value from Attributes.Username appended by "={}". When the Attributes.Username + is set to "dn" then the Filter must be explicitly specified, + since the default value of "dn={}" would not work. + type: string + type: object + required: + - host + type: object + status: + description: Status of the identity provider. + properties: + conditions: + description: Represents the observations of an identity provider's + current state. + items: + description: Condition status of a resource (mirrored from the metav1.Condition + type added in Kubernetes 1.19). In a future API version we can + switch to using the upstream type. See https://github.com/kubernetes/apimachinery/blob/v0.19.0/pkg/apis/meta/v1/types.go#L1353-L1413. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the LDAPIdentityProvider. + enum: + - Pending + - Ready + - Error + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/register.go b/generated/latest/apis/supervisor/idp/v1alpha1/register.go index c03b7dde..ddc9c360 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/register.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/register.go @@ -32,6 +32,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &OIDCIdentityProvider{}, &OIDCIdentityProviderList{}, + &LDAPIdentityProvider{}, + &LDAPIdentityProviderList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go b/generated/latest/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go new file mode 100644 index 00000000..d718ba65 --- /dev/null +++ b/generated/latest/apis/supervisor/idp/v1alpha1/types_ldapidentityprovider.go @@ -0,0 +1,132 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LDAPIdentityProviderPhase string + +const ( + // LDAPPhasePending is the default phase for newly-created LDAPIdentityProvider resources. + LDAPPhasePending LDAPIdentityProviderPhase = "Pending" + + // LDAPPhaseReady is the phase for an LDAPIdentityProvider resource in a healthy state. + LDAPPhaseReady LDAPIdentityProviderPhase = "Ready" + + // LDAPPhaseError is the phase for an LDAPIdentityProvider in an unhealthy state. + LDAPPhaseError LDAPIdentityProviderPhase = "Error" +) + +// Status of an LDAP identity provider. +type LDAPIdentityProviderStatus struct { + // Phase summarizes the overall status of the LDAPIdentityProvider. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase LDAPIdentityProviderPhase `json:"phase,omitempty"` + + // Represents the observations of an identity provider's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +type LDAPIdentityProviderBind struct { + // SecretName contains the name of a namespace-local Secret object that provides the username and + // password for an LDAP bind user. This account will be used to perform LDAP searches. The Secret should be + // of type "kubernetes.io/basic-auth" which includes "username" and "password" keys. The username value + // should be the full DN of your bind account, e.g. "cn=bind-account,ou=users,dc=example,dc=com". + // The password must be non-empty. + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +type LDAPIdentityProviderUserSearchAttributes struct { + // Username specifies the name of attribute in the LDAP entry which whose value shall become the username + // of the user after a successful authentication. This would typically be the same attribute name used in + // the user search filter, although it can be different. E.g. "mail" or "uid" or "userPrincipalName". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". When this field + // is set to "dn" then the LDAPIdentityProviderUserSearch's Filter field cannot be blank, since the default + // value of "dn={}" would not work. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username,omitempty"` + + // UID specifies the name of the attribute in the LDAP entry which whose value shall be used to uniquely + // identify the user within this LDAP provider after a successful authentication. E.g. "uidNumber" or "objectGUID". + // The value of this field is case-sensitive and must match the case of the attribute name returned by the LDAP + // server in the user's entry. Distinguished names can be used by specifying lower-case "dn". + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid,omitempty"` +} + +type LDAPIdentityProviderUserSearch struct { + // Base is the DN that should be used as the search base when searching for users. E.g. "ou=users,dc=example,dc=com". + // +kubebuilder:validation:MinLength=1 + Base string `json:"base,omitempty"` + + // Filter is the LDAP search filter which should be applied when searching for users. The pattern "{}" must occur + // in the filter and will be dynamically replaced by the username for which the search is being run. E.g. "mail={}" + // or "&(objectClass=person)(uid={})". For more information about LDAP filters, see https://ldap.com/ldap-filters. + // Note that the dn (distinguished name) is not an attribute of an entry, so "dn={}" cannot be used. + // Optional. When not specified, the default will act as if the Filter were specified as the value from + // Attributes.Username appended by "={}". When the Attributes.Username is set to "dn" then the Filter must be + // explicitly specified, since the default value of "dn={}" would not work. + // +optional + Filter string `json:"filter,omitempty"` + + // Attributes specifies how the user's information should be read from the LDAP entry which was found as + // the result of the user search. + // +optional + Attributes LDAPIdentityProviderUserSearchAttributes `json:"attributes,omitempty"` +} + +// Spec for configuring an LDAP identity provider. +type LDAPIdentityProviderSpec struct { + // Host is the hostname of this LDAP identity provider, i.e., where to connect. For example: ldap.example.com:636. + // +kubebuilder:validation:MinLength=1 + Host string `json:"host"` + + // TLS contains the connection settings for how to establish the connection to the Host. + TLS *TLSSpec `json:"tls,omitempty"` + + // Bind contains the configuration for how to provide access credentials during an initial bind to the LDAP server + // to be allowed to perform searches and binds to validate a user's credentials during a user's authentication attempt. + Bind LDAPIdentityProviderBind `json:"bind,omitempty"` + + // UserSearch contains the configuration for searching for a user by name in the LDAP provider. + UserSearch LDAPIdentityProviderUserSearch `json:"userSearch,omitempty"` +} + +// LDAPIdentityProvider describes the configuration of an upstream Lightweight Directory Access +// Protocol (LDAP) identity provider. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:categories=pinniped;pinniped-idp;pinniped-idps +// +kubebuilder:printcolumn:name="Host",type=string,JSONPath=`.spec.host` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +type LDAPIdentityProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec for configuring the identity provider. + Spec LDAPIdentityProviderSpec `json:"spec"` + + // Status of the identity provider. + Status LDAPIdentityProviderStatus `json:"status,omitempty"` +} + +// List of LDAPIdentityProvider objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LDAPIdentityProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LDAPIdentityProvider `json:"items"` +} diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index b1f0447f..c48c570f 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -28,6 +28,162 @@ func (in *Condition) DeepCopy() *Condition { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProvider) DeepCopyInto(out *LDAPIdentityProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProvider. +func (in *LDAPIdentityProvider) DeepCopy() *LDAPIdentityProvider { + if in == nil { + return nil + } + out := new(LDAPIdentityProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderBind) DeepCopyInto(out *LDAPIdentityProviderBind) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderBind. +func (in *LDAPIdentityProviderBind) DeepCopy() *LDAPIdentityProviderBind { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderBind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderList) DeepCopyInto(out *LDAPIdentityProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LDAPIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderList. +func (in *LDAPIdentityProviderList) DeepCopy() *LDAPIdentityProviderList { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LDAPIdentityProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderSpec) DeepCopyInto(out *LDAPIdentityProviderSpec) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } + out.Bind = in.Bind + out.UserSearch = in.UserSearch + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderSpec. +func (in *LDAPIdentityProviderSpec) DeepCopy() *LDAPIdentityProviderSpec { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderStatus) DeepCopyInto(out *LDAPIdentityProviderStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderStatus. +func (in *LDAPIdentityProviderStatus) DeepCopy() *LDAPIdentityProviderStatus { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearch) DeepCopyInto(out *LDAPIdentityProviderUserSearch) { + *out = *in + out.Attributes = in.Attributes + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearch. +func (in *LDAPIdentityProviderUserSearch) DeepCopy() *LDAPIdentityProviderUserSearch { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopyInto(out *LDAPIdentityProviderUserSearchAttributes) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPIdentityProviderUserSearchAttributes. +func (in *LDAPIdentityProviderUserSearchAttributes) DeepCopy() *LDAPIdentityProviderUserSearchAttributes { + if in == nil { + return nil + } + out := new(LDAPIdentityProviderUserSearchAttributes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = *in diff --git a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go index 70a983b6..c06f7429 100644 --- a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go +++ b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_idp_client.go @@ -15,6 +15,10 @@ type FakeIDPV1alpha1 struct { *testing.Fake } +func (c *FakeIDPV1alpha1) LDAPIdentityProviders(namespace string) v1alpha1.LDAPIdentityProviderInterface { + return &FakeLDAPIdentityProviders{c, namespace} +} + func (c *FakeIDPV1alpha1) OIDCIdentityProviders(namespace string) v1alpha1.OIDCIdentityProviderInterface { return &FakeOIDCIdentityProviders{c, namespace} } diff --git a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go new file mode 100644 index 00000000..d3253d48 --- /dev/null +++ b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/fake/fake_ldapidentityprovider.go @@ -0,0 +1,129 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeLDAPIdentityProviders implements LDAPIdentityProviderInterface +type FakeLDAPIdentityProviders struct { + Fake *FakeIDPV1alpha1 + ns string +} + +var ldapidentityprovidersResource = schema.GroupVersionResource{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Resource: "ldapidentityproviders"} + +var ldapidentityprovidersKind = schema.GroupVersionKind{Group: "idp.supervisor.pinniped.dev", Version: "v1alpha1", Kind: "LDAPIdentityProvider"} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *FakeLDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *FakeLDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(ldapidentityprovidersResource, ldapidentityprovidersKind, c.ns, opts), &v1alpha1.LDAPIdentityProviderList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.LDAPIdentityProviderList{ListMeta: obj.(*v1alpha1.LDAPIdentityProviderList).ListMeta} + for _, item := range obj.(*v1alpha1.LDAPIdentityProviderList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *FakeLDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(ldapidentityprovidersResource, c.ns, opts)) + +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *FakeLDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(ldapidentityprovidersResource, c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *FakeLDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateSubresourceAction(ldapidentityprovidersResource, "status", c.ns, lDAPIdentityProvider), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *FakeLDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(ldapidentityprovidersResource, c.ns, name), &v1alpha1.LDAPIdentityProvider{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(ldapidentityprovidersResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v1alpha1.LDAPIdentityProviderList{}) + return err +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *FakeLDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(ldapidentityprovidersResource, c.ns, name, pt, data, subresources...), &v1alpha1.LDAPIdentityProvider{}) + + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.LDAPIdentityProvider), err +} diff --git a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go index 79c7e697..137892f3 100644 --- a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go +++ b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/generated_expansion.go @@ -5,4 +5,6 @@ package v1alpha1 +type LDAPIdentityProviderExpansion interface{} + type OIDCIdentityProviderExpansion interface{} diff --git a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go index b5d9feb7..a32a2dd1 100644 --- a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go +++ b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/idp_client.go @@ -13,6 +13,7 @@ import ( type IDPV1alpha1Interface interface { RESTClient() rest.Interface + LDAPIdentityProvidersGetter OIDCIdentityProvidersGetter } @@ -21,6 +22,10 @@ type IDPV1alpha1Client struct { restClient rest.Interface } +func (c *IDPV1alpha1Client) LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface { + return newLDAPIdentityProviders(c, namespace) +} + func (c *IDPV1alpha1Client) OIDCIdentityProviders(namespace string) OIDCIdentityProviderInterface { return newOIDCIdentityProviders(c, namespace) } diff --git a/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..c42fee93 --- /dev/null +++ b/generated/latest/client/supervisor/clientset/versioned/typed/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,182 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + v1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + scheme "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// LDAPIdentityProvidersGetter has a method to return a LDAPIdentityProviderInterface. +// A group's client should implement this interface. +type LDAPIdentityProvidersGetter interface { + LDAPIdentityProviders(namespace string) LDAPIdentityProviderInterface +} + +// LDAPIdentityProviderInterface has methods to work with LDAPIdentityProvider resources. +type LDAPIdentityProviderInterface interface { + Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (*v1alpha1.LDAPIdentityProvider, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.LDAPIdentityProvider, error) + List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.LDAPIdentityProviderList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) + LDAPIdentityProviderExpansion +} + +// lDAPIdentityProviders implements LDAPIdentityProviderInterface +type lDAPIdentityProviders struct { + client rest.Interface + ns string +} + +// newLDAPIdentityProviders returns a LDAPIdentityProviders +func newLDAPIdentityProviders(c *IDPV1alpha1Client, namespace string) *lDAPIdentityProviders { + return &lDAPIdentityProviders{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the lDAPIdentityProvider, and returns the corresponding lDAPIdentityProvider object, and an error if there is any. +func (c *lDAPIdentityProviders) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of LDAPIdentityProviders that match those selectors. +func (c *lDAPIdentityProviders) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LDAPIdentityProviderList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.LDAPIdentityProviderList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested lDAPIdentityProviders. +func (c *lDAPIdentityProviders) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a lDAPIdentityProvider and creates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Create(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.CreateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Post(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a lDAPIdentityProvider and updates it. Returns the server's representation of the lDAPIdentityProvider, and an error, if there is any. +func (c *lDAPIdentityProviders) Update(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// UpdateStatus was generated because the type contains a Status member. +// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). +func (c *lDAPIdentityProviders) UpdateStatus(ctx context.Context, lDAPIdentityProvider *v1alpha1.LDAPIdentityProvider, opts v1.UpdateOptions) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Put(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(lDAPIdentityProvider.Name). + SubResource("status"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(lDAPIdentityProvider). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the lDAPIdentityProvider and deletes it. Returns an error if one occurs. +func (c *lDAPIdentityProviders) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *lDAPIdentityProviders) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("ldapidentityproviders"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched lDAPIdentityProvider. +func (c *lDAPIdentityProviders) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.LDAPIdentityProvider, err error) { + result = &v1alpha1.LDAPIdentityProvider{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("ldapidentityproviders"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/generated/latest/client/supervisor/informers/externalversions/generic.go b/generated/latest/client/supervisor/informers/externalversions/generic.go index 36a99ea5..338a4d72 100644 --- a/generated/latest/client/supervisor/informers/externalversions/generic.go +++ b/generated/latest/client/supervisor/informers/externalversions/generic.go @@ -45,6 +45,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Config().V1alpha1().FederationDomains().Informer()}, nil // Group=idp.supervisor.pinniped.dev, Version=v1alpha1 + case idpv1alpha1.SchemeGroupVersion.WithResource("ldapidentityproviders"): + return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().LDAPIdentityProviders().Informer()}, nil case idpv1alpha1.SchemeGroupVersion.WithResource("oidcidentityproviders"): return &genericInformer{resource: resource.GroupResource(), informer: f.IDP().V1alpha1().OIDCIdentityProviders().Informer()}, nil diff --git a/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go b/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go index 989cfd96..1a1c2d57 100644 --- a/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go +++ b/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/interface.go @@ -11,6 +11,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // LDAPIdentityProviders returns a LDAPIdentityProviderInformer. + LDAPIdentityProviders() LDAPIdentityProviderInformer // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. OIDCIdentityProviders() OIDCIdentityProviderInformer } @@ -26,6 +28,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// LDAPIdentityProviders returns a LDAPIdentityProviderInformer. +func (v *version) LDAPIdentityProviders() LDAPIdentityProviderInformer { + return &lDAPIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // OIDCIdentityProviders returns a OIDCIdentityProviderInformer. func (v *version) OIDCIdentityProviders() OIDCIdentityProviderInformer { return &oIDCIdentityProviderInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go b/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..2e6b3861 --- /dev/null +++ b/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,77 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + time "time" + + idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + versioned "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" + internalinterfaces "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/internalinterfaces" + v1alpha1 "go.pinniped.dev/generated/latest/client/supervisor/listers/idp/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderInformer provides access to a shared informer and lister for +// LDAPIdentityProviders. +type LDAPIdentityProviderInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha1.LDAPIdentityProviderLister +} + +type lDAPIdentityProviderInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredLDAPIdentityProviderInformer constructs a new informer for LDAPIdentityProvider type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredLDAPIdentityProviderInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IDPV1alpha1().LDAPIdentityProviders(namespace).Watch(context.TODO(), options) + }, + }, + &idpv1alpha1.LDAPIdentityProvider{}, + resyncPeriod, + indexers, + ) +} + +func (f *lDAPIdentityProviderInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLDAPIdentityProviderInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *lDAPIdentityProviderInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&idpv1alpha1.LDAPIdentityProvider{}, f.defaultInformer) +} + +func (f *lDAPIdentityProviderInformer) Lister() v1alpha1.LDAPIdentityProviderLister { + return v1alpha1.NewLDAPIdentityProviderLister(f.Informer().GetIndexer()) +} diff --git a/generated/latest/client/supervisor/listers/idp/v1alpha1/expansion_generated.go b/generated/latest/client/supervisor/listers/idp/v1alpha1/expansion_generated.go index e754c985..28f41bd7 100644 --- a/generated/latest/client/supervisor/listers/idp/v1alpha1/expansion_generated.go +++ b/generated/latest/client/supervisor/listers/idp/v1alpha1/expansion_generated.go @@ -5,6 +5,14 @@ package v1alpha1 +// LDAPIdentityProviderListerExpansion allows custom methods to be added to +// LDAPIdentityProviderLister. +type LDAPIdentityProviderListerExpansion interface{} + +// LDAPIdentityProviderNamespaceListerExpansion allows custom methods to be added to +// LDAPIdentityProviderNamespaceLister. +type LDAPIdentityProviderNamespaceListerExpansion interface{} + // OIDCIdentityProviderListerExpansion allows custom methods to be added to // OIDCIdentityProviderLister. type OIDCIdentityProviderListerExpansion interface{} diff --git a/generated/latest/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go b/generated/latest/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go new file mode 100644 index 00000000..4c7d773c --- /dev/null +++ b/generated/latest/client/supervisor/listers/idp/v1alpha1/ldapidentityprovider.go @@ -0,0 +1,86 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// LDAPIdentityProviderLister helps list LDAPIdentityProviders. +// All objects returned here must be treated as read-only. +type LDAPIdentityProviderLister interface { + // List lists all LDAPIdentityProviders in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. + LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister + LDAPIdentityProviderListerExpansion +} + +// lDAPIdentityProviderLister implements the LDAPIdentityProviderLister interface. +type lDAPIdentityProviderLister struct { + indexer cache.Indexer +} + +// NewLDAPIdentityProviderLister returns a new LDAPIdentityProviderLister. +func NewLDAPIdentityProviderLister(indexer cache.Indexer) LDAPIdentityProviderLister { + return &lDAPIdentityProviderLister{indexer: indexer} +} + +// List lists all LDAPIdentityProviders in the indexer. +func (s *lDAPIdentityProviderLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// LDAPIdentityProviders returns an object that can list and get LDAPIdentityProviders. +func (s *lDAPIdentityProviderLister) LDAPIdentityProviders(namespace string) LDAPIdentityProviderNamespaceLister { + return lDAPIdentityProviderNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// LDAPIdentityProviderNamespaceLister helps list and get LDAPIdentityProviders. +// All objects returned here must be treated as read-only. +type LDAPIdentityProviderNamespaceLister interface { + // List lists all LDAPIdentityProviders in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) + // Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v1alpha1.LDAPIdentityProvider, error) + LDAPIdentityProviderNamespaceListerExpansion +} + +// lDAPIdentityProviderNamespaceLister implements the LDAPIdentityProviderNamespaceLister +// interface. +type lDAPIdentityProviderNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all LDAPIdentityProviders in the indexer for a given namespace. +func (s lDAPIdentityProviderNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.LDAPIdentityProvider, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha1.LDAPIdentityProvider)) + }) + return ret, err +} + +// Get retrieves the LDAPIdentityProvider from the indexer for a given namespace and name. +func (s lDAPIdentityProviderNamespaceLister) Get(name string) (*v1alpha1.LDAPIdentityProvider, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha1.Resource("ldapidentityprovider"), name) + } + return obj.(*v1alpha1.LDAPIdentityProvider), nil +} diff --git a/go.mod b/go.mod index 9e54e61c..d86141f4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,9 @@ require ( cloud.google.com/go v0.60.0 // indirect github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/coreos/go-oidc/v3 v3.0.0 + github.com/creack/pty v1.1.11 github.com/davecgh/go-spew v1.1.1 + github.com/go-ldap/ldap/v3 v3.3.0 github.com/go-logr/logr v0.4.0 github.com/go-logr/stdr v0.4.0 github.com/go-openapi/spec v0.20.3 @@ -30,6 +32,7 @@ require ( golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/sync v0.0.0-20201207232520-09787c993a3a + golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d gopkg.in/square/go-jose.v2 v2.5.1 k8s.io/api v0.21.0 k8s.io/apimachinery v0.21.0 diff --git a/go.sum b/go.sum index 550f8a33..e58f11bb 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/Azure/go-autorest/logger v0.2.0 h1:e4RVHVZKC5p6UANLJHkM4OfR1UKZPj8Wt8 github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= +github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -146,6 +148,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -198,12 +201,16 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8= +github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-bindata/go-bindata v3.1.1+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-ldap/ldap/v3 v3.3.0 h1:lwx+SJpgOHd8tG6SumBQZXCmNX51zM8B1cfxJ5gv4tQ= +github.com/go-ldap/ldap/v3 v3.3.0/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= @@ -1094,6 +1101,7 @@ golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= diff --git a/hack/prepare-supervisor-on-kind.sh b/hack/prepare-supervisor-on-kind.sh index 5fccad0a..8b6d5969 100755 --- a/hack/prepare-supervisor-on-kind.sh +++ b/hack/prepare-supervisor-on-kind.sh @@ -20,6 +20,34 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +use_oidc_upstream=no +use_ldap_upstream=no +while (("$#")); do + case "$1" in + --ldap) + use_ldap_upstream=yes + shift + ;; + --oidc) + use_oidc_upstream=yes + shift + ;; + -*) + log_error "Unsupported flag $1" >&2 + exit 1 + ;; + *) + log_error "Unsupported positional arg $1" >&2 + exit 1 + ;; + esac +done + +if [[ "$use_oidc_upstream" == "no" && "$use_ldap_upstream" == "no" ]]; then + echo "Error: Please use --oidc or --ldap to specify which type of upstream identity provider(s) you would like" + exit 1 +fi + # Read the env vars output by hack/prepare-for-integration-tests.sh source /tmp/integration-test-env @@ -73,8 +101,9 @@ sleep 5 echo "Fetching FederationDomain discovery info..." https_proxy="$PINNIPED_TEST_PROXY" curl -fLsS --cacert "$root_ca_crt_path" "$issuer/.well-known/openid-configuration" | jq . -# Make an OIDCIdentityProvider which uses Dex to provide identity. -cat <kubeconfig +https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" ./pinniped get kubeconfig --oidc-skip-browser >kubeconfig # Clear the local CLI cache to ensure that the kubectl command below will need to perform a fresh login. -rm -f "$HOME"/.config/pinniped/sessions.yaml +rm -f "$HOME/.config/pinniped/sessions.yaml" +rm -f "$HOME/.config/pinniped/credentials.yaml" echo echo "Ready! 🚀" -echo "To be able to access the login URL shown below, start Chrome like this:" -echo " open -a \"Google Chrome\" --args --proxy-server=\"$PINNIPED_TEST_PROXY\"" -echo "Then use these credentials at the Dex login page:" -echo " Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME" -echo " Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD" -# Perform a login using the kubectl plugin. This should print the URL to be followed for the Dex login page. +if [[ "$use_oidc_upstream" == "yes" ]]; then + echo + echo "To be able to access the login URL shown below, start Chrome like this:" + echo " open -a \"Google Chrome\" --args --proxy-server=\"$PINNIPED_TEST_PROXY\"" + echo "Then use these credentials at the Dex login page:" + echo " Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME" + echo " Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD" +fi + +if [[ "$use_ldap_upstream" == "yes" ]]; then + echo + echo "When prompted for username and password by the CLI, use these values:" + echo " Username: $PINNIPED_TEST_LDAP_USER_CN" + echo " Password: $PINNIPED_TEST_LDAP_USER_PASSWORD" +fi + +# Perform a login using the kubectl plugin. This should print the URL to be followed for the Dex login page +# if using an OIDC upstream, or should prompt on the CLI for username/password if using an LDAP upstream. echo echo "Running: https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" kubectl --kubeconfig ./kubeconfig get pods -A" https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" kubectl --kubeconfig ./kubeconfig get pods -A diff --git a/internal/authenticators/authenticators.go b/internal/authenticators/authenticators.go new file mode 100644 index 00000000..bd24ff0b --- /dev/null +++ b/internal/authenticators/authenticators.go @@ -0,0 +1,35 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package authenticators contains authenticator interfaces. +package authenticators + +import ( + "context" + + "k8s.io/apiserver/pkg/authentication/authenticator" +) + +// This interface is similar to the k8s token authenticator, but works with username/passwords instead +// of a single token string. +// +// The return values should be as follows. +// 1. For a successful authentication: +// - A response which includes the username, uid, and groups in the userInfo. The username and uid must not be blank. +// - true +// - nil error +// 2. For an unsuccessful authentication, e.g. bad username or password: +// - nil response +// - false +// - nil error +// 3. For an unexpected error, e.g. a network problem: +// - nil response +// - false +// - an error +// Other combinations of return values must be avoided. +// +// See k8s.io/apiserver/pkg/authentication/authenticator/interfaces.go for the token authenticator +// interface, as well as the Response type. +type UserAuthenticator interface { + AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) +} diff --git a/internal/config/concierge/config.go b/internal/config/concierge/config.go index 610caa7e..ad6a00df 100644 --- a/internal/config/concierge/config.go +++ b/internal/config/concierge/config.go @@ -10,6 +10,7 @@ import ( "io/ioutil" "strings" + "k8s.io/utils/pointer" "sigs.k8s.io/yaml" "go.pinniped.dev/internal/constable" @@ -69,27 +70,27 @@ func FromPath(path string) (*Config, error) { func maybeSetAPIDefaults(apiConfig *APIConfigSpec) { if apiConfig.ServingCertificateConfig.DurationSeconds == nil { - apiConfig.ServingCertificateConfig.DurationSeconds = int64Ptr(aboutAYear) + apiConfig.ServingCertificateConfig.DurationSeconds = pointer.Int64Ptr(aboutAYear) } if apiConfig.ServingCertificateConfig.RenewBeforeSeconds == nil { - apiConfig.ServingCertificateConfig.RenewBeforeSeconds = int64Ptr(about9Months) + apiConfig.ServingCertificateConfig.RenewBeforeSeconds = pointer.Int64Ptr(about9Months) } } func maybeSetAPIGroupSuffixDefault(apiGroupSuffix **string) { if *apiGroupSuffix == nil { - *apiGroupSuffix = stringPtr(groupsuffix.PinnipedDefaultSuffix) + *apiGroupSuffix = pointer.StringPtr(groupsuffix.PinnipedDefaultSuffix) } } func maybeSetKubeCertAgentDefaults(cfg *KubeCertAgentSpec) { if cfg.NamePrefix == nil { - cfg.NamePrefix = stringPtr("pinniped-kube-cert-agent-") + cfg.NamePrefix = pointer.StringPtr("pinniped-kube-cert-agent-") } if cfg.Image == nil { - cfg.Image = stringPtr("debian:latest") + cfg.Image = pointer.StringPtr("debian:latest") } } @@ -146,11 +147,3 @@ func validateAPI(apiConfig *APIConfigSpec) error { func validateAPIGroupSuffix(apiGroupSuffix string) error { return groupsuffix.Validate(apiGroupSuffix) } - -func int64Ptr(i int64) *int64 { - return &i -} - -func stringPtr(s string) *string { - return &s -} diff --git a/internal/config/concierge/config_test.go b/internal/config/concierge/config_test.go index 1101d2d5..baebf8c3 100644 --- a/internal/config/concierge/config_test.go +++ b/internal/config/concierge/config_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "k8s.io/utils/pointer" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" @@ -55,15 +56,15 @@ func TestFromPath(t *testing.T) { `), wantConfig: &Config{ DiscoveryInfo: DiscoveryInfoSpec{ - URL: stringPtr("https://some.discovery/url"), + URL: pointer.StringPtr("https://some.discovery/url"), }, APIConfig: APIConfigSpec{ ServingCertificateConfig: ServingCertificateConfigSpec{ - DurationSeconds: int64Ptr(3600), - RenewBeforeSeconds: int64Ptr(2400), + DurationSeconds: pointer.Int64Ptr(3600), + RenewBeforeSeconds: pointer.Int64Ptr(2400), }, }, - APIGroupSuffix: stringPtr("some.suffix.com"), + APIGroupSuffix: pointer.StringPtr("some.suffix.com"), NamesConfig: NamesConfigSpec{ ServingCertificateSecret: "pinniped-concierge-api-tls-serving-certificate", CredentialIssuer: "pinniped-config", @@ -80,8 +81,8 @@ func TestFromPath(t *testing.T) { "myLabelKey2": "myLabelValue2", }, KubeCertAgentConfig: KubeCertAgentSpec{ - NamePrefix: stringPtr("kube-cert-agent-name-prefix-"), - Image: stringPtr("kube-cert-agent-image"), + NamePrefix: pointer.StringPtr("kube-cert-agent-name-prefix-"), + Image: pointer.StringPtr("kube-cert-agent-image"), ImagePullSecrets: []string{"kube-cert-agent-image-pull-secret"}, }, LogLevel: plog.LevelDebug, @@ -106,11 +107,11 @@ func TestFromPath(t *testing.T) { DiscoveryInfo: DiscoveryInfoSpec{ URL: nil, }, - APIGroupSuffix: stringPtr("pinniped.dev"), + APIGroupSuffix: pointer.StringPtr("pinniped.dev"), APIConfig: APIConfigSpec{ ServingCertificateConfig: ServingCertificateConfigSpec{ - DurationSeconds: int64Ptr(60 * 60 * 24 * 365), // about a year - RenewBeforeSeconds: int64Ptr(60 * 60 * 24 * 30 * 9), // about 9 months + DurationSeconds: pointer.Int64Ptr(60 * 60 * 24 * 365), // about a year + RenewBeforeSeconds: pointer.Int64Ptr(60 * 60 * 24 * 30 * 9), // about 9 months }, }, NamesConfig: NamesConfigSpec{ @@ -126,8 +127,8 @@ func TestFromPath(t *testing.T) { }, Labels: map[string]string{}, KubeCertAgentConfig: KubeCertAgentSpec{ - NamePrefix: stringPtr("pinniped-kube-cert-agent-"), - Image: stringPtr("debian:latest"), + NamePrefix: pointer.StringPtr("pinniped-kube-cert-agent-"), + Image: pointer.StringPtr("debian:latest"), }, }, }, diff --git a/internal/config/supervisor/config.go b/internal/config/supervisor/config.go index 24668f54..608a7719 100644 --- a/internal/config/supervisor/config.go +++ b/internal/config/supervisor/config.go @@ -10,6 +10,7 @@ import ( "io/ioutil" "strings" + "k8s.io/utils/pointer" "sigs.k8s.io/yaml" "go.pinniped.dev/internal/constable" @@ -54,7 +55,7 @@ func FromPath(path string) (*Config, error) { func maybeSetAPIGroupSuffixDefault(apiGroupSuffix **string) { if *apiGroupSuffix == nil { - *apiGroupSuffix = stringPtr(groupsuffix.PinnipedDefaultSuffix) + *apiGroupSuffix = pointer.StringPtr(groupsuffix.PinnipedDefaultSuffix) } } @@ -72,7 +73,3 @@ func validateNames(names *NamesConfigSpec) error { } return nil } - -func stringPtr(s string) *string { - return &s -} diff --git a/internal/config/supervisor/config_test.go b/internal/config/supervisor/config_test.go index 7fb1acc8..72839aeb 100644 --- a/internal/config/supervisor/config_test.go +++ b/internal/config/supervisor/config_test.go @@ -8,6 +8,8 @@ import ( "os" "testing" + "k8s.io/utils/pointer" + "github.com/stretchr/testify/require" "go.pinniped.dev/internal/here" @@ -32,7 +34,7 @@ func TestFromPath(t *testing.T) { defaultTLSCertificateSecret: my-secret-name `), wantConfig: &Config{ - APIGroupSuffix: stringPtr("some.suffix.com"), + APIGroupSuffix: pointer.StringPtr("some.suffix.com"), Labels: map[string]string{ "myLabelKey1": "myLabelValue1", "myLabelKey2": "myLabelValue2", @@ -50,7 +52,7 @@ func TestFromPath(t *testing.T) { defaultTLSCertificateSecret: my-secret-name `), wantConfig: &Config{ - APIGroupSuffix: stringPtr("pinniped.dev"), + APIGroupSuffix: pointer.StringPtr("pinniped.dev"), Labels: map[string]string{}, NamesConfig: NamesConfigSpec{ DefaultTLSCertificateSecret: "my-secret-name", diff --git a/internal/controller/conditionsutil/conditions_util.go.go b/internal/controller/conditionsutil/conditions_util.go.go new file mode 100644 index 00000000..67f13b03 --- /dev/null +++ b/internal/controller/conditionsutil/conditions_util.go.go @@ -0,0 +1,68 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package conditionsutil + +import ( + "sort" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/equality" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" +) + +// Merge merges conditions into conditionsToUpdate. If returns true if it merged any error conditions. +func Merge(conditions []*v1alpha1.Condition, observedGeneration int64, conditionsToUpdate *[]v1alpha1.Condition, log logr.Logger) bool { + hadErrorCondition := false + for i := range conditions { + cond := conditions[i].DeepCopy() + cond.LastTransitionTime = v1.Now() + cond.ObservedGeneration = observedGeneration + if mergeCondition(conditionsToUpdate, cond) { + log.Info("updated condition", "type", cond.Type, "status", cond.Status, "reason", cond.Reason, "message", cond.Message) + } + if cond.Status == v1alpha1.ConditionFalse { + hadErrorCondition = true + } + } + sort.SliceStable(*conditionsToUpdate, func(i, j int) bool { + return (*conditionsToUpdate)[i].Type < (*conditionsToUpdate)[j].Type + }) + return hadErrorCondition +} + +// mergeCondition merges a new v1alpha1.Condition into a slice of existing conditions. It returns true +// if the condition has meaningfully changed. +func mergeCondition(existing *[]v1alpha1.Condition, new *v1alpha1.Condition) bool { + // Find any existing condition with a matching type. + var old *v1alpha1.Condition + for i := range *existing { + if (*existing)[i].Type == new.Type { + old = &(*existing)[i] + continue + } + } + + // If there is no existing condition of this type, append this one and we're done. + if old == nil { + *existing = append(*existing, *new) + return true + } + + // Set the LastTransitionTime depending on whether the status has changed. + new = new.DeepCopy() + if old.Status == new.Status { + new.LastTransitionTime = old.LastTransitionTime + } + + // If anything has actually changed, update the entry and return true. + if !equality.Semantic.DeepEqual(old, new) { + *old = *new + return true + } + + // Otherwise the entry is already up to date. + return false +} diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go new file mode 100644 index 00000000..5484e424 --- /dev/null +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go @@ -0,0 +1,372 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package ldapupstreamwatcher implements a controller which watches LDAPIdentityProviders. +package ldapupstreamwatcher + +import ( + "context" + "crypto/x509" + "encoding/base64" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/klog/v2/klogr" + + "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" + idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1" + pinnipedcontroller "go.pinniped.dev/internal/controller" + "go.pinniped.dev/internal/controller/conditionsutil" + "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" + "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/upstreamldap" +) + +const ( + ldapControllerName = "ldap-upstream-observer" + ldapBindAccountSecretType = corev1.SecretTypeBasicAuth + testLDAPConnectionTimeout = 90 * time.Second + + // Constants related to conditions. + typeBindSecretValid = "BindSecretValid" + typeTLSConfigurationValid = "TLSConfigurationValid" + typeLDAPConnectionValid = "LDAPConnectionValid" + reasonLDAPConnectionError = "LDAPConnectionError" + noTLSConfigurationMessage = "no TLS configuration provided" + loadedTLSConfigurationMessage = "loaded TLS configuration" +) + +// UpstreamLDAPIdentityProviderICache is a thread safe cache that holds a list of validated upstream LDAP IDP configurations. +type UpstreamLDAPIdentityProviderICache interface { + SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI) +} + +type ldapWatcherController struct { + cache UpstreamLDAPIdentityProviderICache + validatedSecretVersionsCache *secretVersionCache + ldapDialer upstreamldap.LDAPDialer + client pinnipedclientset.Interface + ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer + secretInformer corev1informers.SecretInformer +} + +// An in-memory cache with an entry for each LDAPIdentityProvider, to keep track of which ResourceVersion +// of the bind Secret was used during the most recent successful validation. +type secretVersionCache struct { + ResourceVersionsByName map[string]string +} + +func newSecretVersionCache() *secretVersionCache { + return &secretVersionCache{ResourceVersionsByName: map[string]string{}} +} + +// New instantiates a new controllerlib.Controller which will populate the provided UpstreamLDAPIdentityProviderICache. +func New( + idpCache UpstreamLDAPIdentityProviderICache, + client pinnipedclientset.Interface, + ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer, + secretInformer corev1informers.SecretInformer, + withInformer pinnipedcontroller.WithInformerOptionFunc, +) controllerlib.Controller { + return newInternal( + idpCache, + // start with an empty secretVersionCache + newSecretVersionCache(), + // nil means to use a real production dialer when creating objects to add to the cache + nil, + client, + ldapIdentityProviderInformer, + secretInformer, + withInformer, + ) +} + +// For test dependency injection purposes. +func newInternal( + idpCache UpstreamLDAPIdentityProviderICache, + validatedSecretVersionsCache *secretVersionCache, + ldapDialer upstreamldap.LDAPDialer, + client pinnipedclientset.Interface, + ldapIdentityProviderInformer idpinformers.LDAPIdentityProviderInformer, + secretInformer corev1informers.SecretInformer, + withInformer pinnipedcontroller.WithInformerOptionFunc, +) controllerlib.Controller { + c := ldapWatcherController{ + cache: idpCache, + validatedSecretVersionsCache: validatedSecretVersionsCache, + ldapDialer: ldapDialer, + client: client, + ldapIdentityProviderInformer: ldapIdentityProviderInformer, + secretInformer: secretInformer, + } + return controllerlib.New( + controllerlib.Config{Name: ldapControllerName, Syncer: &c}, + withInformer( + ldapIdentityProviderInformer, + pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()), + controllerlib.InformerOption{}, + ), + withInformer( + secretInformer, + pinnipedcontroller.MatchAnySecretOfTypeFilter(ldapBindAccountSecretType, pinnipedcontroller.SingletonQueue()), + controllerlib.InformerOption{}, + ), + ) +} + +// Sync implements controllerlib.Syncer. +func (c *ldapWatcherController) Sync(ctx controllerlib.Context) error { + actualUpstreams, err := c.ldapIdentityProviderInformer.Lister().List(labels.Everything()) + if err != nil { + return fmt.Errorf("failed to list LDAPIdentityProviders: %w", err) + } + + requeue := false + validatedUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, 0, len(actualUpstreams)) + for _, upstream := range actualUpstreams { + valid, requestedRequeue := c.validateUpstream(ctx.Context, upstream) + if valid != nil { + validatedUpstreams = append(validatedUpstreams, valid) + } + if requestedRequeue { + requeue = true + } + } + + c.cache.SetLDAPIdentityProviders(validatedUpstreams) + + if requeue { + return controllerlib.ErrSyntheticRequeue + } + return nil +} + +func (c *ldapWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider) (p provider.UpstreamLDAPIdentityProviderI, requeue bool) { + spec := upstream.Spec + + config := &upstreamldap.ProviderConfig{ + Name: upstream.Name, + Host: spec.Host, + UserSearch: upstreamldap.UserSearchConfig{ + Base: spec.UserSearch.Base, + Filter: spec.UserSearch.Filter, + UsernameAttribute: spec.UserSearch.Attributes.Username, + UIDAttribute: spec.UserSearch.Attributes.UID, + }, + Dialer: c.ldapDialer, + } + + conditions := []*v1alpha1.Condition{} + secretValidCondition, currentSecretVersion := c.validateSecret(upstream, config) + tlsValidCondition := c.validateTLSConfig(upstream, config) + conditions = append(conditions, secretValidCondition, tlsValidCondition) + + // No point in trying to connect to the server if the config was already determined to be invalid. + var finishedConfigCondition *v1alpha1.Condition + if secretValidCondition.Status == v1alpha1.ConditionTrue && tlsValidCondition.Status == v1alpha1.ConditionTrue { + finishedConfigCondition = c.validateFinishedConfig(ctx, upstream, config, currentSecretVersion) + if finishedConfigCondition != nil { + conditions = append(conditions, finishedConfigCondition) + } + } + + c.updateStatus(ctx, upstream, conditions) + + switch { + case secretValidCondition.Status != v1alpha1.ConditionTrue || tlsValidCondition.Status != v1alpha1.ConditionTrue: + // Invalid provider, so do not load it into the cache. + p = nil + requeue = true + case finishedConfigCondition != nil && finishedConfigCondition.Status != v1alpha1.ConditionTrue: + // Error but load it into the cache anyway, treating this condition failure more like a warning. + p = upstreamldap.New(*config) + // Try again hoping that the condition will improve. + requeue = true + default: + // Fully validated provider, so load it into the cache. + p = upstreamldap.New(*config) + requeue = false + } + + return p, requeue +} + +func (c *ldapWatcherController) validateTLSConfig(upstream *v1alpha1.LDAPIdentityProvider, config *upstreamldap.ProviderConfig) *v1alpha1.Condition { + tlsSpec := upstream.Spec.TLS + if tlsSpec == nil { + return c.validTLSCondition(noTLSConfigurationMessage) + } + if len(tlsSpec.CertificateAuthorityData) == 0 { + return c.validTLSCondition(loadedTLSConfigurationMessage) + } + + bundle, err := base64.StdEncoding.DecodeString(tlsSpec.CertificateAuthorityData) + if err != nil { + return c.invalidTLSCondition(fmt.Sprintf("certificateAuthorityData is invalid: %s", err.Error())) + } + + ca := x509.NewCertPool() + ok := ca.AppendCertsFromPEM(bundle) + if !ok { + return c.invalidTLSCondition(fmt.Sprintf("certificateAuthorityData is invalid: %s", upstreamwatchers.ErrNoCertificates)) + } + + config.CABundle = bundle + return c.validTLSCondition(loadedTLSConfigurationMessage) +} + +func (c *ldapWatcherController) validateFinishedConfig(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider, config *upstreamldap.ProviderConfig, currentSecretVersion string) *v1alpha1.Condition { + ldapProvider := upstreamldap.New(*config) + + if c.hasPreviousSuccessfulConditionForCurrentSpecGenerationAndSecretVersion(upstream, currentSecretVersion) { + return nil + } + + testConnectionTimeout, cancelFunc := context.WithTimeout(ctx, testLDAPConnectionTimeout) + defer cancelFunc() + + condition := c.testConnection(testConnectionTimeout, upstream, config, ldapProvider, currentSecretVersion) + + if condition.Status == v1alpha1.ConditionTrue { + // Remember (in-memory for this pod) that the controller has successfully validated the LDAP provider + // using this version of the Secret. This is for performance reasons, to avoid attempting to connect to + // the LDAP server more than is needed. If the pod restarts, it will attempt this validation again. + c.validatedSecretVersionsCache.ResourceVersionsByName[upstream.GetName()] = currentSecretVersion + } + + return condition +} + +func (c *ldapWatcherController) testConnection( + ctx context.Context, + upstream *v1alpha1.LDAPIdentityProvider, + config *upstreamldap.ProviderConfig, + ldapProvider *upstreamldap.Provider, + currentSecretVersion string, +) *v1alpha1.Condition { + err := ldapProvider.TestConnection(ctx) + if err != nil { + return &v1alpha1.Condition{ + Type: typeLDAPConnectionValid, + Status: v1alpha1.ConditionFalse, + Reason: reasonLDAPConnectionError, + Message: fmt.Sprintf(`could not successfully connect to "%s" and bind as user "%s": %s`, + config.Host, config.BindUsername, err.Error()), + } + } + + return &v1alpha1.Condition{ + Type: typeLDAPConnectionValid, + Status: v1alpha1.ConditionTrue, + Reason: upstreamwatchers.ReasonSuccess, + Message: fmt.Sprintf(`successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + config.Host, config.BindUsername, upstream.Spec.Bind.SecretName, currentSecretVersion), + } +} + +func (c *ldapWatcherController) hasPreviousSuccessfulConditionForCurrentSpecGenerationAndSecretVersion(upstream *v1alpha1.LDAPIdentityProvider, currentSecretVersion string) bool { + currentGeneration := upstream.Generation + for _, cond := range upstream.Status.Conditions { + if cond.Type == typeLDAPConnectionValid && cond.Status == v1alpha1.ConditionTrue && cond.ObservedGeneration == currentGeneration { + // Found a previously successful condition for the current spec generation. + // Now figure out which version of the bind Secret was used during that previous validation, if any. + validatedSecretVersion := c.validatedSecretVersionsCache.ResourceVersionsByName[upstream.GetName()] + if validatedSecretVersion == currentSecretVersion { + return true + } + } + } + return false +} + +func (c *ldapWatcherController) validTLSCondition(message string) *v1alpha1.Condition { + return &v1alpha1.Condition{ + Type: typeTLSConfigurationValid, + Status: v1alpha1.ConditionTrue, + Reason: upstreamwatchers.ReasonSuccess, + Message: message, + } +} + +func (c *ldapWatcherController) invalidTLSCondition(message string) *v1alpha1.Condition { + return &v1alpha1.Condition{ + Type: typeTLSConfigurationValid, + Status: v1alpha1.ConditionFalse, + Reason: upstreamwatchers.ReasonInvalidTLSConfig, + Message: message, + } +} + +func (c *ldapWatcherController) validateSecret(upstream *v1alpha1.LDAPIdentityProvider, config *upstreamldap.ProviderConfig) (*v1alpha1.Condition, string) { + secretName := upstream.Spec.Bind.SecretName + + secret, err := c.secretInformer.Lister().Secrets(upstream.Namespace).Get(secretName) + if err != nil { + return &v1alpha1.Condition{ + Type: typeBindSecretValid, + Status: v1alpha1.ConditionFalse, + Reason: upstreamwatchers.ReasonNotFound, + Message: err.Error(), + }, "" + } + + if secret.Type != corev1.SecretTypeBasicAuth { + return &v1alpha1.Condition{ + Type: typeBindSecretValid, + Status: v1alpha1.ConditionFalse, + Reason: upstreamwatchers.ReasonWrongType, + Message: fmt.Sprintf("referenced Secret %q has wrong type %q (should be %q)", + secretName, secret.Type, corev1.SecretTypeBasicAuth), + }, secret.ResourceVersion + } + + config.BindUsername = string(secret.Data[corev1.BasicAuthUsernameKey]) + config.BindPassword = string(secret.Data[corev1.BasicAuthPasswordKey]) + if len(config.BindUsername) == 0 || len(config.BindPassword) == 0 { + return &v1alpha1.Condition{ + Type: typeBindSecretValid, + Status: v1alpha1.ConditionFalse, + Reason: upstreamwatchers.ReasonMissingKeys, + Message: fmt.Sprintf("referenced Secret %q is missing required keys %q", + secretName, []string{corev1.BasicAuthUsernameKey, corev1.BasicAuthPasswordKey}), + }, secret.ResourceVersion + } + + return &v1alpha1.Condition{ + Type: typeBindSecretValid, + Status: v1alpha1.ConditionTrue, + Reason: upstreamwatchers.ReasonSuccess, + Message: "loaded bind secret", + }, secret.ResourceVersion +} + +func (c *ldapWatcherController) updateStatus(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider, conditions []*v1alpha1.Condition) { + log := klogr.New().WithValues("namespace", upstream.Namespace, "name", upstream.Name) + updated := upstream.DeepCopy() + + hadErrorCondition := conditionsutil.Merge(conditions, upstream.Generation, &updated.Status.Conditions, log) + + updated.Status.Phase = v1alpha1.LDAPPhaseReady + if hadErrorCondition { + updated.Status.Phase = v1alpha1.LDAPPhaseError + } + + if equality.Semantic.DeepEqual(upstream, updated) { + return // nothing to update + } + + _, err := c.client. + IDPV1alpha1(). + LDAPIdentityProviders(upstream.Namespace). + UpdateStatus(ctx, updated, metav1.UpdateOptions{}) + if err != nil { + log.Error(err, "failed to update status") + } +} diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go new file mode 100644 index 00000000..9b0137d2 --- /dev/null +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go @@ -0,0 +1,793 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package ldapupstreamwatcher + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "sort" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + + "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + pinnipedfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + pinnipedinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions" + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/mocks/mockldapconn" + "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/upstreamldap" +) + +func TestLDAPUpstreamWatcherControllerFilterSecrets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + secret metav1.Object + wantAdd bool + wantUpdate bool + wantDelete bool + }{ + { + name: "a secret of the right type", + secret: &corev1.Secret{ + Type: corev1.SecretTypeBasicAuth, + ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"}, + }, + wantAdd: true, + wantUpdate: true, + wantDelete: true, + }, + { + name: "a secret of the wrong type", + secret: &corev1.Secret{ + Type: "this-is-the-wrong-type", + ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"}, + }, + }, + { + name: "resource of a data type which is not watched by this controller", + secret: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"}, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + fakePinnipedClient := pinnipedfake.NewSimpleClientset() + pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) + ldapIDPInformer := pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders() + fakeKubeClient := fake.NewSimpleClientset() + kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) + secretInformer := kubeInformers.Core().V1().Secrets() + withInformer := testutil.NewObservableWithInformerOption() + + New(nil, nil, ldapIDPInformer, secretInformer, withInformer.WithInformer) + + unrelated := corev1.Secret{} + filter := withInformer.GetFilterForInformer(secretInformer) + require.Equal(t, test.wantAdd, filter.Add(test.secret)) + require.Equal(t, test.wantUpdate, filter.Update(&unrelated, test.secret)) + require.Equal(t, test.wantUpdate, filter.Update(test.secret, &unrelated)) + require.Equal(t, test.wantDelete, filter.Delete(test.secret)) + }) + } +} + +func TestLDAPUpstreamWatcherControllerFilterLDAPIdentityProviders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + idp metav1.Object + wantAdd bool + wantUpdate bool + wantDelete bool + }{ + { + name: "any LDAPIdentityProvider", + idp: &v1alpha1.LDAPIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"}, + }, + wantAdd: true, + wantUpdate: true, + wantDelete: true, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + fakePinnipedClient := pinnipedfake.NewSimpleClientset() + pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) + ldapIDPInformer := pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders() + fakeKubeClient := fake.NewSimpleClientset() + kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) + secretInformer := kubeInformers.Core().V1().Secrets() + withInformer := testutil.NewObservableWithInformerOption() + + New(nil, nil, ldapIDPInformer, secretInformer, withInformer.WithInformer) + + unrelated := corev1.Secret{} + filter := withInformer.GetFilterForInformer(ldapIDPInformer) + require.Equal(t, test.wantAdd, filter.Add(test.idp)) + require.Equal(t, test.wantUpdate, filter.Update(&unrelated, test.idp)) + require.Equal(t, test.wantUpdate, filter.Update(test.idp, &unrelated)) + require.Equal(t, test.wantDelete, filter.Delete(test.idp)) + }) + } +} + +// Wrap the func into a struct so the test can do deep equal assertions on instances of upstreamldap.Provider. +type comparableDialer struct { + upstreamldap.LDAPDialerFunc +} + +func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { + t.Parallel() + now := metav1.NewTime(time.Now().UTC()) + + const ( + testNamespace = "test-namespace" + testName = "test-name" + testSecretName = "test-bind-secret" + testBindUsername = "test-bind-username" + testBindPassword = "test-bind-password" + testHost = "ldap.example.com:123" + testUserSearchBase = "test-user-search-base" + testUserSearchFilter = "test-user-search-filter" + testUsernameAttrName = "test-username-attr" + testUIDAttrName = "test-uid-attr" + ) + + testValidSecretData := map[string][]byte{"username": []byte(testBindUsername), "password": []byte(testBindPassword)} + + testCA, err := certauthority.New("test CA", time.Minute) + require.NoError(t, err) + testCABundle := testCA.Bundle() + testCABundleBase64Encoded := base64.StdEncoding.EncodeToString(testCABundle) + + validUpstream := &v1alpha1.LDAPIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Name: testName, Namespace: testNamespace, Generation: 1234}, + Spec: v1alpha1.LDAPIdentityProviderSpec{ + Host: testHost, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testCABundleBase64Encoded}, + Bind: v1alpha1.LDAPIdentityProviderBind{SecretName: testSecretName}, + UserSearch: v1alpha1.LDAPIdentityProviderUserSearch{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + Attributes: v1alpha1.LDAPIdentityProviderUserSearchAttributes{ + Username: testUsernameAttrName, + UID: testUIDAttrName, + }, + }, + }, + } + editedValidUpstream := func(editFunc func(*v1alpha1.LDAPIdentityProvider)) *v1alpha1.LDAPIdentityProvider { + deepCopy := validUpstream.DeepCopy() + editFunc(deepCopy) + return deepCopy + } + + providerConfigForValidUpstream := &upstreamldap.ProviderConfig{ + Name: testName, + Host: testHost, + CABundle: testCABundle, + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: upstreamldap.UserSearchConfig{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + UsernameAttribute: testUsernameAttrName, + UIDAttribute: testUIDAttrName, + }, + } + + bindSecretValidTrueCondition := func(gen int64) v1alpha1.Condition { + return v1alpha1.Condition{ + Type: "BindSecretValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "loaded bind secret", + ObservedGeneration: gen, + } + } + ldapConnectionValidTrueCondition := func(gen int64, secretVersion string) v1alpha1.Condition { + return v1alpha1.Condition{ + Type: "LDAPConnectionValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + testHost, testBindUsername, testSecretName, secretVersion), + ObservedGeneration: gen, + } + } + tlsConfigurationValidLoadedTrueCondition := func(gen int64) v1alpha1.Condition { + return v1alpha1.Condition{ + Type: "TLSConfigurationValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "loaded TLS configuration", + ObservedGeneration: gen, + } + } + allConditionsTrue := func(gen int64, secretVersion string) []v1alpha1.Condition { + return []v1alpha1.Condition{ + bindSecretValidTrueCondition(gen), + ldapConnectionValidTrueCondition(gen, secretVersion), + tlsConfigurationValidLoadedTrueCondition(gen), + } + } + + validBindUserSecret := func(secretVersion string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: testSecretName, Namespace: testNamespace, ResourceVersion: secretVersion}, + Type: corev1.SecretTypeBasicAuth, + Data: testValidSecretData, + } + } + + tests := []struct { + name string + initialValidatedSecretVersions map[string]string + inputUpstreams []runtime.Object + inputSecrets []runtime.Object + setupMocks func(conn *mockldapconn.MockConn) + dialError error + wantErr string + wantResultingCache []*upstreamldap.ProviderConfig + wantResultingUpstreams []v1alpha1.LDAPIdentityProvider + wantValidatedSecretVersions map[string]string + }{ + { + name: "no LDAPIdentityProvider upstreams clears the cache", + wantResultingCache: []*upstreamldap.ProviderConfig{}, + }, + { + name: "one valid upstream updates the cache to include only that upstream", + inputUpstreams: []runtime.Object{validUpstream}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "missing secret", + inputUpstreams: []runtime.Object{validUpstream}, + inputSecrets: []runtime.Object{}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "BindSecretValid", + Status: "False", + LastTransitionTime: now, + Reason: "SecretNotFound", + Message: fmt.Sprintf(`secret "%s" not found`, testSecretName), + ObservedGeneration: 1234, + }, + tlsConfigurationValidLoadedTrueCondition(1234), + }, + }, + }}, + }, + { + name: "secret has wrong type", + inputUpstreams: []runtime.Object{validUpstream}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: testSecretName, Namespace: testNamespace}, + Type: "some-other-type", + Data: testValidSecretData, + }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "BindSecretValid", + Status: "False", + LastTransitionTime: now, + Reason: "SecretWrongType", + Message: fmt.Sprintf(`referenced Secret "%s" has wrong type "some-other-type" (should be "kubernetes.io/basic-auth")`, testSecretName), + ObservedGeneration: 1234, + }, + tlsConfigurationValidLoadedTrueCondition(1234), + }, + }, + }}, + }, + { + name: "secret is missing key", + inputUpstreams: []runtime.Object{validUpstream}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: testSecretName, Namespace: testNamespace}, + Type: corev1.SecretTypeBasicAuth, + }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "BindSecretValid", + Status: "False", + LastTransitionTime: now, + Reason: "SecretMissingKeys", + Message: fmt.Sprintf(`referenced Secret "%s" is missing required keys ["username" "password"]`, testSecretName), + ObservedGeneration: 1234, + }, + tlsConfigurationValidLoadedTrueCondition(1234), + }, + }, + }}, + }, + { + name: "CertificateAuthorityData is not base64 encoded", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Spec.TLS.CertificateAuthorityData = "this-is-not-base64-encoded" + })}, + inputSecrets: []runtime.Object{validBindUserSecret("")}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + bindSecretValidTrueCondition(1234), + { + Type: "TLSConfigurationValid", + Status: "False", + LastTransitionTime: now, + Reason: "InvalidTLSConfig", + Message: "certificateAuthorityData is invalid: illegal base64 data at input byte 4", + ObservedGeneration: 1234, + }, + }, + }, + }}, + }, + { + name: "CertificateAuthorityData is not valid pem data", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Spec.TLS.CertificateAuthorityData = base64.StdEncoding.EncodeToString([]byte("this is not pem data")) + })}, + inputSecrets: []runtime.Object{validBindUserSecret("")}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + bindSecretValidTrueCondition(1234), + { + Type: "TLSConfigurationValid", + Status: "False", + LastTransitionTime: now, + Reason: "InvalidTLSConfig", + Message: "certificateAuthorityData is invalid: no certificates found", + ObservedGeneration: 1234, + }, + }, + }, + }}, + }, + { + name: "nil TLS configuration is valid", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Spec.TLS = nil + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{ + { + Name: testName, + Host: testHost, + CABundle: nil, + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: upstreamldap.UserSearchConfig{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + UsernameAttribute: testUsernameAttrName, + UIDAttribute: testUIDAttrName, + }, + }, + }, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + bindSecretValidTrueCondition(1234), + ldapConnectionValidTrueCondition(1234, "4242"), + { + Type: "TLSConfigurationValid", + Status: "True", + LastTransitionTime: now, + Reason: "Success", + Message: "no TLS configuration provided", + ObservedGeneration: 1234, + }, + }, + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "non-nil TLS configuration with empty CertificateAuthorityData is valid", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Spec.TLS.CertificateAuthorityData = "" + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{ + { + Name: testName, + Host: testHost, + CABundle: nil, + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: upstreamldap.UserSearchConfig{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + UsernameAttribute: testUsernameAttrName, + UIDAttribute: testUIDAttrName, + }, + }, + }, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "one valid upstream and one invalid upstream updates the cache to include only the valid upstream", + inputUpstreams: []runtime.Object{validUpstream, editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Name = "other-upstream" + upstream.Generation = 42 + upstream.Spec.Bind.SecretName = "non-existent-secret" + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind for the one valid upstream configuration. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "other-upstream", Generation: 42}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + { + Type: "BindSecretValid", + Status: "False", + LastTransitionTime: now, + Reason: "SecretNotFound", + Message: fmt.Sprintf(`secret "%s" not found`, "non-existent-secret"), + ObservedGeneration: 42, + }, + tlsConfigurationValidLoadedTrueCondition(42), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }, + }, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "when testing the connection to the LDAP server fails then the upstream is still added to the cache anyway (treated like a warning)", + inputUpstreams: []runtime.Object{validUpstream}, + inputSecrets: []runtime.Object{validBindUserSecret("")}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1).Return(errors.New("some bind error")) + conn.EXPECT().Close().Times(1) + }, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Error", + Conditions: []v1alpha1.Condition{ + bindSecretValidTrueCondition(1234), + { + Type: "LDAPConnectionValid", + Status: "False", + LastTransitionTime: now, + Reason: "LDAPConnectionError", + Message: fmt.Sprintf( + `could not successfully connect to "%s" and bind as user "%s": error binding as "%s": some bind error`, + testHost, testBindUsername, testBindUsername), + ObservedGeneration: 1234, + }, + tlsConfigurationValidLoadedTrueCondition(1234), + }, + }, + }}, + }, + { + name: "when the LDAP server connection was already validated for the current resource generation and secret version, then do not validate it again", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Generation = 1234 + upstream.Status.Conditions = []v1alpha1.Condition{ + ldapConnectionValidTrueCondition(1234, "4242"), + } + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + initialValidatedSecretVersions: map[string]string{testName: "4242"}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should not perform a test dial and bind. No mocking here means the test will fail if Bind() or Close() are called. + }, + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "when the LDAP server connection was validated for an older resource generation, then try to validate it again", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Generation = 1234 // current generation + upstream.Status.Conditions = []v1alpha1.Condition{ + ldapConnectionValidTrueCondition(1233, "4242"), // older spec generation! + } + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + initialValidatedSecretVersions: map[string]string{testName: "4242"}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "when the LDAP server connection validation previously failed for this resource generation, then try to validate it again", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Generation = 1234 + upstream.Status.Conditions = []v1alpha1.Condition{ + { + Type: "LDAPConnectionValid", + Status: "False", // failure! + LastTransitionTime: now, + Reason: "LDAPConnectionError", + Message: "some-error-message", + ObservedGeneration: 1234, // same (current) generation! + }, + } + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, + initialValidatedSecretVersions: map[string]string{testName: "1"}, + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + { + name: "when the LDAP server connection was already validated for this resource generation but the bind secret has changed, then try to validate it again", + inputUpstreams: []runtime.Object{editedValidUpstream(func(upstream *v1alpha1.LDAPIdentityProvider) { + upstream.Generation = 1234 + upstream.Status.Conditions = []v1alpha1.Condition{ + ldapConnectionValidTrueCondition(1234, "4241"), // same spec generation, old secret version + } + })}, + inputSecrets: []runtime.Object{validBindUserSecret("4242")}, // newer secret version! + initialValidatedSecretVersions: map[string]string{testName: "4241"}, // old version was validated + setupMocks: func(conn *mockldapconn.MockConn) { + // Should perform a test dial and bind. + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstream}, + wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + Status: v1alpha1.LDAPIdentityProviderStatus{ + Phase: "Ready", + Conditions: allConditionsTrue(1234, "4242"), + }, + }}, + wantValidatedSecretVersions: map[string]string{testName: "4242"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fakePinnipedClient := pinnipedfake.NewSimpleClientset(tt.inputUpstreams...) + pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) + fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...) + kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) + cache := provider.NewDynamicUpstreamIDPProvider() + cache.SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ + upstreamldap.New(upstreamldap.ProviderConfig{Name: "initial-entry"}), + }) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + conn := mockldapconn.NewMockConn(ctrl) + if tt.setupMocks != nil { + tt.setupMocks(conn) + } + + dialer := &comparableDialer{upstreamldap.LDAPDialerFunc(func(ctx context.Context, _ string) (upstreamldap.Conn, error) { + if tt.dialError != nil { + return nil, tt.dialError + } + return conn, nil + })} + + validatedSecretVersionCache := newSecretVersionCache() + if tt.initialValidatedSecretVersions != nil { + validatedSecretVersionCache.ResourceVersionsByName = tt.initialValidatedSecretVersions + } + + controller := newInternal( + cache, + validatedSecretVersionCache, + dialer, + fakePinnipedClient, + pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders(), + kubeInformers.Core().V1().Secrets(), + controllerlib.WithInformer, + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pinnipedInformers.Start(ctx.Done()) + kubeInformers.Start(ctx.Done()) + controllerlib.TestRunSynchronously(t, controller) + + syncCtx := controllerlib.Context{Context: ctx, Key: controllerlib.Key{}} + + if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + actualIDPList := cache.GetLDAPIdentityProviders() + require.Equal(t, len(tt.wantResultingCache), len(actualIDPList)) + for i := range actualIDPList { + actualIDP := actualIDPList[i].(*upstreamldap.Provider) + copyOfExpectedValue := *tt.wantResultingCache[i] // copy before edit to avoid race because these tests are run in parallel + // The dialer that was passed in to the controller's constructor should always have been + // passed through to the provider. + copyOfExpectedValue.Dialer = dialer + require.Equal(t, copyOfExpectedValue, actualIDP.GetConfig()) + } + + actualUpstreams, err := fakePinnipedClient.IDPV1alpha1().LDAPIdentityProviders(testNamespace).List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + + // Assert on the expected Status of the upstreams. Preprocess the upstreams a bit so that they're easier to assert against. + normalizedActualUpstreams := normalizeLDAPUpstreams(actualUpstreams.Items, now) + require.Equal(t, len(tt.wantResultingUpstreams), len(normalizedActualUpstreams)) + for i := range tt.wantResultingUpstreams { + // Require each separately to get a nice diff when the test fails. + require.Equal(t, tt.wantResultingUpstreams[i], normalizedActualUpstreams[i]) + } + + // Check that the controller remembered which version of the secret it most recently validated successfully with. + if tt.wantValidatedSecretVersions == nil { + tt.wantValidatedSecretVersions = map[string]string{} + } + require.Equal(t, tt.wantValidatedSecretVersions, validatedSecretVersionCache.ResourceVersionsByName) + }) + } +} + +func normalizeLDAPUpstreams(upstreams []v1alpha1.LDAPIdentityProvider, now metav1.Time) []v1alpha1.LDAPIdentityProvider { + result := make([]v1alpha1.LDAPIdentityProvider, 0, len(upstreams)) + for _, u := range upstreams { + normalized := u.DeepCopy() + + // We're only interested in comparing the status, so zero out the spec. + normalized.Spec = v1alpha1.LDAPIdentityProviderSpec{} + + // Round down the LastTransitionTime values to `now` if they were just updated. This makes + // it much easier to encode assertions about the expected timestamps. + for i := range normalized.Status.Conditions { + if time.Since(normalized.Status.Conditions[i].LastTransitionTime.Time) < 5*time.Second { + normalized.Status.Conditions[i].LastTransitionTime = now + } + } + result = append(result, *normalized) + } + + sort.SliceStable(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + + return result +} diff --git a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go similarity index 74% rename from internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go rename to internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go index d996492f..b610a2c6 100644 --- a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go @@ -1,8 +1,8 @@ // Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Package upstreamwatcher implements a controller that watches OIDCIdentityProvider objects. -package upstreamwatcher +// Package oidcupstreamwatcher implements a controller which watches OIDCIdentityProviders. +package oidcupstreamwatcher import ( "context" @@ -31,6 +31,8 @@ import ( idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1" "go.pinniped.dev/internal/constable" pinnipedcontroller "go.pinniped.dev/internal/controller" + "go.pinniped.dev/internal/controller/conditionsutil" + "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/upstreamoidc" @@ -38,7 +40,7 @@ import ( const ( // Setup for the name of our controller in logs. - controllerName = "upstream-observer" + oidcControllerName = "oidc-upstream-observer" // Constants related to the client credentials Secret. oidcClientSecretType corev1.SecretType = "secrets.pinniped.dev/oidc-client" @@ -47,27 +49,22 @@ const ( clientSecretDataKey = "clientSecret" // Constants related to the OIDC provider discovery cache. These do not affect the cache of JWKS. - validatorCacheTTL = 15 * time.Minute + oidcValidatorCacheTTL = 15 * time.Minute // Constants related to conditions. - typeClientCredsValid = "ClientCredentialsValid" + typeClientCredentialsValid = "ClientCredentialsValid" typeOIDCDiscoverySucceeded = "OIDCDiscoverySucceeded" - reasonNotFound = "SecretNotFound" - reasonWrongType = "SecretWrongType" - reasonMissingKeys = "SecretMissingKeys" - reasonSuccess = "Success" - reasonUnreachable = "Unreachable" - reasonInvalidTLSConfig = "InvalidTLSConfig" - reasonInvalidResponse = "InvalidResponse" + + reasonUnreachable = "Unreachable" + reasonInvalidResponse = "InvalidResponse" // Errors that are generated by our reconcile process. - errFailureStatus = constable.Error("OIDCIdentityProvider has a failing condition") - errNoCertificates = constable.Error("no certificates found") + errOIDCFailureStatus = constable.Error("OIDCIdentityProvider has a failing condition") ) -// IDPCache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations. -type IDPCache interface { - SetIDPList([]provider.UpstreamOIDCIdentityProviderI) +// UpstreamOIDCIdentityProviderICache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations. +type UpstreamOIDCIdentityProviderICache interface { + SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI) } // lruValidatorCache caches the *oidc.Provider associated with a particular issuer/TLS configuration. @@ -87,7 +84,7 @@ func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec) } func (c *lruValidatorCache) putProvider(spec *v1alpha1.OIDCIdentityProviderSpec, provider *oidc.Provider, client *http.Client) { - c.cache.Set(c.cacheKey(spec), &lruValidatorCacheEntry{provider: provider, client: client}, validatorCacheTTL) + c.cache.Set(c.cacheKey(spec), &lruValidatorCacheEntry{provider: provider, client: client}, oidcValidatorCacheTTL) } func (c *lruValidatorCache) cacheKey(spec *v1alpha1.OIDCIdentityProviderSpec) interface{} { @@ -99,8 +96,8 @@ func (c *lruValidatorCache) cacheKey(spec *v1alpha1.OIDCIdentityProviderSpec) in return key } -type controller struct { - cache IDPCache +type oidcWatcherController struct { + cache UpstreamOIDCIdentityProviderICache log logr.Logger client pinnipedclientset.Interface oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer @@ -111,25 +108,25 @@ type controller struct { } } -// New instantiates a new controllerlib.Controller which will populate the provided IDPCache. +// New instantiates a new controllerlib.Controller which will populate the provided UpstreamOIDCIdentityProviderICache. func New( - idpCache IDPCache, + idpCache UpstreamOIDCIdentityProviderICache, client pinnipedclientset.Interface, oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer, secretInformer corev1informers.SecretInformer, log logr.Logger, withInformer pinnipedcontroller.WithInformerOptionFunc, ) controllerlib.Controller { - c := controller{ + c := oidcWatcherController{ cache: idpCache, - log: log.WithName(controllerName), + log: log.WithName(oidcControllerName), client: client, oidcIdentityProviderInformer: oidcIdentityProviderInformer, secretInformer: secretInformer, validatorCache: &lruValidatorCache{cache: cache.NewExpiring()}, } return controllerlib.New( - controllerlib.Config{Name: controllerName, Syncer: &c}, + controllerlib.Config{Name: oidcControllerName, Syncer: &c}, withInformer( oidcIdentityProviderInformer, pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()), @@ -144,7 +141,7 @@ func New( } // Sync implements controllerlib.Syncer. -func (c *controller) Sync(ctx controllerlib.Context) error { +func (c *oidcWatcherController) Sync(ctx controllerlib.Context) error { actualUpstreams, err := c.oidcIdentityProviderInformer.Lister().List(labels.Everything()) if err != nil { return fmt.Errorf("failed to list OIDCIdentityProviders: %w", err) @@ -160,7 +157,7 @@ func (c *controller) Sync(ctx controllerlib.Context) error { validatedUpstreams = append(validatedUpstreams, provider.UpstreamOIDCIdentityProviderI(valid)) } } - c.cache.SetIDPList(validatedUpstreams) + c.cache.SetOIDCIdentityProviders(validatedUpstreams) if requeue { return controllerlib.ErrSyntheticRequeue } @@ -169,7 +166,7 @@ func (c *controller) Sync(ctx controllerlib.Context) error { // validateUpstream validates the provided v1alpha1.OIDCIdentityProvider and returns the validated configuration as a // provider.UpstreamOIDCIdentityProvider. As a side effect, it also updates the status of the v1alpha1.OIDCIdentityProvider. -func (c *controller) validateUpstream(ctx controllerlib.Context, upstream *v1alpha1.OIDCIdentityProvider) *upstreamoidc.ProviderConfig { +func (c *oidcWatcherController) validateUpstream(ctx controllerlib.Context, upstream *v1alpha1.OIDCIdentityProvider) *upstreamoidc.ProviderConfig { result := upstreamoidc.ProviderConfig{ Name: upstream.Name, Config: &oauth2.Config{ @@ -193,7 +190,7 @@ func (c *controller) validateUpstream(ctx controllerlib.Context, upstream *v1alp "type", condition.Type, "reason", condition.Reason, "message", condition.Message, - ).Error(errFailureStatus, "found failing condition") + ).Error(errOIDCFailureStatus, "found failing condition") } } if valid { @@ -203,16 +200,16 @@ func (c *controller) validateUpstream(ctx controllerlib.Context, upstream *v1alp } // validateSecret validates the .spec.client.secretName field and returns the appropriate ClientCredentialsValid condition. -func (c *controller) validateSecret(upstream *v1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) *v1alpha1.Condition { +func (c *oidcWatcherController) validateSecret(upstream *v1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) *v1alpha1.Condition { secretName := upstream.Spec.Client.SecretName // Fetch the Secret from informer cache. secret, err := c.secretInformer.Lister().Secrets(upstream.Namespace).Get(secretName) if err != nil { return &v1alpha1.Condition{ - Type: typeClientCredsValid, + Type: typeClientCredentialsValid, Status: v1alpha1.ConditionFalse, - Reason: reasonNotFound, + Reason: upstreamwatchers.ReasonNotFound, Message: err.Error(), } } @@ -220,9 +217,9 @@ func (c *controller) validateSecret(upstream *v1alpha1.OIDCIdentityProvider, res // Validate the secret .type field. if secret.Type != oidcClientSecretType { return &v1alpha1.Condition{ - Type: typeClientCredsValid, + Type: typeClientCredentialsValid, Status: v1alpha1.ConditionFalse, - Reason: reasonWrongType, + Reason: upstreamwatchers.ReasonWrongType, Message: fmt.Sprintf("referenced Secret %q has wrong type %q (should be %q)", secretName, secret.Type, oidcClientSecretType), } } @@ -232,9 +229,9 @@ func (c *controller) validateSecret(upstream *v1alpha1.OIDCIdentityProvider, res clientSecret := secret.Data[clientSecretDataKey] if len(clientID) == 0 || len(clientSecret) == 0 { return &v1alpha1.Condition{ - Type: typeClientCredsValid, + Type: typeClientCredentialsValid, Status: v1alpha1.ConditionFalse, - Reason: reasonMissingKeys, + Reason: upstreamwatchers.ReasonMissingKeys, Message: fmt.Sprintf("referenced Secret %q is missing required keys %q", secretName, []string{clientIDDataKey, clientSecretDataKey}), } } @@ -243,15 +240,15 @@ func (c *controller) validateSecret(upstream *v1alpha1.OIDCIdentityProvider, res result.Config.ClientID = string(clientID) result.Config.ClientSecret = string(clientSecret) return &v1alpha1.Condition{ - Type: typeClientCredsValid, + Type: typeClientCredentialsValid, Status: v1alpha1.ConditionTrue, - Reason: reasonSuccess, + Reason: upstreamwatchers.ReasonSuccess, Message: "loaded client credentials", } } // validateIssuer validates the .spec.issuer field, performs OIDC discovery, and returns the appropriate OIDCDiscoverySucceeded condition. -func (c *controller) validateIssuer(ctx context.Context, upstream *v1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) *v1alpha1.Condition { +func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1alpha1.OIDCIdentityProvider, result *upstreamoidc.ProviderConfig) *v1alpha1.Condition { // Get the provider and HTTP Client from cache if possible. discoveredProvider, httpClient := c.validatorCache.getProvider(&upstream.Spec) @@ -262,7 +259,7 @@ func (c *controller) validateIssuer(ctx context.Context, upstream *v1alpha1.OIDC return &v1alpha1.Condition{ Type: typeOIDCDiscoverySucceeded, Status: v1alpha1.ConditionFalse, - Reason: reasonInvalidTLSConfig, + Reason: upstreamwatchers.ReasonInvalidTLSConfig, Message: err.Error(), } } @@ -314,11 +311,35 @@ func (c *controller) validateIssuer(ctx context.Context, upstream *v1alpha1.OIDC return &v1alpha1.Condition{ Type: typeOIDCDiscoverySucceeded, Status: v1alpha1.ConditionTrue, - Reason: reasonSuccess, + Reason: upstreamwatchers.ReasonSuccess, Message: "discovered issuer configuration", } } +func (c *oidcWatcherController) updateStatus(ctx context.Context, upstream *v1alpha1.OIDCIdentityProvider, conditions []*v1alpha1.Condition) { + log := c.log.WithValues("namespace", upstream.Namespace, "name", upstream.Name) + updated := upstream.DeepCopy() + + hadErrorCondition := conditionsutil.Merge(conditions, upstream.Generation, &updated.Status.Conditions, log) + + updated.Status.Phase = v1alpha1.PhaseReady + if hadErrorCondition { + updated.Status.Phase = v1alpha1.PhaseError + } + + if equality.Semantic.DeepEqual(upstream, updated) { + return + } + + _, err := c.client. + IDPV1alpha1(). + OIDCIdentityProviders(upstream.Namespace). + UpdateStatus(ctx, updated, metav1.UpdateOptions{}) + if err != nil { + log.Error(err, "failed to update status") + } +} + func getTLSConfig(upstream *v1alpha1.OIDCIdentityProvider) (*tls.Config, error) { result := tls.Config{ MinVersion: tls.VersionTLS12, @@ -335,81 +356,12 @@ func getTLSConfig(upstream *v1alpha1.OIDCIdentityProvider) (*tls.Config, error) result.RootCAs = x509.NewCertPool() if !result.RootCAs.AppendCertsFromPEM(bundle) { - return nil, fmt.Errorf("spec.certificateAuthorityData is invalid: %w", errNoCertificates) + return nil, fmt.Errorf("spec.certificateAuthorityData is invalid: %w", upstreamwatchers.ErrNoCertificates) } return &result, nil } -func (c *controller) updateStatus(ctx context.Context, upstream *v1alpha1.OIDCIdentityProvider, conditions []*v1alpha1.Condition) { - log := c.log.WithValues("namespace", upstream.Namespace, "name", upstream.Name) - updated := upstream.DeepCopy() - - updated.Status.Phase = v1alpha1.PhaseReady - - for i := range conditions { - cond := conditions[i].DeepCopy() - cond.LastTransitionTime = metav1.Now() - cond.ObservedGeneration = upstream.Generation - if mergeCondition(&updated.Status.Conditions, cond) { - log.Info("updated condition", "type", cond.Type, "status", cond.Status, "reason", cond.Reason, "message", cond.Message) - } - if cond.Status == v1alpha1.ConditionFalse { - updated.Status.Phase = v1alpha1.PhaseError - } - } - - sort.SliceStable(updated.Status.Conditions, func(i, j int) bool { - return updated.Status.Conditions[i].Type < updated.Status.Conditions[j].Type - }) - - if equality.Semantic.DeepEqual(upstream, updated) { - return - } - - _, err := c.client. - IDPV1alpha1(). - OIDCIdentityProviders(upstream.Namespace). - UpdateStatus(ctx, updated, metav1.UpdateOptions{}) - if err != nil { - log.Error(err, "failed to update status") - } -} - -// mergeCondition merges a new v1alpha1.Condition into a slice of existing conditions. It returns true -// if the condition has meaningfully changed. -func mergeCondition(existing *[]v1alpha1.Condition, new *v1alpha1.Condition) bool { - // Find any existing condition with a matching type. - var old *v1alpha1.Condition - for i := range *existing { - if (*existing)[i].Type == new.Type { - old = &(*existing)[i] - continue - } - } - - // If there is no existing condition of this type, append this one and we're done. - if old == nil { - *existing = append(*existing, *new) - return true - } - - // Set the LastTransitionTime depending on whether the status has changed. - new = new.DeepCopy() - if old.Status == new.Status { - new.LastTransitionTime = old.LastTransitionTime - } - - // If anything has actually changed, update the entry and return true. - if !equality.Semantic.DeepEqual(old, new) { - *old = *new - return true - } - - // Otherwise the entry is already up to date. - return false -} - func computeScopes(additionalScopes []string) []string { // First compute the unique set of scopes, including "openid" (de-duplicate). set := make(map[string]bool, len(additionalScopes)+1) diff --git a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go similarity index 74% rename from internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go rename to internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go index 164c08ed..9370aad3 100644 --- a/internal/controller/supervisorconfig/upstreamwatcher/upstreamwatcher_test.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go @@ -1,7 +1,7 @@ // Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package upstreamwatcher +package oidcupstreamwatcher import ( "context" @@ -24,14 +24,14 @@ import ( pinnipedfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" pinnipedinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions" "go.pinniped.dev/internal/controllerlib" - "go.pinniped.dev/internal/oidc/oidctestutil" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/internal/testutil/testlogger" "go.pinniped.dev/internal/upstreamoidc" ) -func TestControllerFilterSecret(t *testing.T) { +func TestOIDCUpstreamWatcherControllerFilterSecret(t *testing.T) { t.Parallel() tests := []struct { @@ -76,7 +76,7 @@ func TestControllerFilterSecret(t *testing.T) { kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) testLog := testlogger.New(t) cache := provider.NewDynamicUpstreamIDPProvider() - cache.SetIDPList([]provider.UpstreamOIDCIdentityProviderI{ + cache.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ &upstreamoidc.ProviderConfig{Name: "initial-entry"}, }) secretInformer := kubeInformers.Core().V1().Secrets() @@ -101,7 +101,7 @@ func TestControllerFilterSecret(t *testing.T) { } } -func TestController(t *testing.T) { +func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { t.Parallel() now := metav1.NewTime(time.Now().UTC()) earlier := metav1.NewTime(now.Add(-1 * time.Hour).UTC()) @@ -150,9 +150,9 @@ func TestController(t *testing.T) { inputSecrets: []runtime.Object{}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="secret \"test-client-secret\" not found" "reason"="SecretNotFound" "status"="False" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="secret \"test-client-secret\" not found" "name"="test-name" "namespace"="test-namespace" "reason"="SecretNotFound" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="secret \"test-client-secret\" not found" "reason"="SecretNotFound" "status"="False" "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"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="secret \"test-client-secret\" not found" "name"="test-name" "namespace"="test-namespace" "reason"="SecretNotFound" "type"="ClientCredentialsValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -196,9 +196,9 @@ func TestController(t *testing.T) { }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "reason"="SecretWrongType" "status"="False" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "name"="test-name" "namespace"="test-namespace" "reason"="SecretWrongType" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "reason"="SecretWrongType" "status"="False" "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"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "name"="test-name" "namespace"="test-namespace" "reason"="SecretWrongType" "type"="ClientCredentialsValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -241,9 +241,9 @@ func TestController(t *testing.T) { }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "reason"="SecretMissingKeys" "status"="False" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "name"="test-name" "namespace"="test-namespace" "reason"="SecretMissingKeys" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "reason"="SecretMissingKeys" "status"="False" "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"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "name"="test-name" "namespace"="test-namespace" "reason"="SecretMissingKeys" "type"="ClientCredentialsValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -289,9 +289,9 @@ func TestController(t *testing.T) { }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, + `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"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -337,9 +337,9 @@ func TestController(t *testing.T) { }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: no certificates found" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: no certificates found" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, + `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"="spec.certificateAuthorityData is invalid: no certificates found" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: no certificates found" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -382,10 +382,10 @@ func TestController(t *testing.T) { }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `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"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `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"`, - `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"`, + `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-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{{ @@ -430,9 +430,9 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, + `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 parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -476,9 +476,9 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, - `upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, + `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"="authorization endpoint URL scheme must be \"https\", not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -529,8 +529,8 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u Data: testValidSecretData, }}, wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `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{ @@ -578,8 +578,8 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u Data: testValidSecretData, }}, wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `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{ @@ -627,8 +627,8 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u Data: testValidSecretData, }}, wantLogs: []string{ - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `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{ @@ -669,10 +669,10 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `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"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `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"`, - `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"`, + `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{{ @@ -717,10 +717,10 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs }}, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ - `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"`, - `upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, - `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"`, - `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"`, + `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{{ @@ -758,7 +758,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) testLog := testlogger.New(t) cache := provider.NewDynamicUpstreamIDPProvider() - cache.SetIDPList([]provider.UpstreamOIDCIdentityProviderI{ + cache.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ &upstreamoidc.ProviderConfig{Name: "initial-entry"}, }) @@ -787,7 +787,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs } require.Equal(t, strings.Join(tt.wantLogs, "\n"), strings.Join(testLog.Lines(), "\n")) - actualIDPList := cache.GetIDPList() + actualIDPList := cache.GetOIDCIdentityProviders() require.Equal(t, len(tt.wantResultingCache), len(actualIDPList)) for i := range actualIDPList { actualIDP := actualIDPList[i].(*upstreamoidc.ProviderConfig) @@ -802,8 +802,8 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs actualUpstreams, err := fakePinnipedClient.IDPV1alpha1().OIDCIdentityProviders(testNamespace).List(ctx, metav1.ListOptions{}) require.NoError(t, err) - // Preprocess the set of upstreams a bit so that they're easier to assert against. - require.ElementsMatch(t, tt.wantResultingUpstreams, normalizeUpstreams(actualUpstreams.Items, now)) + // Assert on the expected Status of the upstreams. Preprocess the upstreams a bit so that they're easier to assert against. + require.ElementsMatch(t, tt.wantResultingUpstreams, normalizeOIDCUpstreams(actualUpstreams.Items, now)) // Running the sync() a second time should be idempotent except for logs, and should return the same error. // This also helps exercise code paths where the OIDC provider discovery hits cache. @@ -816,7 +816,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs } } -func normalizeUpstreams(upstreams []v1alpha1.OIDCIdentityProvider, now metav1.Time) []v1alpha1.OIDCIdentityProvider { +func normalizeOIDCUpstreams(upstreams []v1alpha1.OIDCIdentityProvider, now metav1.Time) []v1alpha1.OIDCIdentityProvider { result := make([]v1alpha1.OIDCIdentityProvider, 0, len(upstreams)) for _, u := range upstreams { normalized := u.DeepCopy() diff --git a/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go b/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go new file mode 100644 index 00000000..36bd37c8 --- /dev/null +++ b/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go @@ -0,0 +1,16 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package upstreamwatchers + +import "go.pinniped.dev/internal/constable" + +const ( + ReasonNotFound = "SecretNotFound" + ReasonWrongType = "SecretWrongType" + ReasonMissingKeys = "SecretMissingKeys" + ReasonSuccess = "Success" + ReasonInvalidTLSConfig = "InvalidTLSConfig" + + ErrNoCertificates = constable.Error("no certificates found") +) diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 6da85d3f..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 diff --git a/internal/fositestoragei/fosite_storage_interface.go b/internal/fositestoragei/fosite_storage_interface.go new file mode 100644 index 00000000..408dfb28 --- /dev/null +++ b/internal/fositestoragei/fosite_storage_interface.go @@ -0,0 +1,21 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fositestoragei + +import ( + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/pkce" +) + +// This interface seems to be missing from Fosite. +// Not having this interface makes it a pain to avoid cyclical test dependencies, so we'll define it. +type AllFositeStorage interface { + fosite.ClientManager + oauth2.CoreStorage + oauth2.TokenRevocationStorage + openid.OpenIDConnectRequestStorage + pkce.PKCERequestStorage +} diff --git a/internal/mocks/mockldapconn/generate.go b/internal/mocks/mockldapconn/generate.go new file mode 100644 index 00000000..e9bf5943 --- /dev/null +++ b/internal/mocks/mockldapconn/generate.go @@ -0,0 +1,6 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mockldapconn + +//go:generate go run -v github.com/golang/mock/mockgen -destination=mockldapconn.go -package=mockldapconn -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/upstreamldap Conn diff --git a/internal/mocks/mockldapconn/mockldapconn.go b/internal/mocks/mockldapconn/mockldapconn.go new file mode 100644 index 00000000..a96cf79c --- /dev/null +++ b/internal/mocks/mockldapconn/mockldapconn.go @@ -0,0 +1,80 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: go.pinniped.dev/internal/upstreamldap (interfaces: Conn) + +// Package mockldapconn is a generated GoMock package. +package mockldapconn + +import ( + reflect "reflect" + + ldap "github.com/go-ldap/ldap/v3" + gomock "github.com/golang/mock/gomock" +) + +// MockConn is a mock of Conn interface. +type MockConn struct { + ctrl *gomock.Controller + recorder *MockConnMockRecorder +} + +// MockConnMockRecorder is the mock recorder for MockConn. +type MockConnMockRecorder struct { + mock *MockConn +} + +// NewMockConn creates a new mock instance. +func NewMockConn(ctrl *gomock.Controller) *MockConn { + mock := &MockConn{ctrl: ctrl} + mock.recorder = &MockConnMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConn) EXPECT() *MockConnMockRecorder { + return m.recorder +} + +// Bind mocks base method. +func (m *MockConn) Bind(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Bind", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Bind indicates an expected call of Bind. +func (mr *MockConnMockRecorder) Bind(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Bind", reflect.TypeOf((*MockConn)(nil).Bind), arg0, arg1) +} + +// Close mocks base method. +func (m *MockConn) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close. +func (mr *MockConnMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockConn)(nil).Close)) +} + +// Search mocks base method. +func (m *MockConn) Search(arg0 *ldap.SearchRequest) (*ldap.SearchResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", arg0) + ret0, _ := ret[0].(*ldap.SearchResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Search indicates an expected call of Search. +func (mr *MockConnMockRecorder) Search(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockConn)(nil).Search), arg0) +} diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index e5d359a8..21aad56c 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.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 auth provides a handler for the OIDC authorization endpoint. @@ -13,6 +13,7 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" + "github.com/pkg/errors" "golang.org/x/oauth2" "go.pinniped.dev/internal/httputil/httperr" @@ -25,10 +26,16 @@ import ( "go.pinniped.dev/pkg/oidcclient/pkce" ) +const ( + CustomUsernameHeaderName = "Pinniped-Username" + CustomPasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential +) + func NewHandler( downstreamIssuer string, - idpListGetter oidc.IDPListGetter, - oauthHelper fosite.OAuth2Provider, + idpLister oidc.UpstreamIdentityProvidersLister, + oauthHelperWithoutStorage fosite.OAuth2Provider, + oauthHelperWithStorage fosite.OAuth2Provider, generateCSRF func() (csrftoken.CSRFToken, error), generatePKCE func() (pkce.Code, error), generateNonce func() (nonce.Nonce, error), @@ -43,109 +50,206 @@ func NewHandler( return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) } - csrfFromCookie := readCSRFCookie(r, cookieCodec) - - authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r) - if err != nil { - plog.Info("authorize request error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil - } - - upstreamIDP, err := chooseUpstreamIDP(idpListGetter) + oidcUpstream, ldapUpstream, err := chooseUpstreamIDP(idpLister) if err != nil { plog.WarningErr("authorize upstream config", err) return err } - // Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations. - oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID) - // There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope - // at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite. - oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess) - - // Grant the pinniped:request-audience scope if requested. - oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience") - - now := time.Now() - _, err = oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. - Subject: "none", - AuthTime: now, - RequestedAt: now, - }, - }) - if err != nil { - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + if oidcUpstream != nil { + return handleAuthRequestForOIDCUpstream(r, w, + oauthHelperWithoutStorage, + generateCSRF, generateNonce, generatePKCE, + oidcUpstream, + downstreamIssuer, + upstreamStateEncoder, + cookieCodec, + ) } - - csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) - if err != nil { - plog.Error("authorize generate error", err) - return err - } - if csrfFromCookie != "" { - csrfValue = csrfFromCookie - } - - upstreamOAuthConfig := oauth2.Config{ - ClientID: upstreamIDP.GetClientID(), - Endpoint: oauth2.Endpoint{ - AuthURL: upstreamIDP.GetAuthorizationURL().String(), - }, - RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuer), - Scopes: upstreamIDP.GetScopes(), - } - - encodedStateParamValue, err := upstreamStateParam( - authorizeRequester, - upstreamIDP.GetName(), - nonceValue, - csrfValue, - pkceValue, - upstreamStateEncoder, + return handleAuthRequestForLDAPUpstream(r, w, + oauthHelperWithStorage, + ldapUpstream, ) - if err != nil { - plog.Error("authorize upstream state param error", err) - return err - } - - if csrfFromCookie == "" { - // We did not receive an incoming CSRF cookie, so write a new one. - err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec) - if err != nil { - plog.Error("error setting CSRF cookie", err) - return err - } - } - - authCodeOptions := []oauth2.AuthCodeOption{ - oauth2.AccessTypeOffline, - nonceValue.Param(), - pkceValue.Challenge(), - pkceValue.Method(), - } - - promptParam := r.Form.Get("prompt") - if promptParam != "" && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("prompt", promptParam)) - } - - http.Redirect(w, r, - upstreamOAuthConfig.AuthCodeURL( - encodedStateParamValue, - authCodeOptions..., - ), - 302, - ) - - return nil })) } +func handleAuthRequestForLDAPUpstream( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + ldapUpstream provider.UpstreamLDAPIdentityProviderI, +) error { + authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) + if !created { + return nil + } + + username := r.Header.Get(CustomUsernameHeaderName) + password := r.Header.Get(CustomPasswordHeaderName) + if username == "" || password == "" { + // Return an error according to OIDC spec 3.1.2.6 (second paragraph). + err := errors.WithStack(fosite.ErrAccessDenied.WithHintf("Missing or blank username or password.")) + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil + } + + authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password) + if err != nil { + plog.WarningErr("unexpected error during upstream LDAP authentication", err, "upstreamName", ldapUpstream.GetName()) + return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication") + } + if !authenticated { + plog.Debug("failed upstream LDAP authentication", "upstreamName", ldapUpstream.GetName()) + // Return an error according to OIDC spec 3.1.2.6 (second paragraph). + err = errors.WithStack(fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider.")) + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil + } + + subject := fmt.Sprintf("%s?%s=%s", ldapUpstream.GetURL(), oidc.IDTokenSubjectClaim, authenticateResponse.User.GetUID()) + now := time.Now().UTC() + openIDSession := &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + Subject: subject, + RequestedAt: now, + AuthTime: now, + }, + } + openIDSession.Claims.Extra = map[string]interface{}{ + oidc.DownstreamUsernameClaim: authenticateResponse.User.GetName(), + oidc.DownstreamGroupsClaim: authenticateResponse.User.GetGroups(), + } + + authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) + if err != nil { + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil + } + + oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) + return nil +} + +func handleAuthRequestForOIDCUpstream( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + generateCSRF func() (csrftoken.CSRFToken, error), + generateNonce func() (nonce.Nonce, error), + generatePKCE func() (pkce.Code, error), + oidcUpstream provider.UpstreamOIDCIdentityProviderI, + downstreamIssuer string, + upstreamStateEncoder oidc.Encoder, + cookieCodec oidc.Codec, +) error { + authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) + if !created { + return nil + } + + now := time.Now() + _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. + Subject: "none", + AuthTime: now, + RequestedAt: now, + }, + }) + if err != nil { + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil + } + + csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) + if err != nil { + plog.Error("authorize generate error", err) + return err + } + csrfFromCookie := readCSRFCookie(r, cookieCodec) + if csrfFromCookie != "" { + csrfValue = csrfFromCookie + } + + upstreamOAuthConfig := oauth2.Config{ + ClientID: oidcUpstream.GetClientID(), + Endpoint: oauth2.Endpoint{ + AuthURL: oidcUpstream.GetAuthorizationURL().String(), + }, + RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuer), + Scopes: oidcUpstream.GetScopes(), + } + + encodedStateParamValue, err := upstreamStateParam( + authorizeRequester, + oidcUpstream.GetName(), + nonceValue, + csrfValue, + pkceValue, + upstreamStateEncoder, + ) + if err != nil { + plog.Error("authorize upstream state param error", err) + return err + } + + if csrfFromCookie == "" { + // We did not receive an incoming CSRF cookie, so write a new one. + err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec) + if err != nil { + plog.Error("error setting CSRF cookie", err) + return err + } + } + + authCodeOptions := []oauth2.AuthCodeOption{ + oauth2.AccessTypeOffline, + nonceValue.Param(), + pkceValue.Challenge(), + pkceValue.Method(), + } + + promptParam := r.Form.Get("prompt") + if promptParam != "" && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("prompt", promptParam)) + } + + http.Redirect(w, r, + upstreamOAuthConfig.AuthCodeURL( + encodedStateParamValue, + authCodeOptions..., + ), + 302, + ) + + return nil +} + +func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider) (fosite.AuthorizeRequester, bool) { + authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r) + if err != nil { + plog.Info("authorize request error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil, false + } + grantScopes(authorizeRequester) + return authorizeRequester, true +} + +func grantScopes(authorizeRequester fosite.AuthorizeRequester) { + // Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations. + oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID) + // There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope + // at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite. + oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess) + // Grant the pinniped:request-audience scope if requested. + oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience") +} + func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken { receivedCSRFCookie, err := r.Cookie(oidc.CSRFCookieName) if err != nil { @@ -165,27 +269,34 @@ func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken { return csrfFromCookie } -func chooseUpstreamIDP(idpListGetter oidc.IDPListGetter) (provider.UpstreamOIDCIdentityProviderI, error) { - allUpstreamIDPs := idpListGetter.GetIDPList() - if len(allUpstreamIDPs) == 0 { - return nil, httperr.New( +// Select either an OIDC or an LDAP IDP, or return an error. +func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, provider.UpstreamLDAPIdentityProviderI, error) { + oidcUpstreams := idpLister.GetOIDCIdentityProviders() + ldapUpstreams := idpLister.GetLDAPIdentityProviders() + switch { + case len(oidcUpstreams)+len(ldapUpstreams) == 0: + return nil, nil, httperr.New( http.StatusUnprocessableEntity, "No upstream providers are configured", ) - } else if len(allUpstreamIDPs) > 1 { + case len(oidcUpstreams)+len(ldapUpstreams) > 1: var upstreamIDPNames []string - for _, idp := range allUpstreamIDPs { + for _, idp := range oidcUpstreams { + upstreamIDPNames = append(upstreamIDPNames, idp.GetName()) + } + for _, idp := range ldapUpstreams { upstreamIDPNames = append(upstreamIDPNames, idp.GetName()) } - plog.Warning("Too many upstream providers are configured (found: %s)", upstreamIDPNames) - - return nil, httperr.New( + return nil, nil, httperr.New( http.StatusUnprocessableEntity, "Too many upstream providers are configured (support for multiple upstreams is not yet implemented)", ) + case len(oidcUpstreams) == 1: + return oidcUpstreams[0], nil, nil + default: + return nil, ldapUpstreams[0], nil } - return allUpstreamIDPs[0], nil } func generateValues( diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index a0f715d1..d65dbf4b 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -1,9 +1,10 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package auth import ( + "context" "fmt" "html" "net/http" @@ -14,15 +15,21 @@ import ( "testing" "github.com/gorilla/securecookie" + "github.com/ory/fosite" "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/kubernetes/fake" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/utils/pointer" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidctestutil" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) @@ -32,7 +39,13 @@ func TestAuthorizationEndpoint(t *testing.T) { downstreamIssuer = "https://my-downstream-issuer.com/some-path" downstreamRedirectURI = "http://127.0.0.1/callback" downstreamRedirectURIWithDifferentPort = "http://127.0.0.1:42/callback" + downstreamNonce = "some-nonce-value" + downstreamPKCEChallenge = "some-challenge" + downstreamPKCEChallengeMethod = "S256" happyState = "8b-state" + downstreamClientID = "pinniped-cli" + upstreamLDAPURL = "ldaps://some-ldap-host:123" + htmlContentType = "text/html; charset=utf-8" ) require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -99,24 +112,78 @@ func TestAuthorizationEndpoint(t *testing.T) { "error_description": "The authorization server does not support obtaining a token using this method. `The request is missing the 'response_type' parameter.", "state": happyState, } + + fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", + "state": happyState, + } + + fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Missing or blank username or password.", + "state": happyState, + } ) + hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } + require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") + jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() + timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() + + createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) { + // Configure fosite the same way that the production code would when using Kube storage. + // Inject this into our test subject at the last second so we get a fresh storage for every test. + kubeOauthStore := oidc.NewKubeStorage(secretsClient, timeoutsConfiguration) + return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore + } + + // Configure fosite the same way that the production code would, using NullStorage to turn off storage. + nullOauthStore := oidc.NullStorage{} + oauthHelperWithNullStorage := oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") require.NoError(t, err) upstreamOIDCIdentityProvider := oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: "some-idp", + Name: "some-oidc-idp", ClientID: "some-client-id", AuthorizationURL: *upstreamAuthURL, Scopes: []string{"scope1", "scope2"}, // the scopes to request when starting the upstream authorization flow } - // Configure fosite the same way that the production code would, using NullStorage to turn off storage. - oauthStore := oidc.NullStorage{} - hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } - require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") - jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() - oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, oidc.DefaultOIDCTimeoutsConfiguration()) + happyLDAPUsername := "some-ldap-user" + happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" + happyLDAPPassword := "some-ldap-password" //nolint:gosec + happyLDAPUID := "some-ldap-uid" + happyLDAPGroups := []string{"group1", "group2", "group3"} + + upstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: "some-ldap-idp", + URL: upstreamLDAPURL, + AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { + if username == "" || password == "" { + return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator") + } + if username == happyLDAPUsername && password == happyLDAPPassword { + return &authenticator.Response{ + User: &user.DefaultInfo{ + Name: happyLDAPUsernameFromAuthenticator, + UID: happyLDAPUID, + Groups: happyLDAPGroups, + }, + }, true, nil + } + return nil, false, nil + }, + } + + erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: "some-ldap-idp", + AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { + return nil, false, fmt.Errorf("some ldap upstream auth error") + }, + } happyCSRF := "test-csrf" happyPKCE := "test-pkce" @@ -124,6 +191,9 @@ func TestAuthorizationEndpoint(t *testing.T) { happyCSRFGenerator := func() (csrftoken.CSRFToken, error) { return csrftoken.CSRFToken(happyCSRF), nil } happyPKCEGenerator := func() (pkce.Code, error) { return pkce.Code(happyPKCE), nil } happyNonceGenerator := func() (nonce.Nonce, error) { return nonce.Nonce(happyNonce), nil } + sadCSRFGenerator := func() (csrftoken.CSRFToken, error) { return "", fmt.Errorf("some csrf generator error") } + sadPKCEGenerator := func() (pkce.Code, error) { return "", fmt.Errorf("some PKCE generator error") } + sadNonceGenerator := func() (nonce.Nonce, error) { return "", fmt.Errorf("some nonce generator error") } // This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example: // $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1 @@ -162,14 +232,17 @@ func TestAuthorizationEndpoint(t *testing.T) { return urlToReturn } + happyDownstreamScopesRequested := []string{"openid", "profile", "email"} + happyDownstreamScopesGranted := []string{"openid"} + happyGetRequestQueryMap := map[string]string{ "response_type": "code", - "scope": "openid profile email", - "client_id": "pinniped-cli", + "scope": strings.Join(happyDownstreamScopesRequested, " "), + "client_id": downstreamClientID, "state": happyState, - "nonce": "some-nonce-value", - "code_challenge": "some-challenge", - "code_challenge_method": "S256", + "nonce": downstreamNonce, + "code_challenge": downstreamPKCEChallenge, + "code_challenge_method": downstreamPKCEChallengeMethod, "redirect_uri": downstreamRedirectURI, } @@ -218,7 +291,7 @@ func TestAuthorizationEndpoint(t *testing.T) { return encoded } - expectedRedirectLocation := func(expectedUpstreamState string, expectedPrompt string) string { + expectedRedirectLocationForUpstreamOIDC := func(expectedUpstreamState string, expectedPrompt string) string { query := map[string]string{ "response_type": "code", "access_type": "offline", @@ -227,7 +300,7 @@ func TestAuthorizationEndpoint(t *testing.T) { "state": expectedUpstreamState, "nonce": happyNonce, "code_challenge": expectedUpstreamCodeChallenge, - "code_challenge_method": "S256", + "code_challenge_method": downstreamPKCEChallengeMethod, "redirect_uri": downstreamIssuer + "/callback", } if expectedPrompt != "" { @@ -236,6 +309,9 @@ func TestAuthorizationEndpoint(t *testing.T) { return urlWithQuery(upstreamAuthURL.String(), query) } + // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it + happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyState + incomingCookieCSRFValue := "csrf-value-from-cookie" encodedIncomingCookieCSRFValue, err := happyCookieEncoder.Encode("csrf", incomingCookieCSRFValue) require.NoError(t, err) @@ -243,34 +319,46 @@ func TestAuthorizationEndpoint(t *testing.T) { type testCase struct { name string - issuer string - idpListGetter provider.DynamicUpstreamIDPProvider - generateCSRF func() (csrftoken.CSRFToken, error) - generatePKCE func() (pkce.Code, error) - generateNonce func() (nonce.Nonce, error) - stateEncoder oidc.Codec - cookieEncoder oidc.Codec - method string - path string - contentType string - body string - csrfCookie string + idpLister provider.DynamicUpstreamIDPProvider + generateCSRF func() (csrftoken.CSRFToken, error) + generatePKCE func() (pkce.Code, error) + generateNonce func() (nonce.Nonce, error) + stateEncoder oidc.Codec + cookieEncoder oidc.Codec + method string + path string + contentType string + body string + csrfCookie string + customUsernameHeader *string // nil means do not send header, empty means send header with empty value + customPasswordHeader *string // nil means do not send header, empty means send header with empty value - wantStatus int - wantContentType string - wantBodyString string - wantBodyJSON string - wantLocationHeader string - wantCSRFValueInCookieHeader string - - wantUpstreamStateParamInLocationHeader bool + wantStatus int + wantContentType string + wantBodyString string + wantBodyJSON string + wantCSRFValueInCookieHeader string wantBodyStringWithLocationInHref bool + wantLocationHeader string + wantUpstreamStateParamInLocationHeader bool + + // For when the request was authenticated by an upstream LDAP provider and an authcode is being returned. + wantRedirectLocationRegexp string + wantDownstreamRedirectURI string + wantDownstreamGrantedScopes []string + wantDownstreamIDTokenSubject string + wantDownstreamIDTokenUsername string + wantDownstreamIDTokenGroups []string + wantDownstreamRequestedScopes []string + wantDownstreamPKCEChallenge string + wantDownstreamPKCEChallengeMethod string + wantDownstreamNonce string + wantUnnecessaryStoredRecords int } tests := []testCase{ { - name: "happy path using GET without a CSRF cookie", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "OIDC upstream happy path using GET without a CSRF cookie", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -279,16 +367,36 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", + wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "", ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { - name: "happy path using GET with a CSRF cookie", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "LDAP upstream happy path using GET", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantBodyStringWithLocationInHref: false, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream happy path using GET with a CSRF cookie", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -298,15 +406,14 @@ func TestAuthorizationEndpoint(t *testing.T) { path: happyGetRequestPath, csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), ""), + wantContentType: htmlContentType, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), ""), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { - name: "happy path using POST", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "OIDC upstream happy path using POST", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -320,13 +427,35 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: "", wantBodyString: "", wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "", ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), wantUpstreamStateParamInLocationHeader: true, }, { - name: "happy path with prompt param login passed through to redirect uri", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "LDAP upstream happy path using POST", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodPost, + path: "/some/path", + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantBodyStringWithLocationInHref: false, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream happy path with prompt param login passed through to redirect uri", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -337,16 +466,15 @@ func TestAuthorizationEndpoint(t *testing.T) { contentType: "application/x-www-form-urlencoded", body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", + wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), "login"), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), "login"), wantUpstreamStateParamInLocationHeader: true, }, { - name: "error while decoding CSRF cookie just generates a new cookie and succeeds as usual", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "OIDC upstream with error while decoding CSRF cookie just generates a new cookie and succeeds as usual", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -356,17 +484,16 @@ func TestAuthorizationEndpoint(t *testing.T) { path: happyGetRequestPath, csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", + wantContentType: htmlContentType, // Generated a new CSRF cookie and set it in the response. wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(nil, "", ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { - name: "happy path when downstream redirect uri matches what is configured for client except for the port number", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "OIDC upstream happy path when downstream redirect uri matches what is configured for client except for the port number", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -377,18 +504,40 @@ func TestAuthorizationEndpoint(t *testing.T) { "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", + wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(map[string]string{ + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }, "", ""), ""), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { - name: "happy path when downstream requested scopes include offline_access", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "LDAP upstream happy path when downstream redirect uri matches what is configured for client except for the port number", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client + }), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, + wantBodyStringWithLocationInHref: false, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURIWithDifferentPort, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream happy path when downstream requested scopes include offline_access", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -397,18 +546,76 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid offline_access"}), wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", + wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam(map[string]string{ + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "scope": "openid offline_access", }, "", ""), ""), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { - name: "downstream redirect uri does not match what is configured for client", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "error during upstream LDAP authentication", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusBadGateway, + wantContentType: htmlContentType, + wantBodyString: "Bad Gateway: unexpected error during upstream authentication\n", + }, + { + name: "wrong upstream password for LDAP authentication", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr("wrong-password"), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), + wantBodyString: "", + }, + { + name: "wrong upstream username for LDAP authentication", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr("wrong-username"), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), + wantBodyString: "", + }, + { + name: "missing upstream username on request for LDAP authentication", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: nil, // do not send header + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), + wantBodyString: "", + }, + { + name: "missing upstream password on request for LDAP authentication", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: nil, // do not send header + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), + wantBodyString: "", + }, + { + name: "downstream redirect uri does not match what is configured for client when using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -423,9 +630,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { - name: "downstream client does not exist", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "downstream redirect uri does not match what is configured for client when using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", + }), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusBadRequest, + wantContentType: "application/json; charset=utf-8", + wantBodyJSON: fositeInvalidRedirectURIErrorBody, + }, + { + name: "downstream client does not exist when using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -438,9 +657,17 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidClientErrorBody, }, { - name: "response type is unsupported", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "downstream client does not exist when using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), + wantStatus: http.StatusUnauthorized, + wantContentType: "application/json; charset=utf-8", + wantBodyJSON: fositeInvalidClientErrorBody, + }, + { + name: "response type is unsupported when using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -454,9 +681,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "downstream scopes do not match what is configured for client", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "response type is unsupported when using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), + wantBodyString: "", + }, + { + name: "downstream scopes do not match what is configured for client using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -470,9 +706,20 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing response type in request", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "downstream scopes do not match what is configured for client using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid tuna"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), + wantBodyString: "", + }, + { + name: "missing response type in request using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -486,9 +733,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing client id in request", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "missing response type in request using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), + wantBodyString: "", + }, + { + name: "missing client id in request using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -501,9 +757,17 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidClientErrorBody, }, { - name: "missing PKCE code_challenge in request", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "missing client id in request using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": ""}), + wantStatus: http.StatusUnauthorized, + wantContentType: "application/json; charset=utf-8", + wantBodyJSON: fositeInvalidClientErrorBody, + }, + { + name: "missing PKCE code_challenge in request using OIDC upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -517,9 +781,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "invalid value for PKCE code_challenge_method in request", // https://tools.ietf.org/html/rfc7636#section-4.3 - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "missing PKCE code_challenge in request using LDAP upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), + wantBodyString: "", + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "invalid value for PKCE code_challenge_method in request using OIDC upstream", // https://tools.ietf.org/html/rfc7636#section-4.3 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -533,9 +809,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "when PKCE code_challenge_method in request is `plain`", // https://tools.ietf.org/html/rfc7636#section-4.3 - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "invalid value for PKCE code_challenge_method in request using LDAP upstream", // https://tools.ietf.org/html/rfc7636#section-4.3 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), + wantBodyString: "", + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream", // https://tools.ietf.org/html/rfc7636#section-4.3 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -549,9 +837,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing PKCE code_challenge_method in request", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "when PKCE code_challenge_method in request is `plain` using LDAP upstream", // https://tools.ietf.org/html/rfc7636#section-4.3 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), + wantBodyString: "", + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "missing PKCE code_challenge_method in request using OIDC upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -564,12 +864,24 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", }, + { + name: "missing PKCE code_challenge_method in request using LDAP upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), + wantBodyString: "", + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, { // This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running - // through that part of the fosite library. - name: "prompt param is not allowed to have none and another legal value at the same time", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + // through that part of the fosite library when using an OIDC upstream. + name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -583,9 +895,23 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "OIDC validations are skipped when the openid scope was not requested", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + // This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running + // through that part of the fosite library when using an LDAP upstream. + name: "prompt param is not allowed to have none and another legal value at the same time using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), + wantBodyString: "", + wantUnnecessaryStoredRecords: 1, // fosite already stored the authcode before it noticed the error + }, + { + name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -595,18 +921,39 @@ func TestAuthorizationEndpoint(t *testing.T) { // The following prompt value is illegal when openid is requested, but note that openid is not requested. path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}), wantStatus: http.StatusFound, - wantContentType: "text/html; charset=utf-8", + wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocation(expectedUpstreamStateParam( + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam( map[string]string{"prompt": "none login", "scope": "email"}, "", "", ), ""), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { - name: "state does not have enough entropy", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + // The following prompt value is illegal when openid is requested, but note that openid is not requested. + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted + wantBodyStringWithLocationInHref: false, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "?sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: []string{"email"}, // only email was requested + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{}, // no scopes granted + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "downstream state does not have enough entropy using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -620,9 +967,20 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "error while encoding upstream state param", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "downstream state does not have enough entropy using LDAP upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), + wantBodyString: "", + }, + { + name: "error while encoding upstream state param using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -635,9 +993,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error encoding upstream state param\n", }, { - name: "error while encoding CSRF cookie value for new cookie", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "error while encoding CSRF cookie value for new cookie using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -650,10 +1007,9 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error encoding CSRF cookie\n", }, { - name: "error while generating CSRF token", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), - generateCSRF: func() (csrftoken.CSRFToken, error) { return "", fmt.Errorf("some csrf generator error") }, + name: "error while generating CSRF token using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + generateCSRF: sadCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, stateEncoder: happyStateEncoder, @@ -665,12 +1021,11 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error generating CSRF token\n", }, { - name: "error while generating nonce", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "error while generating nonce using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, - generateNonce: func() (nonce.Nonce, error) { return "", fmt.Errorf("some nonce generator error") }, + generateNonce: sadNonceGenerator, stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, @@ -680,11 +1035,10 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error generating nonce param\n", }, { - name: "error while generating PKCE", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + name: "error while generating PKCE using OIDC upstream", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), generateCSRF: happyCSRFGenerator, - generatePKCE: func() (pkce.Code, error) { return "", fmt.Errorf("some PKCE generator error") }, + generatePKCE: sadPKCEGenerator, generateNonce: happyNonceGenerator, stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, @@ -696,8 +1050,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "no upstream providers are configured", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(), // empty + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC().Build(), // empty method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -705,9 +1058,26 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Unprocessable Entity: No upstream providers are configured\n", }, { - name: "too many upstream providers are configured", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider, &upstreamOIDCIdentityProvider), // more than one not allowed + name: "too many upstream providers are configured: multiple OIDC", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider, &upstreamOIDCIdentityProvider).Build(), // more than one not allowed + method: http.MethodGet, + path: happyGetRequestPath, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "text/plain; charset=utf-8", + wantBodyString: "Unprocessable Entity: Too many upstream providers are configured (support for multiple upstreams is not yet implemented)\n", + }, + { + name: "too many upstream providers are configured: multiple LDAP", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider, &upstreamLDAPIdentityProvider).Build(), // more than one not allowed + method: http.MethodGet, + path: happyGetRequestPath, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "text/plain; charset=utf-8", + wantBodyString: "Unprocessable Entity: Too many upstream providers are configured (support for multiple upstreams is not yet implemented)\n", + }, + { + name: "too many upstream providers are configured: both OIDC and LDAP", + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).WithLDAP(&upstreamLDAPIdentityProvider).Build(), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -716,8 +1086,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PUT is a bad method", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), method: http.MethodPut, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -726,8 +1095,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PATCH is a bad method", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), method: http.MethodPatch, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -736,8 +1104,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "DELETE is a bad method", - issuer: downstreamIssuer, - idpListGetter: oidctestutil.NewIDPListGetter(&upstreamOIDCIdentityProvider), + idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), method: http.MethodDelete, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -746,12 +1113,18 @@ func TestAuthorizationEndpoint(t *testing.T) { }, } - runOneTestCase := func(t *testing.T, test testCase, subject http.Handler) { + runOneTestCase := func(t *testing.T, test testCase, subject http.Handler, kubeOauthStore *oidc.KubeStorage, kubeClient *fake.Clientset, secretsClient v1.SecretInterface) { req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) req.Header.Set("Content-Type", test.contentType) if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) } + if test.customUsernameHeader != nil { + req.Header.Set("Pinniped-Username", *test.customUsernameHeader) + } + if test.customPasswordHeader != nil { + req.Header.Set("Pinniped-Password", *test.customPasswordHeader) + } rsp := httptest.NewRecorder() subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) @@ -762,7 +1135,8 @@ func TestAuthorizationEndpoint(t *testing.T) { testutil.RequireSecurityHeaders(t, rsp) actualLocation := rsp.Header().Get("Location") - if test.wantLocationHeader != "" { + switch { + case test.wantLocationHeader != "": if test.wantUpstreamStateParamInLocationHeader { requireEqualDecodedStateParams(t, actualLocation, test.wantLocationHeader, test.stateEncoder) } @@ -770,7 +1144,34 @@ func TestAuthorizationEndpoint(t *testing.T) { // compare those states since they may be different, but we do want to compare the downstream // state param that should be exactly the same. requireEqualURLs(t, actualLocation, test.wantLocationHeader, test.wantUpstreamStateParamInLocationHeader) - } else { + + // Authorization requests for either a successful OIDC upstream or for an error with any upstream + // should never use Kube storage. There is only one exception to this rule, which is that certain + // OIDC validations are checked in fosite after the OAuth authcode (and sometimes the OIDC session) + // is stored, so it is possible with an LDAP upstream to store objects and then return an error to + // the client anyway (which makes the stored objects useless, but oh well). + require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords) + case test.wantRedirectLocationRegexp != "": + require.Len(t, rsp.Header().Values("Location"), 1) + oidctestutil.RequireAuthcodeRedirectLocation( + t, + rsp.Header().Get("Location"), + test.wantRedirectLocationRegexp, + kubeClient, + secretsClient, + kubeOauthStore, + test.wantDownstreamGrantedScopes, + test.wantDownstreamIDTokenSubject, + test.wantDownstreamIDTokenUsername, + test.wantDownstreamIDTokenGroups, + test.wantDownstreamRequestedScopes, + test.wantDownstreamPKCEChallenge, + test.wantDownstreamPKCEChallengeMethod, + test.wantDownstreamNonce, + downstreamClientID, + test.wantDownstreamRedirectURI, + ) + default: require.Empty(t, rsp.Header().Values("Location")) } @@ -803,18 +1204,36 @@ func TestAuthorizationEndpoint(t *testing.T) { for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { - subject := NewHandler(test.issuer, test.idpListGetter, oauthHelper, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder) - runOneTestCase(t, test, subject) + kubeClient := fake.NewSimpleClientset() + secretsClient := kubeClient.CoreV1().Secrets("some-namespace") + oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient) + subject := NewHandler( + downstreamIssuer, + test.idpLister, + oauthHelperWithNullStorage, oauthHelperWithRealStorage, + test.generateCSRF, test.generatePKCE, test.generateNonce, + test.stateEncoder, test.cookieEncoder, + ) + runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient) }) } t.Run("allows upstream provider configuration to change between requests", func(t *testing.T) { test := tests[0] - require.Equal(t, "happy path using GET without a CSRF cookie", test.name) // re-use the happy path test case + require.Equal(t, "OIDC upstream happy path using GET without a CSRF cookie", test.name) // re-use the happy path test case - subject := NewHandler(test.issuer, test.idpListGetter, oauthHelper, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder) + kubeClient := fake.NewSimpleClientset() + secretsClient := kubeClient.CoreV1().Secrets("some-namespace") + oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient) + subject := NewHandler( + downstreamIssuer, + test.idpLister, + oauthHelperWithNullStorage, oauthHelperWithRealStorage, + test.generateCSRF, test.generatePKCE, test.generateNonce, + test.stateEncoder, test.cookieEncoder, + ) - runOneTestCase(t, test, subject) + runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient) // Call the setter to change the upstream IDP settings. newProviderSettings := oidctestutil.TestUpstreamOIDCIdentityProvider{ @@ -823,7 +1242,7 @@ func TestAuthorizationEndpoint(t *testing.T) { AuthorizationURL: *upstreamAuthURL, Scopes: []string{"other-scope1", "other-scope2"}, } - test.idpListGetter.SetIDPList([]provider.UpstreamOIDCIdentityProviderI{provider.UpstreamOIDCIdentityProviderI(&newProviderSettings)}) + test.idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{provider.UpstreamOIDCIdentityProviderI(&newProviderSettings)}) // Update the expectations of the test case to match the new upstream IDP settings. test.wantLocationHeader = urlWithQuery(upstreamAuthURL.String(), @@ -835,7 +1254,7 @@ func TestAuthorizationEndpoint(t *testing.T) { "state": expectedUpstreamStateParam(nil, "", newProviderSettings.Name), "nonce": happyNonce, "code_challenge": expectedUpstreamCodeChallenge, - "code_challenge_method": "S256", + "code_challenge_method": downstreamPKCEChallengeMethod, "redirect_uri": downstreamIssuer + "/callback", }, ) @@ -848,7 +1267,7 @@ func TestAuthorizationEndpoint(t *testing.T) { // modified expectations. This should ensure that the implementation is using the in-memory cache // of upstream IDP settings appropriately in terms of always getting the values from the cache // on every request. - runOneTestCase(t, test, subject) + runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient) }) } diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index a103824e..5bece1d9 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -33,7 +33,7 @@ const ( ) func NewHandler( - idpListGetter oidc.IDPListGetter, + upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister, oauthHelper fosite.OAuth2Provider, stateDecoder, cookieDecoder oidc.Decoder, redirectURI string, @@ -44,7 +44,7 @@ func NewHandler( return err } - upstreamIDPConfig := findUpstreamIDPConfig(state.UpstreamName, idpListGetter) + upstreamIDPConfig := findUpstreamIDPConfig(state.UpstreamName, upstreamIDPs) if upstreamIDPConfig == nil { plog.Warning("upstream provider not found") return httperr.New(http.StatusUnprocessableEntity, "upstream provider not found") @@ -143,8 +143,8 @@ func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) return state, nil } -func findUpstreamIDPConfig(upstreamName string, idpListGetter oidc.IDPListGetter) provider.UpstreamOIDCIdentityProviderI { - for _, p := range idpListGetter.GetIDPList() { +func findUpstreamIDPConfig(upstreamName string, upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister) provider.UpstreamOIDCIdentityProviderI { + for _, p := range upstreamIDPs.GetOIDCIdentityProviders() { if p.GetName() == upstreamName { return p } diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 218b6753..c999cba4 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -9,26 +9,17 @@ import ( "net/http" "net/http/httptest" "net/url" - "regexp" "strings" "testing" - "time" "github.com/gorilla/securecookie" - "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes/fake" - "go.pinniped.dev/internal/crud" - "go.pinniped.dev/internal/fositestorage/authorizationcode" - "go.pinniped.dev/internal/fositestorage/openidconnect" - "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidctestutil" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" oidcpkce "go.pinniped.dev/pkg/oidcclient/pkce" @@ -44,8 +35,7 @@ const ( upstreamUsernameClaim = "the-user-claim" upstreamGroupsClaim = "the-groups-claim" - happyUpstreamAuthcode = "upstream-auth-code" - + happyUpstreamAuthcode = "upstream-auth-code" happyUpstreamRedirectURI = "https://example.com/callback" happyDownstreamState = "8b-state" @@ -61,8 +51,7 @@ const ( downstreamPKCEChallenge = "some-challenge" downstreamPKCEChallengeMethod = "S256" - authCodeExpirationSeconds = 10 * 60 // Current, we set our auth code expiration to 10 minutes - timeComparisonFudgeFactor = time.Second * 15 + htmlContentType = "text/html; charset=utf-8" ) var ( @@ -129,6 +118,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie string wantStatus int + wantContentType string wantBody string wantRedirectLocationRegexp string wantDownstreamGrantedScopes []string @@ -252,6 +242,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has invalid format\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -264,6 +255,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: email_verified claim in upstream ID token has false value\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -327,57 +319,64 @@ func TestCallbackEndpoint(t *testing.T) { // Pre-upstream-exchange verification { - name: "PUT method is invalid", - method: http.MethodPut, - path: newRequestPath().String(), - wantStatus: http.StatusMethodNotAllowed, - wantBody: "Method Not Allowed: PUT (try GET)\n", + name: "PUT method is invalid", + method: http.MethodPut, + path: newRequestPath().String(), + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: PUT (try GET)\n", }, { - name: "POST method is invalid", - method: http.MethodPost, - path: newRequestPath().String(), - wantStatus: http.StatusMethodNotAllowed, - wantBody: "Method Not Allowed: POST (try GET)\n", + name: "POST method is invalid", + method: http.MethodPost, + path: newRequestPath().String(), + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: POST (try GET)\n", }, { - name: "PATCH method is invalid", - method: http.MethodPatch, - path: newRequestPath().String(), - wantStatus: http.StatusMethodNotAllowed, - wantBody: "Method Not Allowed: PATCH (try GET)\n", + name: "PATCH method is invalid", + method: http.MethodPatch, + path: newRequestPath().String(), + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: PATCH (try GET)\n", }, { - name: "DELETE method is invalid", - method: http.MethodDelete, - path: newRequestPath().String(), - wantStatus: http.StatusMethodNotAllowed, - wantBody: "Method Not Allowed: DELETE (try GET)\n", + name: "DELETE method is invalid", + method: http.MethodDelete, + path: newRequestPath().String(), + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: DELETE (try GET)\n", }, { - name: "code param was not included on request", - method: http.MethodGet, - path: newRequestPath().WithState(happyState).WithoutCode().String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusBadRequest, - wantBody: "Bad Request: code param not found\n", + name: "code param was not included on request", + method: http.MethodGet, + path: newRequestPath().WithState(happyState).WithoutCode().String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: code param not found\n", }, { - name: "state param was not included on request", - method: http.MethodGet, - path: newRequestPath().WithoutState().String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusBadRequest, - wantBody: "Bad Request: state param not found\n", + name: "state param was not included on request", + method: http.MethodGet, + path: newRequestPath().WithoutState().String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: state param not found\n", }, { - name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason", - idp: happyUpstream().Build(), - method: http.MethodGet, - path: newRequestPath().WithState("this-will-not-decode").String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusBadRequest, - wantBody: "Bad Request: error reading state\n", + name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason", + idp: happyUpstream().Build(), + method: http.MethodGet, + path: newRequestPath().WithState("this-will-not-decode").String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error reading state\n", }, { // This shouldn't happen in practice because the authorize endpoint should have already run the same @@ -393,16 +392,18 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, wantStatus: http.StatusInternalServerError, + wantContentType: htmlContentType, wantBody: "Internal Server Error: error while generating and saving authcode\n", }, { - name: "state's internal version does not match what we want", - idp: happyUpstream().Build(), - method: http.MethodGet, - path: newRequestPath().WithState(happyUpstreamStateParam().WithStateVersion("wrong-state-version").Build(t, happyStateCodec)).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusUnprocessableEntity, - wantBody: "Unprocessable Entity: state format version is invalid\n", + name: "state's internal version does not match what we want", + idp: happyUpstream().Build(), + method: http.MethodGet, + path: newRequestPath().WithState(happyUpstreamStateParam().WithStateVersion("wrong-state-version").Build(t, happyStateCodec)).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: state format version is invalid\n", }, { name: "state's downstream auth params element is invalid", @@ -411,9 +412,10 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyUpstreamStateParam(). WithAuthorizeRequestParams("the following is an invalid url encoding token, and therefore this is an invalid param: %z"). Build(t, happyStateCodec)).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusBadRequest, - wantBody: "Bad Request: error reading state downstream auth params\n", + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error reading state downstream auth params\n", }, { name: "state's downstream auth params are missing required value (e.g., client_id)", @@ -424,9 +426,10 @@ func TestCallbackEndpoint(t *testing.T) { WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": ""}).Encode()). Build(t, happyStateCodec), ).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusBadRequest, - wantBody: "Bad Request: error using state downstream auth params\n", + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error using state downstream auth params\n", }, { name: "state's downstream auth params does not contain openid scope", @@ -474,39 +477,43 @@ func TestCallbackEndpoint(t *testing.T) { wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, { - name: "the OIDCIdentityProvider CRD has been deleted", - idp: otherUpstreamOIDCIdentityProvider, - method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusUnprocessableEntity, - wantBody: "Unprocessable Entity: upstream provider not found\n", + name: "the OIDCIdentityProvider CRD has been deleted", + idp: otherUpstreamOIDCIdentityProvider, + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: upstream provider not found\n", }, { - name: "the CSRF cookie does not exist on request", - idp: happyUpstream().Build(), - method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), - wantStatus: http.StatusForbidden, - wantBody: "Forbidden: CSRF cookie is missing\n", + name: "the CSRF cookie does not exist on request", + idp: happyUpstream().Build(), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: CSRF cookie is missing\n", }, { - name: "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason", - idp: happyUpstream().Build(), - method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), - csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", - wantStatus: http.StatusForbidden, - wantBody: "Forbidden: error reading CSRF cookie\n", + name: "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason", + idp: happyUpstream().Build(), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: error reading CSRF cookie\n", }, { - name: "cookie csrf value does not match state csrf value", - idp: happyUpstream().Build(), - method: http.MethodGet, - path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusForbidden, - wantBody: "Forbidden: CSRF value does not match\n", + name: "cookie csrf value does not match state csrf value", + idp: happyUpstream().Build(), + method: http.MethodGet, + path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: CSRF value does not match\n", }, // Upstream exchange @@ -518,6 +525,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusBadGateway, wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n", + wantContentType: htmlContentType, wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, { @@ -528,6 +536,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantBody: "Unprocessable Entity: no username claim in upstream ID token\n", + wantContentType: htmlContentType, wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, { @@ -556,6 +565,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: username claim in upstream ID token has invalid format\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -566,6 +576,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: issuer claim in upstream ID token missing\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -576,6 +587,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: issuer claim in upstream ID token has invalid format\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -586,6 +598,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -596,6 +609,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -606,6 +620,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, }, @@ -626,8 +641,8 @@ func TestCallbackEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) - idpListGetter := oidctestutil.NewIDPListGetter(&test.idp) - subject := NewHandler(idpListGetter, oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) + idpLister := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&test.idp).Build() + subject := NewHandler(idpLister, oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) req := httptest.NewRequest(test.method, test.path, nil) if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) @@ -648,6 +663,7 @@ func TestCallbackEndpoint(t *testing.T) { } require.Equal(t, test.wantStatus, rsp.Code) + testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) if test.wantBody != "" { require.Equal(t, test.wantBody, rsp.Body.String()) @@ -656,79 +672,30 @@ func TestCallbackEndpoint(t *testing.T) { } if test.wantRedirectLocationRegexp != "" { //nolint:nestif // don't mind have several sequential if statements in this test - // Assert that Location header matches regular expression. require.Len(t, rsp.Header().Values("Location"), 1) - actualLocation := rsp.Header().Get("Location") - regex := regexp.MustCompile(test.wantRedirectLocationRegexp) - submatches := regex.FindStringSubmatch(actualLocation) - require.Lenf(t, submatches, 2, "no regexp match in actualLocation: %q", actualLocation) - capturedAuthCode := submatches[1] - - // fosite authcodes are in the format `data.signature`, so grab the signature part, which is the lookup key in the storage interface - authcodeDataAndSignature := strings.Split(capturedAuthCode, ".") - require.Len(t, authcodeDataAndSignature, 2) - - // Several Secrets should have been created - expectedNumberOfCreatedSecrets := 2 - if includesOpenIDScope(test.wantDownstreamGrantedScopes) { - expectedNumberOfCreatedSecrets++ - } - require.Len(t, client.Actions(), expectedNumberOfCreatedSecrets) - - // One authcode should have been stored. - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) - - storedRequestFromAuthcode, storedSessionFromAuthcode := validateAuthcodeStorage( + oidctestutil.RequireAuthcodeRedirectLocation( t, + rsp.Header().Get("Location"), + test.wantRedirectLocationRegexp, + client, + secrets, oauthStore, - authcodeDataAndSignature[1], // Authcode store key is authcode signature test.wantDownstreamGrantedScopes, test.wantDownstreamIDTokenSubject, test.wantDownstreamIDTokenUsername, test.wantDownstreamIDTokenGroups, test.wantDownstreamRequestedScopes, - ) - - // One PKCE should have been stored. - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: pkce.TypeLabelValue}, 1) - - validatePKCEStorage( - t, - oauthStore, - authcodeDataAndSignature[1], // PKCE store key is authcode signature - storedRequestFromAuthcode, - storedSessionFromAuthcode, test.wantDownstreamPKCEChallenge, test.wantDownstreamPKCEChallengeMethod, + test.wantDownstreamNonce, + downstreamClientID, + downstreamRedirectURI, ) - - // One IDSession should have been stored, if the downstream actually requested the "openid" scope - if includesOpenIDScope(test.wantDownstreamGrantedScopes) { - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1) - - validateIDSessionStorage( - t, - oauthStore, - capturedAuthCode, // IDSession store key is full authcode - storedRequestFromAuthcode, - storedSessionFromAuthcode, - test.wantDownstreamNonce, - ) - } } }) } } -func includesOpenIDScope(scopes []string) bool { - for _, scope := range scopes { - if scope == "openid" { - return true - } - } - return false -} - type requestPath struct { code, state *string } @@ -898,141 +865,3 @@ func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string } return copied } - -func validateAuthcodeStorage( - t *testing.T, - oauthStore *oidc.KubeStorage, - storeKey string, - wantDownstreamGrantedScopes []string, - wantDownstreamIDTokenSubject string, - wantDownstreamIDTokenUsername string, - wantDownstreamIDTokenGroups []string, - wantDownstreamRequestedScopes []string, -) (*fosite.Request, *openid.DefaultSession) { - t.Helper() - - // Get the authcode session back from storage so we can require that it was stored correctly. - storedAuthorizeRequestFromAuthcode, err := oauthStore.GetAuthorizeCodeSession(context.Background(), storeKey, nil) - require.NoError(t, err) - - // Check that storage returned the expected concrete data types. - storedRequestFromAuthcode, storedSessionFromAuthcode := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromAuthcode) - - // Check which scopes were granted. - require.ElementsMatch(t, wantDownstreamGrantedScopes, storedRequestFromAuthcode.GetGrantedScopes()) - - // Check all the other fields of the stored request. - require.NotEmpty(t, storedRequestFromAuthcode.ID) - require.Equal(t, downstreamClientID, storedRequestFromAuthcode.Client.GetID()) - require.ElementsMatch(t, wantDownstreamRequestedScopes, storedRequestFromAuthcode.RequestedScope) - require.Nil(t, storedRequestFromAuthcode.RequestedAudience) - require.Empty(t, storedRequestFromAuthcode.GrantedAudience) - require.Equal(t, url.Values{"redirect_uri": []string{downstreamRedirectURI}}, storedRequestFromAuthcode.Form) - testutil.RequireTimeInDelta(t, time.Now(), storedRequestFromAuthcode.RequestedAt, timeComparisonFudgeFactor) - - // We're not using these fields yet, so confirm that we did not set them (for now). - require.Empty(t, storedSessionFromAuthcode.Subject) - require.Empty(t, storedSessionFromAuthcode.Username) - require.Empty(t, storedSessionFromAuthcode.Headers) - - // The authcode that we are issuing should be good for the length of time that we declare in the fosite config. - testutil.RequireTimeInDelta(t, time.Now().Add(authCodeExpirationSeconds*time.Second), storedSessionFromAuthcode.ExpiresAt[fosite.AuthorizeCode], timeComparisonFudgeFactor) - require.Len(t, storedSessionFromAuthcode.ExpiresAt, 1) - - // Now confirm the ID token claims. - actualClaims := storedSessionFromAuthcode.Claims - - // Check the user's identity, which are put into the downstream ID token's subject, username and groups claims. - require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject) - require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"]) - require.Len(t, actualClaims.Extra, 2) - actualDownstreamIDTokenGroups := actualClaims.Extra["groups"] - require.NotNil(t, actualDownstreamIDTokenGroups) - require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualDownstreamIDTokenGroups) - - // Check the rest of the downstream ID token's claims. Fosite wants us to set these (in UTC time). - testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.RequestedAt, timeComparisonFudgeFactor) - testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.AuthTime, timeComparisonFudgeFactor) - requestedAtZone, _ := actualClaims.RequestedAt.Zone() - require.Equal(t, "UTC", requestedAtZone) - authTimeZone, _ := actualClaims.AuthTime.Zone() - require.Equal(t, "UTC", authTimeZone) - - // Fosite will set these fields for us in the token endpoint based on the store session - // information. Therefore, we assert that they are empty because we want the library to do the - // lifting for us. - require.Empty(t, actualClaims.Issuer) - require.Nil(t, actualClaims.Audience) - require.Empty(t, actualClaims.Nonce) - require.Zero(t, actualClaims.ExpiresAt) - require.Zero(t, actualClaims.IssuedAt) - - // These are not needed yet. - require.Empty(t, actualClaims.JTI) - require.Empty(t, actualClaims.CodeHash) - require.Empty(t, actualClaims.AccessTokenHash) - require.Empty(t, actualClaims.AuthenticationContextClassReference) - require.Empty(t, actualClaims.AuthenticationMethodsReference) - - return storedRequestFromAuthcode, storedSessionFromAuthcode -} - -func validatePKCEStorage( - t *testing.T, - oauthStore *oidc.KubeStorage, - storeKey string, - storedRequestFromAuthcode *fosite.Request, - storedSessionFromAuthcode *openid.DefaultSession, - wantDownstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod string, -) { - t.Helper() - - storedAuthorizeRequestFromPKCE, err := oauthStore.GetPKCERequestSession(context.Background(), storeKey, nil) - require.NoError(t, err) - - // Check that storage returned the expected concrete data types. - storedRequestFromPKCE, storedSessionFromPKCE := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromPKCE) - - // The stored PKCE request should be the same as the stored authcode request. - require.Equal(t, storedRequestFromAuthcode.ID, storedRequestFromPKCE.ID) - require.Equal(t, storedSessionFromAuthcode, storedSessionFromPKCE) - - // The stored PKCE request should also contain the PKCE challenge that the downstream sent us. - require.Equal(t, wantDownstreamPKCEChallenge, storedRequestFromPKCE.Form.Get("code_challenge")) - require.Equal(t, wantDownstreamPKCEChallengeMethod, storedRequestFromPKCE.Form.Get("code_challenge_method")) -} - -func validateIDSessionStorage( - t *testing.T, - oauthStore *oidc.KubeStorage, - storeKey string, - storedRequestFromAuthcode *fosite.Request, - storedSessionFromAuthcode *openid.DefaultSession, - wantDownstreamNonce string, -) { - t.Helper() - - storedAuthorizeRequestFromIDSession, err := oauthStore.GetOpenIDConnectSession(context.Background(), storeKey, nil) - require.NoError(t, err) - - // Check that storage returned the expected concrete data types. - storedRequestFromIDSession, storedSessionFromIDSession := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromIDSession) - - // The stored IDSession request should be the same as the stored authcode request. - require.Equal(t, storedRequestFromAuthcode.ID, storedRequestFromIDSession.ID) - require.Equal(t, storedSessionFromAuthcode, storedSessionFromIDSession) - - // The stored IDSession request should also contain the nonce that the downstream sent us. - require.Equal(t, wantDownstreamNonce, storedRequestFromIDSession.Form.Get("nonce")) -} - -func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requester) (*fosite.Request, *openid.DefaultSession) { - t.Helper() - - storedRequest, ok := storedAuthorizeRequest.(*fosite.Request) - require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest, &fosite.Request{}) - storedSession, ok := storedAuthorizeRequest.GetSession().(*openid.DefaultSession) - require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest.GetSession(), &openid.DefaultSession{}) - - return storedRequest, storedSession -} diff --git a/internal/oidc/discovery/discovery_handler.go b/internal/oidc/discovery/discovery_handler.go index b45c1042..e472c012 100644 --- a/internal/oidc/discovery/discovery_handler.go +++ b/internal/oidc/discovery/discovery_handler.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 discovery provides a handler for the OIDC discovery endpoint. @@ -37,6 +37,21 @@ type Metadata struct { ClaimsSupported []string `json:"claims_supported"` // ^^^ Optional ^^^ + + // vvv Custom vvv + + SupervisorDiscovery SupervisorDiscoveryMetadataV1Alpha1 `json:"discovery.supervisor.pinniped.dev/v1alpha1"` + + // ^^^ Custom ^^^ +} + +type SupervisorDiscoveryMetadataV1Alpha1 struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +type IdentityProviderMetadata struct { + Name string `json:"name"` + Type string `json:"type"` } // NewHandler returns an http.Handler that serves an OIDC discovery endpoint. @@ -46,6 +61,7 @@ func NewHandler(issuerURL string) http.Handler { AuthorizationEndpoint: issuerURL + oidc.AuthorizationEndpointPath, TokenEndpoint: issuerURL + oidc.TokenEndpointPath, JWKSURI: issuerURL + oidc.JWKSEndpointPath, + SupervisorDiscovery: SupervisorDiscoveryMetadataV1Alpha1{PinnipedIDPsEndpoint: issuerURL + oidc.PinnipedIDPsPathV1Alpha1}, ResponseTypesSupported: []string{"code"}, SubjectTypesSupported: []string{"public"}, IDTokenSigningAlgValuesSupported: []string{"ES256"}, diff --git a/internal/oidc/discovery/discovery_handler_test.go b/internal/oidc/discovery/discovery_handler_test.go index f15c9a0c..b3c70b35 100644 --- a/internal/oidc/discovery/discovery_handler_test.go +++ b/internal/oidc/discovery/discovery_handler_test.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 discovery @@ -35,10 +35,13 @@ func TestDiscovery(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "application/json", wantBodyJSON: &Metadata{ - Issuer: "https://some-issuer.com/some/path", - AuthorizationEndpoint: "https://some-issuer.com/some/path/oauth2/authorize", - TokenEndpoint: "https://some-issuer.com/some/path/oauth2/token", - JWKSURI: "https://some-issuer.com/some/path/jwks.json", + Issuer: "https://some-issuer.com/some/path", + AuthorizationEndpoint: "https://some-issuer.com/some/path/oauth2/authorize", + TokenEndpoint: "https://some-issuer.com/some/path/oauth2/token", + JWKSURI: "https://some-issuer.com/some/path/jwks.json", + SupervisorDiscovery: SupervisorDiscoveryMetadataV1Alpha1{ + PinnipedIDPsEndpoint: "https://some-issuer.com/some/path/v1alpha1/pinniped_identity_providers", + }, ResponseTypesSupported: []string{"code"}, SubjectTypesSupported: []string{"public"}, IDTokenSigningAlgValuesSupported: []string{"ES256"}, diff --git a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go b/internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go index 6df5e5bc..5c32c798 100644 --- a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go +++ b/internal/oidc/dynamic_open_id_connect_ecdsa_strategy.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 oidc @@ -8,14 +8,13 @@ import ( "crypto/ecdsa" "reflect" - "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/plog" - "github.com/ory/fosite" "github.com/ory/fosite/compose" "github.com/ory/fosite/handler/openid" + "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/plog" ) // dynamicOpenIDConnectECDSAStrategy is an openid.OpenIDConnectTokenStrategy that can dynamically diff --git a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go b/internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go index f0250e22..c8e036c1 100644 --- a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go +++ b/internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.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 oidc @@ -21,7 +21,7 @@ import ( "gopkg.in/square/go-jose.v2" "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidctestutil" + "go.pinniped.dev/internal/testutil/oidctestutil" ) func TestDynamicOpenIDConnectECDSAStrategy(t *testing.T) { diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/oidc/idpdiscovery/idp_discovery_handler.go new file mode 100644 index 00000000..9ee0bf76 --- /dev/null +++ b/internal/oidc/idpdiscovery/idp_discovery_handler.go @@ -0,0 +1,75 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package idpdiscovery provides a handler for the upstream IDP discovery endpoint. +package idpdiscovery + +import ( + "bytes" + "encoding/json" + "net/http" + "sort" + + "go.pinniped.dev/internal/oidc" +) + +const ( + idpDiscoveryTypeLDAP = "ldap" + idpDiscoveryTypeOIDC = "oidc" +) + +type response struct { + IDPs []identityProviderResponse `json:"pinniped_identity_providers"` +} + +type identityProviderResponse struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// NewHandler returns an http.Handler that serves the upstream IDP discovery endpoint. +func NewHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed) + return + } + + encodedMetadata, encodeErr := responseAsJSON(upstreamIDPs) + if encodeErr != nil { + http.Error(w, encodeErr.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(encodedMetadata); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, error) { + r := response{ + IDPs: []identityProviderResponse{}, + } + + // The cache of IDPs could change at any time, so always recalculate the list. + for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() { + r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeLDAP}) + } + for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() { + r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeOIDC}) + } + + // Nobody like an API that changes the results unnecessarily. :) + sort.SliceStable(r.IDPs, func(i, j int) bool { + return r.IDPs[i].Name < r.IDPs[j].Name + }) + + var b bytes.Buffer + encodeErr := json.NewEncoder(&b).Encode(&r) + encodedMetadata := b.Bytes() + + return encodedMetadata, encodeErr +} diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go new file mode 100644 index 00000000..3912f9c9 --- /dev/null +++ b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go @@ -0,0 +1,126 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package idpdiscovery + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/testutil/oidctestutil" +) + +func TestIDPDiscovery(t *testing.T) { + tests := []struct { + name string + + method string + path string + + wantStatus int + wantContentType string + wantFirstResponseBodyJSON interface{} + wantSecondResponseBodyJSON interface{} + wantBodyString string + }{ + { + name: "happy path", + method: http.MethodGet, + path: "/some/path" + oidc.WellKnownEndpointPath, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantFirstResponseBodyJSON: &response{ + IDPs: []identityProviderResponse{ + {Name: "a-some-ldap-idp", Type: "ldap"}, + {Name: "a-some-oidc-idp", Type: "oidc"}, + {Name: "x-some-idp", Type: "ldap"}, + {Name: "x-some-idp", Type: "oidc"}, + {Name: "z-some-ldap-idp", Type: "ldap"}, + {Name: "z-some-oidc-idp", Type: "oidc"}, + }, + }, + wantSecondResponseBodyJSON: &response{ + IDPs: []identityProviderResponse{ + {Name: "some-other-ldap-idp-1", Type: "ldap"}, + {Name: "some-other-ldap-idp-2", Type: "ldap"}, + {Name: "some-other-oidc-idp-1", Type: "oidc"}, + {Name: "some-other-oidc-idp-2", Type: "oidc"}, + }, + }, + }, + { + name: "bad method", + method: http.MethodPost, + path: oidc.WellKnownEndpointPath, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: "text/plain; charset=utf-8", + wantBodyString: "Method not allowed (try GET)\n", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + idpLister := oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "z-some-oidc-idp"}). + WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}). + WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}). + WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}). + WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "z-some-ldap-idp"}). + WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "x-some-idp"}). + Build() + + handler := NewHandler(idpLister) + req := httptest.NewRequest(test.method, test.path, nil) + rsp := httptest.NewRecorder() + handler.ServeHTTP(rsp, req) + + require.Equal(t, test.wantStatus, rsp.Code) + + require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type")) + + if test.wantFirstResponseBodyJSON != nil { + wantJSON, err := json.Marshal(test.wantFirstResponseBodyJSON) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), rsp.Body.String()) + } + + if test.wantBodyString != "" { + require.Equal(t, test.wantBodyString, rsp.Body.String()) + } + + // Change the list of IDPs in the cache. + idpLister.SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ + &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-1"}, + &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-2"}, + }) + idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ + &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-1"}, + &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"}, + }) + + // Make the same request to the same handler instance again, and expect different results. + rsp = httptest.NewRecorder() + handler.ServeHTTP(rsp, req) + + require.Equal(t, test.wantStatus, rsp.Code) + + require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type")) + + if test.wantFirstResponseBodyJSON != nil { + wantJSON, err := json.Marshal(test.wantSecondResponseBodyJSON) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), rsp.Body.String()) + } + + if test.wantBodyString != "" { + require.Equal(t, test.wantBodyString, rsp.Body.String()) + } + }) + } +} diff --git a/internal/oidc/kube_storage.go b/internal/oidc/kube_storage.go index 46f9c947..f775c22b 100644 --- a/internal/oidc/kube_storage.go +++ b/internal/oidc/kube_storage.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 oidc @@ -19,6 +19,7 @@ import ( "go.pinniped.dev/internal/fositestorage/openidconnect" "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/fositestorage/refreshtoken" + "go.pinniped.dev/internal/fositestoragei" ) const errKubeStorageNotImplemented = constable.Error("KubeStorage does not implement this method. It should not have been called.") @@ -31,6 +32,8 @@ type KubeStorage struct { refreshTokenStorage refreshtoken.RevocationStorage } +var _ fositestoragei.AllFositeStorage = &KubeStorage{} + func NewKubeStorage(secrets corev1client.SecretInterface, timeoutsConfiguration TimeoutsConfiguration) *KubeStorage { nowFunc := time.Now return &KubeStorage{ diff --git a/internal/oidc/nullstorage.go b/internal/oidc/nullstorage.go index 3dcd7a06..121e3c3a 100644 --- a/internal/oidc/nullstorage.go +++ b/internal/oidc/nullstorage.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 oidc @@ -10,12 +10,15 @@ import ( "github.com/ory/fosite" "go.pinniped.dev/internal/constable" + "go.pinniped.dev/internal/fositestoragei" ) const errNullStorageNotImplemented = constable.Error("NullStorage does not implement this method. It should not have been called.") type NullStorage struct{} +var _ fositestoragei.AllFositeStorage = &NullStorage{} + func (NullStorage) RevokeRefreshToken(_ context.Context, _ string) error { return errNullStorageNotImplemented } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index d5f08e76..78319329 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -24,6 +24,7 @@ const ( TokenEndpointPath = "/oauth2/token" //nolint:gosec // ignore lint warning that this is a credential CallbackEndpointPath = "/callback" JWKSEndpointPath = "/jwks.json" + PinnipedIDPsPathV1Alpha1 = "/v1alpha1/pinniped_identity_providers" ) const ( @@ -269,8 +270,17 @@ func FositeErrorForLog(err error) []interface{} { return keysAndValues } -type IDPListGetter interface { - GetIDPList() []provider.UpstreamOIDCIdentityProviderI +type UpstreamOIDCIdentityProvidersLister interface { + GetOIDCIdentityProviders() []provider.UpstreamOIDCIdentityProviderI +} + +type UpstreamLDAPIdentityProvidersLister interface { + GetLDAPIdentityProviders() []provider.UpstreamLDAPIdentityProviderI +} + +type UpstreamIdentityProvidersLister interface { + UpstreamOIDCIdentityProvidersLister + UpstreamLDAPIdentityProvidersLister } func GrantScopeIfRequested(authorizeRequester fosite.AuthorizeRequester, scopeName string) { diff --git a/internal/oidc/oidctestutil/oidc.go b/internal/oidc/oidctestutil/oidc.go deleted file mode 100644 index 07a1c890..00000000 --- a/internal/oidc/oidctestutil/oidc.go +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package oidctestutil - -import ( - "context" - "crypto" - "crypto/ecdsa" - "fmt" - "net/url" - "testing" - - coreosoidc "github.com/coreos/go-oidc/v3/oidc" - "github.com/stretchr/testify/require" - "golang.org/x/oauth2" - "gopkg.in/square/go-jose.v2" - - "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/pkg/oidcclient/nonce" - "go.pinniped.dev/pkg/oidcclient/oidctypes" - "go.pinniped.dev/pkg/oidcclient/pkce" -) - -// Test helpers for the OIDC package. - -// ExchangeAuthcodeAndValidateTokenArgs is a POGO (plain old go object?) used to spy on calls to -// TestUpstreamOIDCIdentityProvider.ExchangeAuthcodeAndValidateTokensFunc(). -type ExchangeAuthcodeAndValidateTokenArgs struct { - Ctx context.Context - Authcode string - PKCECodeVerifier pkce.Code - ExpectedIDTokenNonce nonce.Nonce - RedirectURI string -} - -type TestUpstreamOIDCIdentityProvider struct { - Name string - ClientID string - AuthorizationURL url.URL - UsernameClaim string - GroupsClaim string - Scopes []string - ExchangeAuthcodeAndValidateTokensFunc func( - ctx context.Context, - authcode string, - pkceCodeVerifier pkce.Code, - expectedIDTokenNonce nonce.Nonce, - ) (*oidctypes.Token, error) - - exchangeAuthcodeAndValidateTokensCallCount int - exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs -} - -func (u *TestUpstreamOIDCIdentityProvider) GetName() string { - return u.Name -} - -func (u *TestUpstreamOIDCIdentityProvider) GetClientID() string { - return u.ClientID -} - -func (u *TestUpstreamOIDCIdentityProvider) GetAuthorizationURL() *url.URL { - return &u.AuthorizationURL -} - -func (u *TestUpstreamOIDCIdentityProvider) GetScopes() []string { - return u.Scopes -} - -func (u *TestUpstreamOIDCIdentityProvider) GetUsernameClaim() string { - return u.UsernameClaim -} - -func (u *TestUpstreamOIDCIdentityProvider) GetGroupsClaim() string { - return u.GroupsClaim -} - -func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( - ctx context.Context, - authcode string, - pkceCodeVerifier pkce.Code, - expectedIDTokenNonce nonce.Nonce, - redirectURI string, -) (*oidctypes.Token, error) { - if u.exchangeAuthcodeAndValidateTokensArgs == nil { - u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) - } - u.exchangeAuthcodeAndValidateTokensCallCount++ - u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeAndValidateTokenArgs{ - Ctx: ctx, - Authcode: authcode, - PKCECodeVerifier: pkceCodeVerifier, - ExpectedIDTokenNonce: expectedIDTokenNonce, - RedirectURI: redirectURI, - }) - return u.ExchangeAuthcodeAndValidateTokensFunc(ctx, authcode, pkceCodeVerifier, expectedIDTokenNonce) -} - -func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensCallCount() int { - return u.exchangeAuthcodeAndValidateTokensCallCount -} - -func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeAndValidateTokenArgs { - if u.exchangeAuthcodeAndValidateTokensArgs == nil { - u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) - } - return u.exchangeAuthcodeAndValidateTokensArgs[call] -} - -func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(_ context.Context, _ *oauth2.Token, _ nonce.Nonce) (*oidctypes.Token, error) { - panic("implement me") -} - -func NewIDPListGetter(upstreamOIDCIdentityProviders ...*TestUpstreamOIDCIdentityProvider) provider.DynamicUpstreamIDPProvider { - idpProvider := provider.NewDynamicUpstreamIDPProvider() - upstreams := make([]provider.UpstreamOIDCIdentityProviderI, len(upstreamOIDCIdentityProviders)) - for i := range upstreamOIDCIdentityProviders { - upstreams[i] = provider.UpstreamOIDCIdentityProviderI(upstreamOIDCIdentityProviders[i]) - } - idpProvider.SetIDPList(upstreams) - return idpProvider -} - -// Declare a separate type from the production code to ensure that the state param's contents was serialized -// in the format that we expect, with the json keys that we expect, etc. This also ensure that the order of -// the serialized fields is the same, which doesn't really matter expect that we can make simpler equality -// assertions about the redirect URL in this test. -type ExpectedUpstreamStateParamFormat struct { - P string `json:"p"` - U string `json:"u"` - N string `json:"n"` - C string `json:"c"` - K string `json:"k"` - V string `json:"v"` -} - -type staticKeySet struct { - publicKey crypto.PublicKey -} - -func newStaticKeySet(publicKey crypto.PublicKey) coreosoidc.KeySet { - return &staticKeySet{publicKey} -} - -func (s *staticKeySet) VerifySignature(ctx context.Context, jwt string) ([]byte, error) { - jws, err := jose.ParseSigned(jwt) - if err != nil { - return nil, fmt.Errorf("oidc: malformed jwt: %w", err) - } - return jws.Verify(s.publicKey) -} - -// VerifyECDSAIDToken verifies that the provided idToken was issued via the provided jwtSigningKey. -// It also performs some light validation on the claims, i.e., it makes sure the provided idToken -// has the provided issuer and clientID. -// -// Further validation can be done via callers via the returned coreosoidc.IDToken. -func VerifyECDSAIDToken( - t *testing.T, - issuer, clientID string, - jwtSigningKey *ecdsa.PrivateKey, - idToken string, -) *coreosoidc.IDToken { - t.Helper() - - keySet := newStaticKeySet(jwtSigningKey.Public()) - verifyConfig := coreosoidc.Config{ClientID: clientID, SupportedSigningAlgs: []string{coreosoidc.ES256}} - verifier := coreosoidc.NewVerifier(issuer, keySet, &verifyConfig) - token, err := verifier.Verify(context.Background(), idToken) - require.NoError(t, err) - - return token -} diff --git a/internal/oidc/provider/dynamic_upstream_idp_provider.go b/internal/oidc/provider/dynamic_upstream_idp_provider.go index fd367d88..50965abc 100644 --- a/internal/oidc/provider/dynamic_upstream_idp_provider.go +++ b/internal/oidc/provider/dynamic_upstream_idp_provider.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 provider @@ -10,6 +10,7 @@ import ( "golang.org/x/oauth2" + "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" "go.pinniped.dev/pkg/oidcclient/pkce" @@ -48,30 +49,59 @@ type UpstreamOIDCIdentityProviderI interface { ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) } +type UpstreamLDAPIdentityProviderI interface { + // A name for this upstream provider. + GetName() string + + // Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234". + // This URL is not used for connecting to the provider, but rather is used for creating a globally unique user + // identifier by being combined with the user's UID, since user UIDs are only unique within one provider. + GetURL() string + + // A method for performing user authentication against the upstream LDAP provider. + authenticators.UserAuthenticator +} + type DynamicUpstreamIDPProvider interface { - SetIDPList(oidcIDPs []UpstreamOIDCIdentityProviderI) - GetIDPList() []UpstreamOIDCIdentityProviderI + SetOIDCIdentityProviders(oidcIDPs []UpstreamOIDCIdentityProviderI) + GetOIDCIdentityProviders() []UpstreamOIDCIdentityProviderI + SetLDAPIdentityProviders(ldapIDPs []UpstreamLDAPIdentityProviderI) + GetLDAPIdentityProviders() []UpstreamLDAPIdentityProviderI } type dynamicUpstreamIDPProvider struct { - federationDomains []UpstreamOIDCIdentityProviderI - mutex sync.RWMutex + oidcUpstreams []UpstreamOIDCIdentityProviderI + ldapUpstreams []UpstreamLDAPIdentityProviderI + mutex sync.RWMutex } func NewDynamicUpstreamIDPProvider() DynamicUpstreamIDPProvider { return &dynamicUpstreamIDPProvider{ - federationDomains: []UpstreamOIDCIdentityProviderI{}, + oidcUpstreams: []UpstreamOIDCIdentityProviderI{}, + ldapUpstreams: []UpstreamLDAPIdentityProviderI{}, } } -func (p *dynamicUpstreamIDPProvider) SetIDPList(oidcIDPs []UpstreamOIDCIdentityProviderI) { +func (p *dynamicUpstreamIDPProvider) SetOIDCIdentityProviders(oidcIDPs []UpstreamOIDCIdentityProviderI) { p.mutex.Lock() // acquire a write lock defer p.mutex.Unlock() - p.federationDomains = oidcIDPs + p.oidcUpstreams = oidcIDPs } -func (p *dynamicUpstreamIDPProvider) GetIDPList() []UpstreamOIDCIdentityProviderI { +func (p *dynamicUpstreamIDPProvider) GetOIDCIdentityProviders() []UpstreamOIDCIdentityProviderI { p.mutex.RLock() // acquire a read lock defer p.mutex.RUnlock() - return p.federationDomains + return p.oidcUpstreams +} + +func (p *dynamicUpstreamIDPProvider) SetLDAPIdentityProviders(ldapIDPs []UpstreamLDAPIdentityProviderI) { + p.mutex.Lock() // acquire a write lock + defer p.mutex.Unlock() + p.ldapUpstreams = ldapIDPs +} + +func (p *dynamicUpstreamIDPProvider) GetLDAPIdentityProviders() []UpstreamLDAPIdentityProviderI { + p.mutex.RLock() // acquire a read lock + defer p.mutex.RUnlock() + return p.ldapUpstreams } diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 52227ce5..ea1d2d62 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.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 manager @@ -8,10 +8,6 @@ import ( "strings" "sync" - "go.pinniped.dev/internal/secret" - - "go.pinniped.dev/internal/oidc/dynamiccodec" - corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/internal/oidc" @@ -19,10 +15,13 @@ import ( "go.pinniped.dev/internal/oidc/callback" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/discovery" + "go.pinniped.dev/internal/oidc/dynamiccodec" + "go.pinniped.dev/internal/oidc/idpdiscovery" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/token" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/secret" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) @@ -33,22 +32,22 @@ import ( type Manager struct { mu sync.RWMutex providers []*provider.FederationDomainIssuer - providerHandlers map[string]http.Handler // map of all routes for all providers - nextHandler http.Handler // the next handler in a chain, called when this manager didn't know how to handle a request - dynamicJWKSProvider jwks.DynamicJWKSProvider // in-memory cache of per-issuer JWKS data - idpListGetter oidc.IDPListGetter // in-memory cache of upstream IDPs - secretCache *secret.Cache // in-memory cache of cryptographic material + providerHandlers map[string]http.Handler // map of all routes for all providers + nextHandler http.Handler // the next handler in a chain, called when this manager didn't know how to handle a request + dynamicJWKSProvider jwks.DynamicJWKSProvider // in-memory cache of per-issuer JWKS data + upstreamIDPs oidc.UpstreamIdentityProvidersLister // in-memory cache of upstream IDPs + secretCache *secret.Cache // in-memory cache of cryptographic material secretsClient corev1client.SecretInterface } // NewManager returns an empty Manager. // nextHandler will be invoked for any requests that could not be handled by this manager's providers. // dynamicJWKSProvider will be used as an in-memory cache for per-issuer JWKS data. -// idpListGetter will be used as an in-memory cache of currently configured upstream IDPs. +// upstreamIDPs will be used as an in-memory cache of currently configured upstream IDPs. func NewManager( nextHandler http.Handler, dynamicJWKSProvider jwks.DynamicJWKSProvider, - idpListGetter oidc.IDPListGetter, + upstreamIDPs oidc.UpstreamIdentityProvidersLister, secretCache *secret.Cache, secretsClient corev1client.SecretInterface, ) *Manager { @@ -56,7 +55,7 @@ func NewManager( providerHandlers: make(map[string]http.Handler), nextHandler: nextHandler, dynamicJWKSProvider: dynamicJWKSProvider, - idpListGetter: idpListGetter, + upstreamIDPs: upstreamIDPs, secretCache: secretCache, secretsClient: secretsClient, } @@ -108,10 +107,13 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs m.providerHandlers[(issuerHostWithPath + oidc.JWKSEndpointPath)] = jwks.NewHandler(issuer, m.dynamicJWKSProvider) + m.providerHandlers[(issuerHostWithPath + oidc.PinnipedIDPsPathV1Alpha1)] = idpdiscovery.NewHandler(m.upstreamIDPs) + m.providerHandlers[(issuerHostWithPath + oidc.AuthorizationEndpointPath)] = auth.NewHandler( issuer, - m.idpListGetter, + m.upstreamIDPs, oauthHelperWithNullStorage, + oauthHelperWithKubeStorage, csrftoken.Generate, pkce.Generate, nonce.Generate, @@ -120,7 +122,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs ) m.providerHandlers[(issuerHostWithPath + oidc.CallbackEndpointPath)] = callback.NewHandler( - m.idpListGetter, + m.upstreamIDPs, oauthHelperWithKubeStorage, upstreamStateEncoder, csrfCookieEncoder, diff --git a/internal/oidc/provider/manager/manager_test.go b/internal/oidc/provider/manager/manager_test.go index 64b7ab4a..469a085d 100644 --- a/internal/oidc/provider/manager/manager_test.go +++ b/internal/oidc/provider/manager/manager_test.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 manager @@ -7,6 +7,7 @@ import ( "context" "crypto/ecdsa" "encoding/json" + "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -25,9 +26,9 @@ import ( "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/discovery" "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidctestutil" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" "go.pinniped.dev/pkg/oidcclient/pkce" @@ -52,6 +53,8 @@ func TestManager(t *testing.T) { issuer2DifferentCaseHostname = "https://exAmPlE.Com/some/path/more/deeply/nested/path" issuer2KeyID = "issuer2-key" upstreamIDPAuthorizationURL = "https://test-upstream.com/auth" + upstreamIDPName = "test-idp" + upstreamIDPType = "oidc" downstreamClientID = "pinniped-cli" downstreamRedirectURL = "http://127.0.0.1:12345/callback" @@ -68,7 +71,7 @@ func TestManager(t *testing.T) { return req } - requireDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIssuerInResponse string) { + requireDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIssuer string) { recorder := httptest.NewRecorder() subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.WellKnownEndpointPath+requestURLSuffix)) @@ -82,7 +85,25 @@ func TestManager(t *testing.T) { parsedDiscoveryResult := discovery.Metadata{} err = json.Unmarshal(responseBody, &parsedDiscoveryResult) r.NoError(err) - r.Equal(expectedIssuerInResponse, parsedDiscoveryResult.Issuer) + r.Equal(expectedIssuer, parsedDiscoveryResult.Issuer) + r.Equal(parsedDiscoveryResult.SupervisorDiscovery.PinnipedIDPsEndpoint, expectedIssuer+oidc.PinnipedIDPsPathV1Alpha1) + } + + requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string) { + recorder := httptest.NewRecorder() + + subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix)) + + r.False(fallbackHandlerWasCalled) + + // Minimal check to ensure that the right IDP discovery endpoint was called + r.Equal(http.StatusOK, recorder.Code) + responseBody, err := ioutil.ReadAll(recorder.Body) + r.NoError(err) + r.Equal( + fmt.Sprintf(`{"pinniped_identity_providers":[{"name":"%s","type":"%s"}]}`+"\n", expectedIDPName, expectedIDPType), + string(responseBody), + ) } requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) (string, string) { @@ -221,8 +242,8 @@ func TestManager(t *testing.T) { parsedUpstreamIDPAuthorizationURL, err := url.Parse(upstreamIDPAuthorizationURL) r.NoError(err) - idpListGetter := oidctestutil.NewIDPListGetter(&oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: "test-idp", + idpLister := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{ + Name: upstreamIDPName, ClientID: "test-client-id", AuthorizationURL: *parsedUpstreamIDPAuthorizationURL, Scopes: []string{"test-scope"}, @@ -238,7 +259,7 @@ func TestManager(t *testing.T) { }, }, nil }, - }) + }).Build() kubeClient = fake.NewSimpleClientset() secretsClient := kubeClient.CoreV1().Secrets("some-namespace") @@ -254,7 +275,7 @@ func TestManager(t *testing.T) { cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2")) cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02")) - subject = NewManager(nextHandler, dynamicJWKSProvider, idpListGetter, &cache, secretsClient) + subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient) }) when("given no providers via SetProviders()", func() { @@ -293,6 +314,15 @@ func TestManager(t *testing.T) { requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2) requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1, "", upstreamIDPName, upstreamIDPType) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "", upstreamIDPName, upstreamIDPType) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "?some=query", upstreamIDPName, upstreamIDPType) + + // Hostnames are case-insensitive, so test that we can handle that. + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", upstreamIDPName, upstreamIDPType) + issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID) issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID) requireJWKSRequestToBeHandled(issuer2, "?some=query", issuer2KeyID) diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index b13a64cb..0f64ae43 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -42,11 +42,12 @@ import ( "go.pinniped.dev/internal/fositestorage/openidconnect" storagepkce "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/fositestorage/refreshtoken" + "go.pinniped.dev/internal/fositestoragei" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidctestutil" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" ) const ( @@ -216,25 +217,13 @@ type authcodeExchangeInputs struct { modifyTokenRequest func(tokenRequest *http.Request, authCode string) modifyStorage func( t *testing.T, - s interface { - oauth2.TokenRevocationStorage - oauth2.CoreStorage - openid.OpenIDConnectRequestStorage - pkce.PKCERequestStorage - fosite.ClientManager - }, + s fositestoragei.AllFositeStorage, authCode string, ) makeOathHelper func( t *testing.T, authRequest *http.Request, - store interface { - oauth2.TokenRevocationStorage - oauth2.CoreStorage - openid.OpenIDConnectRequestStorage - pkce.PKCERequestStorage - fosite.ClientManager - }, + store fositestoragei.AllFositeStorage, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) want tokenEndpointResponseExpectedValues @@ -1317,13 +1306,7 @@ func getFositeDataSignature(t *testing.T, data string) string { func makeHappyOauthHelper( t *testing.T, authRequest *http.Request, - store interface { - oauth2.TokenRevocationStorage - oauth2.CoreStorage - openid.OpenIDConnectRequestStorage - pkce.PKCERequestStorage - fosite.ClientManager - }, + store fositestoragei.AllFositeStorage, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() @@ -1349,13 +1332,7 @@ func (s *singleUseJWKProvider) GetJWKS(issuerName string) (jwks *jose.JSONWebKey func makeOauthHelperWithJWTKeyThatWorksOnlyOnce( t *testing.T, authRequest *http.Request, - store interface { - oauth2.TokenRevocationStorage - oauth2.CoreStorage - openid.OpenIDConnectRequestStorage - pkce.PKCERequestStorage - fosite.ClientManager - }, + store fositestoragei.AllFositeStorage, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() @@ -1368,13 +1345,7 @@ func makeOauthHelperWithJWTKeyThatWorksOnlyOnce( func makeOauthHelperWithNilPrivateJWTSigningKey( t *testing.T, authRequest *http.Request, - store interface { - oauth2.TokenRevocationStorage - oauth2.CoreStorage - openid.OpenIDConnectRequestStorage - pkce.PKCERequestStorage - fosite.ClientManager - }, + store fositestoragei.AllFositeStorage, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 9c05c2bb..78d5dd73 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -21,6 +21,7 @@ import ( genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/klog/v2" + "k8s.io/utils/pointer" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" "go.pinniped.dev/internal/issuer" @@ -347,7 +348,7 @@ func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err require.Equal(t, response, &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ Credential: nil, - Message: stringPtr("authentication failed"), + Message: pointer.StringPtr("authentication failed"), }, }) } @@ -359,7 +360,3 @@ func successfulIssuer(ctrl *gomock.Controller) issuer.ClientCertIssuer { Return([]byte("test-cert"), []byte("test-key"), nil) return clientCertIssuer } - -func stringPtr(s string) *string { - return &s -} diff --git a/internal/testutil/ioutil.go b/internal/testutil/ioutil.go index ebe9891a..9b1f086f 100644 --- a/internal/testutil/ioutil.go +++ b/internal/testutil/ioutil.go @@ -1,9 +1,16 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testutil -import "io" +import ( + "io" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" +) // ErrorWriter implements io.Writer by returning a fixed error. type ErrorWriter struct { @@ -13,3 +20,19 @@ type ErrorWriter struct { var _ io.Writer = &ErrorWriter{} func (e *ErrorWriter) Write([]byte) (int, error) { return 0, e.ReturnError } + +func WriteStringToTempFile(t *testing.T, filename string, fileBody string) *os.File { + t.Helper() + f, err := ioutil.TempFile("", filename) + require.NoError(t, err) + deferMe := func() { + err := os.Remove(f.Name()) + require.NoError(t, err) + } + t.Cleanup(deferMe) + _, err = f.WriteString(fileBody) + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + return f +} diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go new file mode 100644 index 00000000..e4718270 --- /dev/null +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -0,0 +1,469 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidctestutil + +import ( + "context" + "crypto" + "crypto/ecdsa" + "fmt" + "net/url" + "regexp" + "strings" + "testing" + "time" + + coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/ory/fosite" + "github.com/ory/fosite/handler/openid" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "gopkg.in/square/go-jose.v2" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/client-go/kubernetes/fake" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + + "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/fositestorage/authorizationcode" + "go.pinniped.dev/internal/fositestorage/openidconnect" + pkce2 "go.pinniped.dev/internal/fositestorage/pkce" + "go.pinniped.dev/internal/fositestoragei" + "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/pkg/oidcclient/nonce" + "go.pinniped.dev/pkg/oidcclient/oidctypes" + "go.pinniped.dev/pkg/oidcclient/pkce" +) + +// Test helpers for the OIDC package. + +// ExchangeAuthcodeAndValidateTokenArgs is used to spy on calls to +// TestUpstreamOIDCIdentityProvider.ExchangeAuthcodeAndValidateTokensFunc(). +type ExchangeAuthcodeAndValidateTokenArgs struct { + Ctx context.Context + Authcode string + PKCECodeVerifier pkce.Code + ExpectedIDTokenNonce nonce.Nonce + RedirectURI string +} + +type TestUpstreamLDAPIdentityProvider struct { + Name string + URL string + AuthenticateFunc func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) +} + +func (u *TestUpstreamLDAPIdentityProvider) GetName() string { + return u.Name +} + +func (u *TestUpstreamLDAPIdentityProvider) AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { + return u.AuthenticateFunc(ctx, username, password) +} + +func (u *TestUpstreamLDAPIdentityProvider) GetURL() string { + return u.URL +} + +type TestUpstreamOIDCIdentityProvider struct { + Name string + ClientID string + AuthorizationURL url.URL + UsernameClaim string + GroupsClaim string + Scopes []string + ExchangeAuthcodeAndValidateTokensFunc func( + ctx context.Context, + authcode string, + pkceCodeVerifier pkce.Code, + expectedIDTokenNonce nonce.Nonce, + ) (*oidctypes.Token, error) + + exchangeAuthcodeAndValidateTokensCallCount int + exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs +} + +func (u *TestUpstreamOIDCIdentityProvider) GetName() string { + return u.Name +} + +func (u *TestUpstreamOIDCIdentityProvider) GetClientID() string { + return u.ClientID +} + +func (u *TestUpstreamOIDCIdentityProvider) GetAuthorizationURL() *url.URL { + return &u.AuthorizationURL +} + +func (u *TestUpstreamOIDCIdentityProvider) GetScopes() []string { + return u.Scopes +} + +func (u *TestUpstreamOIDCIdentityProvider) GetUsernameClaim() string { + return u.UsernameClaim +} + +func (u *TestUpstreamOIDCIdentityProvider) GetGroupsClaim() string { + return u.GroupsClaim +} + +func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( + ctx context.Context, + authcode string, + pkceCodeVerifier pkce.Code, + expectedIDTokenNonce nonce.Nonce, + redirectURI string, +) (*oidctypes.Token, error) { + if u.exchangeAuthcodeAndValidateTokensArgs == nil { + u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) + } + u.exchangeAuthcodeAndValidateTokensCallCount++ + u.exchangeAuthcodeAndValidateTokensArgs = append(u.exchangeAuthcodeAndValidateTokensArgs, &ExchangeAuthcodeAndValidateTokenArgs{ + Ctx: ctx, + Authcode: authcode, + PKCECodeVerifier: pkceCodeVerifier, + ExpectedIDTokenNonce: expectedIDTokenNonce, + RedirectURI: redirectURI, + }) + return u.ExchangeAuthcodeAndValidateTokensFunc(ctx, authcode, pkceCodeVerifier, expectedIDTokenNonce) +} + +func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensCallCount() int { + return u.exchangeAuthcodeAndValidateTokensCallCount +} + +func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs(call int) *ExchangeAuthcodeAndValidateTokenArgs { + if u.exchangeAuthcodeAndValidateTokensArgs == nil { + u.exchangeAuthcodeAndValidateTokensArgs = make([]*ExchangeAuthcodeAndValidateTokenArgs, 0) + } + return u.exchangeAuthcodeAndValidateTokensArgs[call] +} + +func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(_ context.Context, _ *oauth2.Token, _ nonce.Nonce) (*oidctypes.Token, error) { + panic("implement me") +} + +type UpstreamIDPListerBuilder struct { + upstreamOIDCIdentityProviders []*TestUpstreamOIDCIdentityProvider + upstreamLDAPIdentityProviders []*TestUpstreamLDAPIdentityProvider +} + +func (b *UpstreamIDPListerBuilder) WithOIDC(upstreamOIDCIdentityProviders ...*TestUpstreamOIDCIdentityProvider) *UpstreamIDPListerBuilder { + b.upstreamOIDCIdentityProviders = append(b.upstreamOIDCIdentityProviders, upstreamOIDCIdentityProviders...) + return b +} + +func (b *UpstreamIDPListerBuilder) WithLDAP(upstreamLDAPIdentityProviders ...*TestUpstreamLDAPIdentityProvider) *UpstreamIDPListerBuilder { + b.upstreamLDAPIdentityProviders = append(b.upstreamLDAPIdentityProviders, upstreamLDAPIdentityProviders...) + return b +} + +func (b *UpstreamIDPListerBuilder) Build() provider.DynamicUpstreamIDPProvider { + idpProvider := provider.NewDynamicUpstreamIDPProvider() + + oidcUpstreams := make([]provider.UpstreamOIDCIdentityProviderI, len(b.upstreamOIDCIdentityProviders)) + for i := range b.upstreamOIDCIdentityProviders { + oidcUpstreams[i] = provider.UpstreamOIDCIdentityProviderI(b.upstreamOIDCIdentityProviders[i]) + } + idpProvider.SetOIDCIdentityProviders(oidcUpstreams) + + ldapUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, len(b.upstreamLDAPIdentityProviders)) + for i := range b.upstreamLDAPIdentityProviders { + ldapUpstreams[i] = provider.UpstreamLDAPIdentityProviderI(b.upstreamLDAPIdentityProviders[i]) + } + idpProvider.SetLDAPIdentityProviders(ldapUpstreams) + + return idpProvider +} + +func NewUpstreamIDPListerBuilder() *UpstreamIDPListerBuilder { + return &UpstreamIDPListerBuilder{} +} + +// Declare a separate type from the production code to ensure that the state param's contents was serialized +// in the format that we expect, with the json keys that we expect, etc. This also ensure that the order of +// the serialized fields is the same, which doesn't really matter expect that we can make simpler equality +// assertions about the redirect URL in this test. +type ExpectedUpstreamStateParamFormat struct { + P string `json:"p"` + U string `json:"u"` + N string `json:"n"` + C string `json:"c"` + K string `json:"k"` + V string `json:"v"` +} + +type staticKeySet struct { + publicKey crypto.PublicKey +} + +func newStaticKeySet(publicKey crypto.PublicKey) coreosoidc.KeySet { + return &staticKeySet{publicKey} +} + +func (s *staticKeySet) VerifySignature(_ context.Context, jwt string) ([]byte, error) { + jws, err := jose.ParseSigned(jwt) + if err != nil { + return nil, fmt.Errorf("oidc: malformed jwt: %w", err) + } + return jws.Verify(s.publicKey) +} + +// VerifyECDSAIDToken verifies that the provided idToken was issued via the provided jwtSigningKey. +// It also performs some light validation on the claims, i.e., it makes sure the provided idToken +// has the provided issuer and clientID. +// +// Further validation can be done via callers via the returned coreosoidc.IDToken. +func VerifyECDSAIDToken( + t *testing.T, + issuer, clientID string, + jwtSigningKey *ecdsa.PrivateKey, + idToken string, +) *coreosoidc.IDToken { + t.Helper() + + keySet := newStaticKeySet(jwtSigningKey.Public()) + verifyConfig := coreosoidc.Config{ClientID: clientID, SupportedSigningAlgs: []string{coreosoidc.ES256}} + verifier := coreosoidc.NewVerifier(issuer, keySet, &verifyConfig) + token, err := verifier.Verify(context.Background(), idToken) + require.NoError(t, err) + + return token +} + +func RequireAuthcodeRedirectLocation( + t *testing.T, + actualRedirectLocation string, + wantRedirectLocationRegexp string, + kubeClient *fake.Clientset, + secretsClient v1.SecretInterface, + oauthStore fositestoragei.AllFositeStorage, + wantDownstreamGrantedScopes []string, + wantDownstreamIDTokenSubject string, + wantDownstreamIDTokenUsername string, + wantDownstreamIDTokenGroups []string, + wantDownstreamRequestedScopes []string, + wantDownstreamPKCEChallenge string, + wantDownstreamPKCEChallengeMethod string, + wantDownstreamNonce string, + wantDownstreamClientID string, + wantDownstreamRedirectURI string, +) { + t.Helper() + + // Assert that Location header matches regular expression. + regex := regexp.MustCompile(wantRedirectLocationRegexp) + submatches := regex.FindStringSubmatch(actualRedirectLocation) + require.Lenf(t, submatches, 2, "no regexp match in actualRedirectLocation: %q", actualRedirectLocation) + capturedAuthCode := submatches[1] + + // fosite authcodes are in the format `data.signature`, so grab the signature part, which is the lookup key in the storage interface + authcodeDataAndSignature := strings.Split(capturedAuthCode, ".") + require.Len(t, authcodeDataAndSignature, 2) + + // Several Secrets should have been created + expectedNumberOfCreatedSecrets := 2 + if includesOpenIDScope(wantDownstreamGrantedScopes) { + expectedNumberOfCreatedSecrets++ + } + require.Len(t, kubeClient.Actions(), expectedNumberOfCreatedSecrets) + + // One authcode should have been stored. + testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) + + storedRequestFromAuthcode, storedSessionFromAuthcode := validateAuthcodeStorage( + t, + oauthStore, + authcodeDataAndSignature[1], // Authcode store key is authcode signature + wantDownstreamGrantedScopes, + wantDownstreamIDTokenSubject, + wantDownstreamIDTokenUsername, + wantDownstreamIDTokenGroups, + wantDownstreamRequestedScopes, + wantDownstreamClientID, + wantDownstreamRedirectURI, + ) + + // One PKCE should have been stored. + testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: pkce2.TypeLabelValue}, 1) + + validatePKCEStorage( + t, + oauthStore, + authcodeDataAndSignature[1], // PKCE store key is authcode signature + storedRequestFromAuthcode, + storedSessionFromAuthcode, + wantDownstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod, + ) + + // One IDSession should have been stored, if the downstream actually requested the "openid" scope + if includesOpenIDScope(wantDownstreamGrantedScopes) { + testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1) + + validateIDSessionStorage( + t, + oauthStore, + capturedAuthCode, // IDSession store key is full authcode + storedRequestFromAuthcode, + storedSessionFromAuthcode, + wantDownstreamNonce, + ) + } +} + +func includesOpenIDScope(scopes []string) bool { + for _, scope := range scopes { + if scope == "openid" { + return true + } + } + return false +} + +func validateAuthcodeStorage( + t *testing.T, + oauthStore fositestoragei.AllFositeStorage, + storeKey string, + wantDownstreamGrantedScopes []string, + wantDownstreamIDTokenSubject string, + wantDownstreamIDTokenUsername string, + wantDownstreamIDTokenGroups []string, + wantDownstreamRequestedScopes []string, + wantDownstreamClientID string, + wantDownstreamRedirectURI string, +) (*fosite.Request, *openid.DefaultSession) { + t.Helper() + + const ( + authCodeExpirationSeconds = 10 * 60 // Currently, we set our auth code expiration to 10 minutes + timeComparisonFudgeFactor = time.Second * 15 + ) + + // Get the authcode session back from storage so we can require that it was stored correctly. + storedAuthorizeRequestFromAuthcode, err := oauthStore.GetAuthorizeCodeSession(context.Background(), storeKey, nil) + require.NoError(t, err) + + // Check that storage returned the expected concrete data types. + storedRequestFromAuthcode, storedSessionFromAuthcode := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromAuthcode) + + // Check which scopes were granted. + require.ElementsMatch(t, wantDownstreamGrantedScopes, storedRequestFromAuthcode.GetGrantedScopes()) + + // Check all the other fields of the stored request. + require.NotEmpty(t, storedRequestFromAuthcode.ID) + require.Equal(t, wantDownstreamClientID, storedRequestFromAuthcode.Client.GetID()) + require.ElementsMatch(t, wantDownstreamRequestedScopes, storedRequestFromAuthcode.RequestedScope) + require.Nil(t, storedRequestFromAuthcode.RequestedAudience) + require.Empty(t, storedRequestFromAuthcode.GrantedAudience) + require.Equal(t, url.Values{"redirect_uri": []string{wantDownstreamRedirectURI}}, storedRequestFromAuthcode.Form) + testutil.RequireTimeInDelta(t, time.Now(), storedRequestFromAuthcode.RequestedAt, timeComparisonFudgeFactor) + + // We're not using these fields yet, so confirm that we did not set them (for now). + require.Empty(t, storedSessionFromAuthcode.Subject) + require.Empty(t, storedSessionFromAuthcode.Username) + require.Empty(t, storedSessionFromAuthcode.Headers) + + // The authcode that we are issuing should be good for the length of time that we declare in the fosite config. + testutil.RequireTimeInDelta(t, time.Now().Add(authCodeExpirationSeconds*time.Second), storedSessionFromAuthcode.ExpiresAt[fosite.AuthorizeCode], timeComparisonFudgeFactor) + require.Len(t, storedSessionFromAuthcode.ExpiresAt, 1) + + // Now confirm the ID token claims. + actualClaims := storedSessionFromAuthcode.Claims + + // Check the user's identity, which are put into the downstream ID token's subject, username and groups claims. + require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject) + require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"]) + require.Len(t, actualClaims.Extra, 2) + actualDownstreamIDTokenGroups := actualClaims.Extra["groups"] + require.NotNil(t, actualDownstreamIDTokenGroups) + require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualDownstreamIDTokenGroups) + + // Check the rest of the downstream ID token's claims. Fosite wants us to set these (in UTC time). + testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.RequestedAt, timeComparisonFudgeFactor) + testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.AuthTime, timeComparisonFudgeFactor) + requestedAtZone, _ := actualClaims.RequestedAt.Zone() + require.Equal(t, "UTC", requestedAtZone) + authTimeZone, _ := actualClaims.AuthTime.Zone() + require.Equal(t, "UTC", authTimeZone) + + // Fosite will set these fields for us in the token endpoint based on the store session + // information. Therefore, we assert that they are empty because we want the library to do the + // lifting for us. + require.Empty(t, actualClaims.Issuer) + require.Nil(t, actualClaims.Audience) + require.Empty(t, actualClaims.Nonce) + require.Zero(t, actualClaims.ExpiresAt) + require.Zero(t, actualClaims.IssuedAt) + + // These are not needed yet. + require.Empty(t, actualClaims.JTI) + require.Empty(t, actualClaims.CodeHash) + require.Empty(t, actualClaims.AccessTokenHash) + require.Empty(t, actualClaims.AuthenticationContextClassReference) + require.Empty(t, actualClaims.AuthenticationMethodsReference) + + return storedRequestFromAuthcode, storedSessionFromAuthcode +} + +func validatePKCEStorage( + t *testing.T, + oauthStore fositestoragei.AllFositeStorage, + storeKey string, + storedRequestFromAuthcode *fosite.Request, + storedSessionFromAuthcode *openid.DefaultSession, + wantDownstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod string, +) { + t.Helper() + + storedAuthorizeRequestFromPKCE, err := oauthStore.GetPKCERequestSession(context.Background(), storeKey, nil) + require.NoError(t, err) + + // Check that storage returned the expected concrete data types. + storedRequestFromPKCE, storedSessionFromPKCE := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromPKCE) + + // The stored PKCE request should be the same as the stored authcode request. + require.Equal(t, storedRequestFromAuthcode.ID, storedRequestFromPKCE.ID) + require.Equal(t, storedSessionFromAuthcode, storedSessionFromPKCE) + + // The stored PKCE request should also contain the PKCE challenge that the downstream sent us. + require.Equal(t, wantDownstreamPKCEChallenge, storedRequestFromPKCE.Form.Get("code_challenge")) + require.Equal(t, wantDownstreamPKCEChallengeMethod, storedRequestFromPKCE.Form.Get("code_challenge_method")) +} + +func validateIDSessionStorage( + t *testing.T, + oauthStore fositestoragei.AllFositeStorage, + storeKey string, + storedRequestFromAuthcode *fosite.Request, + storedSessionFromAuthcode *openid.DefaultSession, + wantDownstreamNonce string, +) { + t.Helper() + + storedAuthorizeRequestFromIDSession, err := oauthStore.GetOpenIDConnectSession(context.Background(), storeKey, nil) + require.NoError(t, err) + + // Check that storage returned the expected concrete data types. + storedRequestFromIDSession, storedSessionFromIDSession := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromIDSession) + + // The stored IDSession request should be the same as the stored authcode request. + require.Equal(t, storedRequestFromAuthcode.ID, storedRequestFromIDSession.ID) + require.Equal(t, storedSessionFromAuthcode, storedSessionFromIDSession) + + // The stored IDSession request should also contain the nonce that the downstream sent us. + require.Equal(t, wantDownstreamNonce, storedRequestFromIDSession.Form.Get("nonce")) +} + +func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requester) (*fosite.Request, *openid.DefaultSession) { + t.Helper() + + storedRequest, ok := storedAuthorizeRequest.(*fosite.Request) + require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest, &fosite.Request{}) + storedSession, ok := storedAuthorizeRequest.GetSession().(*openid.DefaultSession) + require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest.GetSession(), &openid.DefaultSession{}) + + return storedRequest, storedSession +} diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go new file mode 100644 index 00000000..614a101e --- /dev/null +++ b/internal/upstreamldap/upstreamldap.go @@ -0,0 +1,412 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package upstreamldap implements an abstraction of upstream LDAP IDP interactions. +package upstreamldap + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "strings" + "time" + + "k8s.io/utils/trace" + + "github.com/go-ldap/ldap/v3" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + + "go.pinniped.dev/internal/plog" +) + +const ( + ldapsScheme = "ldaps" + distinguishedNameAttributeName = "dn" + userSearchFilterInterpolationLocationMarker = "{}" +) + +// Conn abstracts the upstream LDAP communication protocol (mostly for testing). +type Conn interface { + Bind(username, password string) error + + Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) + + Close() +} + +// Our Conn type is subset of the ldap.Client interface, which is implemented by ldap.Conn. +var _ Conn = &ldap.Conn{} + +// LDAPDialer is a factory of Conn, and the resulting Conn can then be used to interact with an upstream LDAP IDP. +type LDAPDialer interface { + Dial(ctx context.Context, hostAndPort string) (Conn, error) +} + +// LDAPDialerFunc makes it easy to use a func as an LDAPDialer. +type LDAPDialerFunc func(ctx context.Context, hostAndPort string) (Conn, error) + +var _ LDAPDialer = LDAPDialerFunc(nil) + +func (f LDAPDialerFunc) Dial(ctx context.Context, hostAndPort string) (Conn, error) { + return f(ctx, hostAndPort) +} + +// ProviderConfig includes all of the settings for connection and searching for users and groups in +// the upstream LDAP IDP. It also provides methods for testing the connection and performing logins. +// The nested structs are not pointer fields to enable deep copy on function params and return values. +type ProviderConfig struct { + // Name is the unique name of this upstream LDAP IDP. + Name string + + // Host is the hostname or "hostname:port" of the LDAP server. When the port is not specified, + // the default LDAP port will be used. + Host string + + // PEM-encoded CA cert bundle to trust when connecting to the LDAP server. Can be nil. + CABundle []byte + + // BindUsername is the username to use when performing a bind with the upstream LDAP IDP. + BindUsername string + + // BindPassword is the password to use when performing a bind with the upstream LDAP IDP. + BindPassword string + + // UserSearch contains information about how to search for users in the upstream LDAP IDP. + UserSearch UserSearchConfig + + // Dialer exists to enable testing. When nil, will use a default appropriate for production use. + Dialer LDAPDialer +} + +// UserSearchConfig contains information about how to search for users in the upstream LDAP IDP. +type UserSearchConfig struct { + // Base is the base DN to use for the user search in the upstream LDAP IDP. + Base string + + // Filter is the filter to use for the user search in the upstream LDAP IDP. + Filter string + + // UsernameAttribute is the attribute in the LDAP entry from which the username should be + // retrieved. + UsernameAttribute string + + // UIDAttribute is the attribute in the LDAP entry from which the user's unique ID should be + // retrieved. + UIDAttribute string +} + +type Provider struct { + c ProviderConfig +} + +// Create a Provider. The config is not a pointer to ensure that a copy of the config is created, +// making the resulting Provider use an effectively read-only configuration. +func New(config ProviderConfig) *Provider { + return &Provider{c: config} +} + +// A reader for the config. Returns a copy of the config to keep the underlying config read-only. +func (p *Provider) GetConfig() ProviderConfig { + return p.c +} + +func (p *Provider) dial(ctx context.Context) (Conn, error) { + hostAndPort, err := hostAndPortWithDefaultPort(p.c.Host, ldap.DefaultLdapsPort) + if err != nil { + return nil, ldap.NewError(ldap.ErrorNetwork, err) + } + if p.c.Dialer != nil { + return p.c.Dialer.Dial(ctx, hostAndPort) + } + return p.dialTLS(ctx, hostAndPort) +} + +// dialTLS is the default implementation of the Dialer, used when Dialer is nil. +// Unfortunately, the go-ldap library does not seem to support dialing with a context.Context, +// so we implement it ourselves, heavily inspired by ldap.DialURL. +func (p *Provider) dialTLS(ctx context.Context, hostAndPort string) (Conn, error) { + var rootCAs *x509.CertPool + if p.c.CABundle != nil { + rootCAs = x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(p.c.CABundle) { + return nil, ldap.NewError(ldap.ErrorNetwork, fmt.Errorf("could not parse CA bundle")) + } + } + + dialer := &tls.Dialer{Config: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + }} + + c, err := dialer.DialContext(ctx, "tcp", hostAndPort) + if err != nil { + return nil, ldap.NewError(ldap.ErrorNetwork, err) + } + + conn := ldap.NewConn(c, true) + conn.Start() + return conn, nil +} + +// Adds the default port if hostAndPort did not already include a port. +func hostAndPortWithDefaultPort(hostAndPort string, defaultPort string) (string, error) { + host, port, err := net.SplitHostPort(hostAndPort) + if err != nil { + if strings.HasSuffix(err.Error(), ": missing port in address") { // sad to need to do this string compare + host = hostAndPort + port = defaultPort + } else { + return "", err // hostAndPort argument was not parsable + } + } + switch { + case port != "" && strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]"): + // don't add extra square brackets to an IPv6 address that already has them + return host + ":" + port, nil + case port != "": + return net.JoinHostPort(host, port), nil + default: + return host, nil + } +} + +// A name for this upstream provider. +func (p *Provider) GetName() string { + return p.c.Name +} + +// Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234". +// This URL is not used for connecting to the provider, but rather is used for creating a globally unique user +// identifier by being combined with the user's UID, since user UIDs are only unique within one provider. +func (p *Provider) GetURL() string { + return fmt.Sprintf("%s://%s", ldapsScheme, p.c.Host) +} + +// TestConnection provides a method for testing the connection and bind settings. It performs a dial and bind +// and returns any errors that we encountered. +func (p *Provider) TestConnection(ctx context.Context) error { + err := p.validateConfig() + if err != nil { + return err + } + + conn, err := p.dial(ctx) + if err != nil { + return fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) + } + defer conn.Close() + + err = conn.Bind(p.c.BindUsername, p.c.BindPassword) + if err != nil { + return fmt.Errorf(`error binding as "%s": %w`, p.c.BindUsername, err) + } + + return nil +} + +// DryRunAuthenticateUser provides a method for testing all of the Provider settings in a kind of dry run of +// authentication for a given end user's username. It runs the same logic as AuthenticateUser except it does +// not bind as that user, so it does not test their password. It returns the same values that a real call to +// AuthenticateUser with the correct password would return. +func (p *Provider) DryRunAuthenticateUser(ctx context.Context, username string) (*authenticator.Response, bool, error) { + endUserBindFunc := func(conn Conn, foundUserDN string) error { + // Act as if the end user bind always succeeds. + return nil + } + return p.authenticateUserImpl(ctx, username, endUserBindFunc) +} + +// Authenticate an end user and return their mapped username, groups, and UID. Implements authenticators.UserAuthenticator. +func (p *Provider) AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { + endUserBindFunc := func(conn Conn, foundUserDN string) error { + return conn.Bind(foundUserDN, password) + } + return p.authenticateUserImpl(ctx, username, endUserBindFunc) +} + +func (p *Provider) authenticateUserImpl(ctx context.Context, username string, bindFunc func(conn Conn, foundUserDN string) error) (*authenticator.Response, bool, error) { + t := trace.FromContext(ctx).Nest("slow ldap authenticate user attempt", trace.Field{Key: "providerName", Value: p.GetName()}) + defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches + + err := p.validateConfig() + if err != nil { + p.traceAuthFailure(t, err) + return nil, false, err + } + + if len(username) == 0 { + // Empty passwords are already handled by go-ldap. + p.traceAuthFailure(t, fmt.Errorf("empty username")) + return nil, false, nil + } + + conn, err := p.dial(ctx) + if err != nil { + p.traceAuthFailure(t, err) + return nil, false, fmt.Errorf(`error dialing host "%s": %w`, p.c.Host, err) + } + defer conn.Close() + + err = conn.Bind(p.c.BindUsername, p.c.BindPassword) + if err != nil { + p.traceAuthFailure(t, err) + return nil, false, fmt.Errorf(`error binding as "%s" before user search: %w`, p.c.BindUsername, err) + } + + mappedUsername, mappedUID, err := p.searchAndBindUser(conn, username, bindFunc) + if err != nil { + p.traceAuthFailure(t, err) + return nil, false, err + } + if len(mappedUsername) == 0 || len(mappedUID) == 0 { + // Couldn't find the username or couldn't bind using the password. + p.traceAuthFailure(t, fmt.Errorf("bad username or password")) + return nil, false, nil + } + + response := &authenticator.Response{ + User: &user.DefaultInfo{ + Name: mappedUsername, + UID: mappedUID, + Groups: []string{}, // Support for group search coming soon. + }, + } + p.traceAuthSuccess(t) + return response, true, nil +} + +func (p *Provider) validateConfig() error { + if p.c.UserSearch.UsernameAttribute == distinguishedNameAttributeName && len(p.c.UserSearch.Filter) == 0 { + // LDAP search filters do not allow searching by DN, so we would have no reasonable default for Filter. + return fmt.Errorf(`must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`) + } + return nil +} + +func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(conn Conn, foundUserDN string) error) (string, string, error) { + searchResult, err := conn.Search(p.userSearchRequest(username)) + if err != nil { + return "", "", fmt.Errorf(`error searching for user "%s": %w`, username, err) + } + if len(searchResult.Entries) == 0 { + plog.Debug("error finding user: user not found (if this username is valid, please check the user search configuration)", + "upstreamName", p.GetName(), "username", username) + return "", "", nil + } + if len(searchResult.Entries) > 1 { + return "", "", fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`, + username, len(searchResult.Entries), + ) + } + userEntry := searchResult.Entries[0] + if len(userEntry.DN) == 0 { + return "", "", fmt.Errorf(`searching for user "%s" resulted in search result without DN`, username) + } + + mappedUsername, err := p.getSearchResultAttributeValue(p.c.UserSearch.UsernameAttribute, userEntry, username) + if err != nil { + return "", "", err + } + + mappedUID, err := p.getSearchResultAttributeValue(p.c.UserSearch.UIDAttribute, userEntry, username) + if err != nil { + return "", "", err + } + + // Caution: Note that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername! + err = bindFunc(conn, userEntry.DN) + if err != nil { + plog.DebugErr("error binding for user (if this is not the expected dn for this username, please check the user search configuration)", + err, "upstreamName", p.GetName(), "username", username, "dn", userEntry.DN) + ldapErr := &ldap.Error{} + if errors.As(err, &ldapErr) && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials { + return "", "", nil + } + return "", "", fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err) + } + + return mappedUsername, mappedUID, nil +} + +func (p *Provider) userSearchRequest(username string) *ldap.SearchRequest { + // See https://ldap.com/the-ldap-search-operation for general documentation of LDAP search options. + return &ldap.SearchRequest{ + BaseDN: p.c.UserSearch.Base, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + SizeLimit: 2, + TimeLimit: 90, + TypesOnly: false, + Filter: p.userSearchFilter(username), + Attributes: p.userSearchRequestedAttributes(), + Controls: nil, // this could be used to enable paging, but we're already limiting the result max size + } +} + +func (p *Provider) userSearchRequestedAttributes() []string { + attributes := []string{} + if p.c.UserSearch.UsernameAttribute != distinguishedNameAttributeName { + attributes = append(attributes, p.c.UserSearch.UsernameAttribute) + } + if p.c.UserSearch.UIDAttribute != distinguishedNameAttributeName { + attributes = append(attributes, p.c.UserSearch.UIDAttribute) + } + return attributes +} + +func (p *Provider) userSearchFilter(username string) string { + safeUsername := p.escapeUsernameForSearchFilter(username) + if len(p.c.UserSearch.Filter) == 0 { + return fmt.Sprintf("(%s=%s)", p.c.UserSearch.UsernameAttribute, safeUsername) + } + filter := strings.ReplaceAll(p.c.UserSearch.Filter, userSearchFilterInterpolationLocationMarker, safeUsername) + if strings.HasPrefix(filter, "(") && strings.HasSuffix(filter, ")") { + return filter + } + return "(" + filter + ")" +} + +func (p *Provider) escapeUsernameForSearchFilter(username string) string { + // The username is end user input, so it should be escaped before being included in a search to prevent query injection. + return ldap.EscapeFilter(username) +} + +func (p *Provider) getSearchResultAttributeValue(attributeName string, fromUserEntry *ldap.Entry, username string) (string, error) { + if attributeName == distinguishedNameAttributeName { + return fromUserEntry.DN, nil + } + + attributeValues := fromUserEntry.GetAttributeValues(attributeName) + + if len(attributeValues) != 1 { + return "", fmt.Errorf(`found %d values for attribute "%s" while searching for user "%s", but expected 1 result`, + len(attributeValues), attributeName, username, + ) + } + + attributeValue := attributeValues[0] + if len(attributeValue) == 0 { + return "", fmt.Errorf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, + attributeName, username, + ) + } + + return attributeValue, nil +} + +func (p *Provider) traceAuthFailure(t *trace.Trace, err error) { + t.Step("authentication failed", + trace.Field{Key: "authenticated", Value: false}, + trace.Field{Key: "reason", Value: err.Error()}, + ) +} + +func (p *Provider) traceAuthSuccess(t *trace.Trace) { + t.Step("authentication succeeded", + trace.Field{Key: "authenticated", Value: true}, + ) +} diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go new file mode 100644 index 00000000..b4e64cce --- /dev/null +++ b/internal/upstreamldap/upstreamldap_test.go @@ -0,0 +1,951 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package upstreamldap + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "testing" + + "github.com/go-ldap/ldap/v3" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + + "go.pinniped.dev/internal/mocks/mockldapconn" + "go.pinniped.dev/internal/testutil" +) + +const ( + testHost = "ldap.example.com:8443" + testBindUsername = "cn=some-bind-username,dc=pinniped,dc=dev" + testBindPassword = "some-bind-password" + testUpstreamUsername = "some-upstream-username" + testUpstreamPassword = "some-upstream-password" + testUserSearchBase = "some-upstream-base-dn" + testUserSearchFilter = "some-filter={}-and-more-filter={}" + testUserSearchUsernameAttribute = "some-upstream-username-attribute" + testUserSearchUIDAttribute = "some-upstream-uid-attribute" + testSearchResultDNValue = "some-upstream-user-dn" + testSearchResultUsernameAttributeValue = "some-upstream-username-value" + testSearchResultUIDAttributeValue = "some-upstream-uid-value" +) + +var ( + testUserSearchFilterInterpolated = fmt.Sprintf("(some-filter=%s-and-more-filter=%s)", testUpstreamUsername, testUpstreamUsername) +) + +func TestEndUserAuthentication(t *testing.T) { + providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig { + config := &ProviderConfig{ + Name: "some-provider-name", + Host: testHost, + CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: UserSearchConfig{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + UsernameAttribute: testUserSearchUsernameAttribute, + UIDAttribute: testUserSearchUIDAttribute, + }, + } + if editFunc != nil { + editFunc(config) + } + return config + } + + expectedSearch := func(editFunc func(r *ldap.SearchRequest)) *ldap.SearchRequest { + request := &ldap.SearchRequest{ + BaseDN: testUserSearchBase, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + SizeLimit: 2, + TimeLimit: 90, + TypesOnly: false, + Filter: testUserSearchFilterInterpolated, + Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, + Controls: nil, + } + if editFunc != nil { + editFunc(request) + } + return request + } + + tests := []struct { + name string + username string + password string + providerConfig *ProviderConfig + searchMocks func(conn *mockldapconn.MockConn) + bindEndUserMocks func(conn *mockldapconn.MockConn) + dialError error + wantError string + wantToSkipDial bool + wantAuthResponse *authenticator.Response + wantUnauthenticated bool + skipDryRunAuthenticateUser bool // tests about when the end user bind fails don't make sense for DryRunAuthenticateUser() + }{ + { + name: "happy path", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + Referrals: []string{}, // note that we are not following referrals at this time + Controls: []ldap.Control{}, // TODO are there any response controls that we need to be able to handle? + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: testSearchResultUsernameAttributeValue, + UID: testSearchResultUIDAttributeValue, + Groups: []string{}, + }, + }, + }, + { + name: "when the user search filter is already wrapped by parenthesis then it is not wrapped again", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.UserSearch.Filter = "(" + testUserSearchFilter + ")" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: testSearchResultUsernameAttributeValue, + UID: testSearchResultUIDAttributeValue, + Groups: []string{}, + }, + }, + }, + { + name: "when the UsernameAttribute is dn and there is a user search filter provided", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.UserSearch.UsernameAttribute = "dn" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) { + r.Attributes = []string{testUserSearchUIDAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: testSearchResultDNValue, + UID: testSearchResultUIDAttributeValue, + Groups: []string{}, + }, + }, + }, + { + name: "when the UIDAttribute is dn", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.UserSearch.UIDAttribute = "dn" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) { + r.Attributes = []string{testUserSearchUsernameAttribute} + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: testSearchResultUsernameAttributeValue, + UID: testSearchResultDNValue, + Groups: []string{}, + }, + }, + }, + { + name: "when Filter is blank it derives a search filter from the UsernameAttribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.UserSearch.Filter = "" + }), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) { + r.Filter = "(" + testUserSearchUsernameAttribute + "=" + testUpstreamUsername + ")" + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: testSearchResultUsernameAttributeValue, + UID: testSearchResultUIDAttributeValue, + Groups: []string{}, + }, + }, + }, + { + name: "when the username has special LDAP search filter characters then they must be properly escaped in the search filter", + username: `a&b|c(d)e\f*g`, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(func(r *ldap.SearchRequest) { + r.Filter = fmt.Sprintf("(some-filter=%s-and-more-filter=%s)", `a&b|c\28d\29e\5cf\2ag`, `a&b|c\28d\29e\5cf\2ag`) + })).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Times(1) + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: testSearchResultUsernameAttributeValue, + UID: testSearchResultUIDAttributeValue, + Groups: []string{}, + }, + }, + }, + { + name: "when dial fails", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + dialError: errors.New("some dial error"), + wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost), + }, + { + name: "when the UsernameAttribute is dn and there is not a user search filter provided", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(func(p *ProviderConfig) { + p.UserSearch.UsernameAttribute = "dn" + p.UserSearch.Filter = "" + }), + wantToSkipDial: true, + wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, + }, + { + name: "when binding as the bind user returns an error", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`error binding as "%s" before user search: some bind error`, testBindUsername), + }, + { + name: "when searching for the user returns an error", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(nil, errors.New("some search error")).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`error searching for user "%s": some search error`, testUpstreamUsername), + }, + { + name: "when searching for the user returns no results", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{}, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantUnauthenticated: true, + }, + { + name: "when searching for the user returns multiple results", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + {DN: testSearchResultDNValue}, + {DN: "some-other-dn"}, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`searching for user "%s" resulted in 2 search results, but expected 1 result`, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user without a DN", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + {DN: ""}, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`searching for user "%s" resulted in search result without DN`, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user without an expected username attribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUsernameAttribute, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user with too many values for the expected username attribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{ + testSearchResultUsernameAttributeValue, + "unexpected-additional-value", + }), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUsernameAttribute, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user with an empty value for the expected username attribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{""}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, testUserSearchUsernameAttribute, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user without an expected UID attribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`found 0 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUIDAttribute, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user with too many values for the expected UID attribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{ + testSearchResultUIDAttributeValue, + "unexpected-additional-value", + }), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`found 2 values for attribute "%s" while searching for user "%s", but expected 1 result`, testUserSearchUIDAttribute, testUpstreamUsername), + }, + { + name: "when searching for the user returns a user with an empty value for the expected UID attribute", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{""}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`, testUserSearchUIDAttribute, testUpstreamUsername), + }, + { + name: "when binding as the found user returns an error", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Return(errors.New("some bind error")).Times(1) + }, + skipDryRunAuthenticateUser: true, + wantError: fmt.Sprintf(`error binding for user "%s" using provided password against DN "%s": some bind error`, testUpstreamUsername, testSearchResultDNValue), + }, + { + name: "when binding as the found user returns a specific invalid credentials error", + username: testUpstreamUsername, + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + searchMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}), + ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}), + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantUnauthenticated: true, + skipDryRunAuthenticateUser: true, + bindEndUserMocks: func(conn *mockldapconn.MockConn) { + err := &ldap.Error{ + Err: errors.New("some bind error"), + ResultCode: ldap.LDAPResultInvalidCredentials, + } + conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Return(err).Times(1) + }, + }, + { + name: "when no username is specified", + username: "", + password: testUpstreamPassword, + providerConfig: providerConfig(nil), + wantToSkipDial: true, + wantUnauthenticated: true, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + conn := mockldapconn.NewMockConn(ctrl) + if tt.searchMocks != nil { + tt.searchMocks(conn) + } + if tt.bindEndUserMocks != nil { + tt.bindEndUserMocks(conn) + } + + dialWasAttempted := false + tt.providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, hostAndPort string) (Conn, error) { + dialWasAttempted = true + require.Equal(t, tt.providerConfig.Host, hostAndPort) + if tt.dialError != nil { + return nil, tt.dialError + } + return conn, nil + }) + + provider := New(*tt.providerConfig) + + authResponse, authenticated, err := provider.AuthenticateUser(context.Background(), tt.username, tt.password) + require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) + switch { + case tt.wantError != "": + require.EqualError(t, err, tt.wantError) + require.False(t, authenticated) + require.Nil(t, authResponse) + case tt.wantUnauthenticated: + require.NoError(t, err) + require.False(t, authenticated) + require.Nil(t, authResponse) + default: + require.NoError(t, err) + require.True(t, authenticated) + require.Equal(t, tt.wantAuthResponse, authResponse) + } + + // DryRunAuthenticateUser() should have the same behavior as AuthenticateUser() except that it does not bind + // as the end user to confirm their password. Since it should behave the same, all of the same test cases + // apply, except for those which are specifically testing what happens when the end user bind fails. + if tt.skipDryRunAuthenticateUser { + return // move on to the next test + } + + // Reset some variables to get ready to call DryRunAuthenticateUser(). + dialWasAttempted = false + conn = mockldapconn.NewMockConn(ctrl) + if tt.searchMocks != nil { + tt.searchMocks(conn) + } + // Skip tt.bindEndUserMocks since DryRunAuthenticateUser() never binds as the end user. + + authResponse, authenticated, err = provider.DryRunAuthenticateUser(context.Background(), tt.username) + require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) + switch { + case tt.wantError != "": + require.EqualError(t, err, tt.wantError) + require.False(t, authenticated) + require.Nil(t, authResponse) + case tt.wantUnauthenticated: + require.NoError(t, err) + require.False(t, authenticated) + require.Nil(t, authResponse) + default: + require.NoError(t, err) + require.True(t, authenticated) + require.Equal(t, tt.wantAuthResponse, authResponse) + } + }) + } +} + +func TestTestConnection(t *testing.T) { + providerConfig := func(editFunc func(p *ProviderConfig)) *ProviderConfig { + config := &ProviderConfig{ + Name: "some-provider-name", + Host: testHost, + CABundle: nil, // this field is only used by the production dialer, which is replaced by a mock for this test + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: UserSearchConfig{}, // not used by TestConnection + } + if editFunc != nil { + editFunc(config) + } + return config + } + + tests := []struct { + name string + providerConfig *ProviderConfig + setupMocks func(conn *mockldapconn.MockConn) + dialError error + wantError string + wantToSkipDial bool + }{ + { + name: "happy path", + providerConfig: providerConfig(nil), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Close().Times(1) + }, + }, + { + name: "when dial fails", + providerConfig: providerConfig(nil), + dialError: errors.New("some dial error"), + wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost), + }, + { + name: "when binding as the bind user returns an error", + providerConfig: providerConfig(nil), + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantError: fmt.Sprintf(`error binding as "%s": some bind error`, testBindUsername), + }, + { + name: "when the config is invalid", + providerConfig: providerConfig(func(p *ProviderConfig) { + // This particular combination of options is not allowed. + p.UserSearch.UsernameAttribute = "dn" + p.UserSearch.Filter = "" + }), + wantToSkipDial: true, + wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + conn := mockldapconn.NewMockConn(ctrl) + if tt.setupMocks != nil { + tt.setupMocks(conn) + } + + dialWasAttempted := false + tt.providerConfig.Dialer = LDAPDialerFunc(func(ctx context.Context, hostAndPort string) (Conn, error) { + dialWasAttempted = true + require.Equal(t, tt.providerConfig.Host, hostAndPort) + if tt.dialError != nil { + return nil, tt.dialError + } + return conn, nil + }) + + provider := New(*tt.providerConfig) + err := provider.TestConnection(context.Background()) + + require.Equal(t, !tt.wantToSkipDial, dialWasAttempted) + + switch { + case tt.wantError != "": + require.EqualError(t, err, tt.wantError) + default: + require.NoError(t, err) + } + }) + } +} + +func TestGetConfig(t *testing.T) { + c := ProviderConfig{ + Name: "original-provider-name", + Host: testHost, + CABundle: []byte("some-ca-bundle"), + BindUsername: testBindUsername, + BindPassword: testBindPassword, + UserSearch: UserSearchConfig{ + Base: testUserSearchBase, + Filter: testUserSearchFilter, + UsernameAttribute: testUserSearchUsernameAttribute, + UIDAttribute: testUserSearchUIDAttribute, + }, + } + p := New(c) + require.Equal(t, c, p.c) + require.Equal(t, c, p.GetConfig()) + + // The original config can be changed without impacting the provider, since the provider made a copy of the config. + c.Name = "changed-name" + require.Equal(t, "original-provider-name", p.c.Name) + + // The return value of GetConfig can be modified without impacting the provider, since it is a copy of the config. + returnedConfig := p.GetConfig() + returnedConfig.Name = "changed-name" + require.Equal(t, "original-provider-name", p.c.Name) +} + +func TestGetURL(t *testing.T) { + require.Equal(t, "ldaps://ldap.example.com:1234", New(ProviderConfig{Host: "ldap.example.com:1234"}).GetURL()) + require.Equal(t, "ldaps://ldap.example.com", New(ProviderConfig{Host: "ldap.example.com"}).GetURL()) +} + +// Testing of host parsing, TLS negotiation, and CA bundle, etc. for the production code's dialer. +func TestRealTLSDialing(t *testing.T) { + testServerCABundle, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {}) + parsedURL, err := url.Parse(testServerURL) + require.NoError(t, err) + testServerHostAndPort := parsedURL.Host + + unusedPortGrabbingListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + recentlyClaimedHostAndPort := unusedPortGrabbingListener.Addr().String() + require.NoError(t, unusedPortGrabbingListener.Close()) + + alreadyCancelledContext, cancelFunc := context.WithCancel(context.Background()) + cancelFunc() // cancel it immediately + + tests := []struct { + name string + host string + caBundle []byte + context context.Context + wantError string + }{ + { + name: "happy path", + host: testServerHostAndPort, + caBundle: []byte(testServerCABundle), + context: context.Background(), + }, + { + name: "invalid CA bundle", + host: testServerHostAndPort, + caBundle: []byte("not a ca bundle"), + context: context.Background(), + wantError: `LDAP Result Code 200 "Network Error": could not parse CA bundle`, + }, + { + name: "missing CA bundle when it is required because the host is not using a trusted CA", + host: testServerHostAndPort, + caBundle: nil, + context: context.Background(), + wantError: `LDAP Result Code 200 "Network Error": x509: certificate signed by unknown authority`, + }, + { + name: "cannot connect to host", + // This is assuming that this port was not reclaimed by another app since the test setup ran. Seems safe enough. + host: recentlyClaimedHostAndPort, + caBundle: []byte(testServerCABundle), + context: context.Background(), + wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: connect: connection refused`, recentlyClaimedHostAndPort), + }, + { + name: "pays attention to the passed context", + host: testServerHostAndPort, + caBundle: []byte(testServerCABundle), + context: alreadyCancelledContext, + wantError: fmt.Sprintf(`LDAP Result Code 200 "Network Error": dial tcp %s: operation was canceled`, testServerHostAndPort), + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + provider := New(ProviderConfig{ + Host: test.host, + CABundle: test.caBundle, + Dialer: nil, // this test is for the default (production) dialer + }) + conn, err := provider.dial(test.context) + if conn != nil { + defer conn.Close() + } + if test.wantError != "" { + require.Nil(t, conn) + require.EqualError(t, err, test.wantError) + } else { + require.NoError(t, err) + require.NotNil(t, conn) + + // Should be an instance of the real production LDAP client type. + // Can't test its methods here because we are not dialed to a real LDAP server. + require.IsType(t, &ldap.Conn{}, conn) + + // Indirectly checking that the Dialer method constructed the ldap.Conn with isTLS set to true, + // since this is always the correct behavior unless/until we want to support StartTLS. + err := conn.(*ldap.Conn).StartTLS(&tls.Config{}) + require.EqualError(t, err, `LDAP Result Code 200 "Network Error": ldap: already encrypted`) + } + }) + } +} + +// Test various cases of host and port parsing. +func TestHostAndPortWithDefaultPort(t *testing.T) { + tests := []struct { + name string + hostAndPort string + defaultPort string + wantError string + wantHostAndPort string + }{ + { + name: "host already has port", + hostAndPort: "host.example.com:99", + defaultPort: "42", + wantHostAndPort: "host.example.com:99", + }, + { + name: "host does not have port", + hostAndPort: "host.example.com", + defaultPort: "42", + wantHostAndPort: "host.example.com:42", + }, + { + name: "host does not have port and default port is empty", + hostAndPort: "host.example.com", + defaultPort: "", + wantHostAndPort: "host.example.com", + }, + { + name: "IPv6 host already has port", + hostAndPort: "[::1%lo0]:80", + defaultPort: "42", + wantHostAndPort: "[::1%lo0]:80", + }, + { + name: "IPv6 host does not have port", + hostAndPort: "[::1%lo0]", + defaultPort: "42", + wantHostAndPort: "[::1%lo0]:42", + }, + { + name: "IPv6 host does not have port and default port is empty", + hostAndPort: "[::1%lo0]", + defaultPort: "", + wantHostAndPort: "[::1%lo0]", + }, + { + name: "host is not valid", + hostAndPort: "host.example.com:port1:port2", + defaultPort: "42", + wantError: "address host.example.com:port1:port2: too many colons in address", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + hostAndPort, err := hostAndPortWithDefaultPort(test.hostAndPort, test.defaultPort) + if test.wantError != "" { + require.EqualError(t, err, test.wantError) + } else { + require.NoError(t, err) + } + require.Equal(t, test.wantHostAndPort, hostAndPort) + }) + } +} diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index 14ca72d3..40739eb8 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -5,13 +5,16 @@ package oidcclient import ( + "bufio" "context" "encoding/json" + "errors" "fmt" "mime" "net" "net/http" "net/url" + "os" "sort" "strings" "time" @@ -20,6 +23,7 @@ import ( "github.com/go-logr/logr" "github.com/pkg/browser" "golang.org/x/oauth2" + "golang.org/x/term" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.pinniped.dev/internal/httputil/httperr" @@ -46,6 +50,16 @@ const ( // we set this to be relatively long. overallTimeout = 90 * time.Minute + supervisorAuthorizeUpstreamNameParam = "pinniped_idp_name" + supervisorAuthorizeUpstreamTypeParam = "pinniped_idp_type" + supervisorAuthorizeUpstreamUsernameHeader = "Pinniped-Username" + supervisorAuthorizeUpstreamPasswordHeader = "Pinniped-Password" // nolint:gosec // this is not a credential + + defaultLDAPUsernamePrompt = "Username: " + defaultLDAPPasswordPrompt = "Password: " + + httpLocationHeaderName = "Location" + debugLogLevel = 4 ) @@ -58,6 +72,10 @@ type handlerState struct { scopes []string cache SessionCache + upstreamIdentityProviderName string + upstreamIdentityProviderType string + cliToSendCredentials bool + requestedAudience string httpClient *http.Client @@ -80,6 +98,8 @@ type handlerState struct { openURL func(string) error getProvider func(*oauth2.Config, *oidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI validateIDToken func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) + promptForValue func(promptLabel string) (string, error) + promptForSecret func(promptLabel string) (string, error) callbacks chan callbackResult } @@ -112,7 +132,7 @@ func WithLogger(logger logr.Logger) Option { // 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: +// to support wildcard port numbers as described by https://tools.ietf.org/html/rfc8252#section-7.3: // // The authorization server MUST allow any port to be specified at the // time of the request for loopback IP redirect URIs, to accommodate @@ -180,6 +200,31 @@ func WithRequestAudience(audience string) Option { } } +// WithCLISendingCredentials causes the login flow to use CLI-based prompts for username and password and causes the +// call to the Issuer's authorize endpoint to be made directly (no web browser) with the username and password on custom +// HTTP headers. This is only intended to be used when the issuer is a Pinniped Supervisor and the upstream identity +// provider type supports this style of authentication. Currently this is supported by LDAPIdentityProviders. +// This should never be used with non-Supervisor issuers because it will send the user's password to the authorization +// endpoint as a custom header, which would be ignored but could potentially get logged somewhere by the issuer. +func WithCLISendingCredentials() Option { + return func(h *handlerState) error { + h.cliToSendCredentials = true + return nil + } +} + +// WithUpstreamIdentityProvider causes the specified name and type to be sent as custom query parameters to the +// issuer's authorize endpoint. This is only intended to be used when the issuer is a Pinniped Supervisor, in which +// case it provides a mechanism to choose among several upstream identity providers. +// Other issuers will ignore these custom query parameters. +func WithUpstreamIdentityProvider(upstreamName, upstreamType string) Option { + return func(h *handlerState) error { + h.upstreamIdentityProviderName = upstreamName + h.upstreamIdentityProviderType = upstreamType + return nil + } +} + // nopCache is a SessionCache that doesn't actually do anything. type nopCache struct{} @@ -209,6 +254,8 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er validateIDToken: func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) { return provider.Verifier(&oidc.Config{ClientID: audience}).Verify(ctx, token) }, + promptForValue: promptForValue, + promptForSecret: promptForSecret, } for _, opt := range opts { if err := opt(&h); err != nil { @@ -296,6 +343,138 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { } } + // Prepare the common options for the authorization URL. We don't have the redirect URL yet though. + authorizeOptions := []oauth2.AuthCodeOption{ + oauth2.AccessTypeOffline, + h.nonce.Param(), + h.pkce.Challenge(), + h.pkce.Method(), + } + if h.upstreamIdentityProviderName != "" { + authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam(supervisorAuthorizeUpstreamNameParam, h.upstreamIdentityProviderName)) + authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam(supervisorAuthorizeUpstreamTypeParam, h.upstreamIdentityProviderType)) + } + + // Choose the appropriate authorization and authcode exchange strategy. + var authFunc = h.webBrowserBasedAuth + if h.cliToSendCredentials { + authFunc = h.cliBasedAuth + } + + // Perform the authorize request and authcode exchange to get back OIDC tokens. + token, err := authFunc(&authorizeOptions) + + // If we got tokens, put them in the cache. + if err == nil { + h.cache.PutToken(cacheKey, token) + } + + return token, err +} + +// Make a direct call to the authorize endpoint, including the user's username and password on custom http headers, +// and parse the authcode from the response. Exchange the authcode for tokens. Return the tokens or an error. +func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (*oidctypes.Token, error) { + // Ask the user for their username and password. + username, err := h.promptForValue(defaultLDAPUsernamePrompt) + if err != nil { + return nil, fmt.Errorf("error prompting for username: %w", err) + } + password, err := h.promptForSecret(defaultLDAPPasswordPrompt) + if err != nil { + return nil, fmt.Errorf("error prompting for password: %w", err) + } + + // Make a callback URL even though we won't be listening on this port, because providing a redirect URL is + // required for OIDC authorize endpoints, and it must match the allowed redirect URL of the OIDC client + // registered on the server. The Supervisor oauth client does not have "localhost" in the allowed redirect + // URI list, so use 127.0.0.1. + localhostAddr := strings.ReplaceAll(h.listenAddr, "localhost", "127.0.0.1") + h.oauth2Config.RedirectURL = (&url.URL{ + Scheme: "http", + Host: localhostAddr, + Path: h.callbackPath, + }).String() + + // Now that we have a redirect URL, we can build the authorize URL. + authorizeURL := h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...) + + // Don't follow redirects automatically because we want to handle redirects here. + h.httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + // Send an authorize request. + authCtx, authorizeCtxCancelFunc := context.WithTimeout(h.ctx, httpRequestTimeout) + defer authorizeCtxCancelFunc() + authReq, err := http.NewRequestWithContext(authCtx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, fmt.Errorf("could not build authorize request: %w", err) + } + authReq.Header.Set(supervisorAuthorizeUpstreamUsernameHeader, username) + authReq.Header.Set(supervisorAuthorizeUpstreamPasswordHeader, password) + authRes, err := h.httpClient.Do(authReq) + if err != nil { + return nil, fmt.Errorf("authorization response error: %w", err) + } + _ = authRes.Body.Close() // don't need the response body, and okay if it fails to close + + // A successful authorization always results in a 302. + if authRes.StatusCode != http.StatusFound { + return nil, fmt.Errorf( + "error getting authorization: expected to be redirected, but response status was %s", authRes.Status) + } + rawLocation := authRes.Header.Get(httpLocationHeaderName) + location, err := url.Parse(rawLocation) + if err != nil { + // This shouldn't be possible in practice because httpClient.Do() already parses the Location header. + return nil, fmt.Errorf("error getting authorization: could not parse redirect location: %w", err) + } + + // Check that the redirect was to the expected location. + if location.Scheme != "http" || location.Host != localhostAddr || location.Path != h.callbackPath { + return nil, fmt.Errorf("error getting authorization: redirected to the wrong location: %s", rawLocation) + } + + // Validate OAuth2 state and fail if it's incorrect (to block CSRF). + if err := h.state.Validate(location.Query().Get("state")); err != nil { + return nil, fmt.Errorf("missing or invalid state parameter in authorization response: %s", rawLocation) + } + + // Get the auth code or return the error from the server. + authCode := location.Query().Get("code") + if authCode == "" { + // Check for error response parameters. See https://openid.net/specs/openid-connect-core-1_0.html#AuthError. + requiredErrorCode := location.Query().Get("error") + optionalErrorDescription := location.Query().Get("error_description") + if optionalErrorDescription == "" { + return nil, fmt.Errorf("login failed with code %q", requiredErrorCode) + } + return nil, fmt.Errorf("login failed with code %q: %s", requiredErrorCode, optionalErrorDescription) + } + + // Exchange the authorization code for access, ID, and refresh tokens and perform required + // validations on the returned ID token. + tokenCtx, tokenCtxCancelFunc := context.WithTimeout(h.ctx, httpRequestTimeout) + defer tokenCtxCancelFunc() + token, err := h.getProvider(h.oauth2Config, h.provider, h.httpClient). + ExchangeAuthcodeAndValidateTokens( + tokenCtx, + authCode, + h.pkce, + h.nonce, + h.oauth2Config.RedirectURL, + ) + if err != nil { + return nil, fmt.Errorf("error during authorization code exchange: %w", err) + } + + return token, nil +} + +// Open a web browser, or ask the user to open a web browser, to visit the authorize endpoint. +// Create a localhost callback listener which exchanges the authcode for tokens. Return the tokens or an error. +func (h *handlerState) webBrowserBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (*oidctypes.Token, error) { // Open a TCP listener and update the OAuth2 redirect_uri to match (in case we are using an ephemeral port number). listener, err := net.Listen("tcp", h.listenAddr) if err != nil { @@ -307,18 +486,14 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { Path: h.callbackPath, }).String() + // Now that we have a redirect URL with the listener port, we can build the authorize URL. + authorizeURL := h.oauth2Config.AuthCodeURL(h.state.String(), *authorizeOptions...) + // Start a callback server in a background goroutine. shutdown := h.serve(listener) defer shutdown() // Open the authorize URL in the users browser. - authorizeURL := h.oauth2Config.AuthCodeURL( - h.state.String(), - oauth2.AccessTypeOffline, - h.nonce.Param(), - h.pkce.Challenge(), - h.pkce.Method(), - ) if err := h.openURL(authorizeURL); err != nil { return nil, fmt.Errorf("could not open browser: %w", err) } @@ -331,11 +506,48 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { if callback.err != nil { return nil, fmt.Errorf("error handling callback: %w", callback.err) } - h.cache.PutToken(cacheKey, callback.token) return callback.token, nil } } +func promptForValue(promptLabel string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.New("stdin is not connected to a terminal") + } + _, err := fmt.Fprint(os.Stderr, promptLabel) + if err != nil { + return "", fmt.Errorf("could not print prompt to stderr: %w", err) + } + text, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", fmt.Errorf("could read input from stdin: %w", err) + } + text = strings.TrimSpace(text) + return text, nil +} + +func promptForSecret(promptLabel string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.New("stdin is not connected to a terminal") + } + _, err := fmt.Fprint(os.Stderr, promptLabel) + if err != nil { + return "", fmt.Errorf("could not print prompt to stderr: %w", err) + } + password, err := term.ReadPassword(0) + if err != nil { + return "", fmt.Errorf("could not read password: %w", err) + } + // term.ReadPassword swallows the newline that was typed by the user, so to + // avoid the next line of output from happening on same line as the password + // prompt, we need to print a newline. + _, err = fmt.Fprint(os.Stderr, "\n") + if err != nil { + return "", fmt.Errorf("could not print newline to stderr: %w", err) + } + return string(password), err +} + func (h *handlerState) initOIDCDiscovery() error { // Make this method idempotent so it can be called in multiple cases with no extra network requests. if h.provider != nil { @@ -463,8 +675,11 @@ func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Req return httperr.New(http.StatusForbidden, "missing or invalid state parameter") } - // Check for error response parameters. + // Check for error response parameters. See https://openid.net/specs/openid-connect-core-1_0.html#AuthError. if errorParam := params.Get("error"); errorParam != "" { + if errorDescParam := params.Get("error_description"); errorDescParam != "" { + return httperr.Newf(http.StatusBadRequest, "login failed with code %q: %s", errorParam, errorDescParam) + } return httperr.Newf(http.StatusBadRequest, "login failed with code %q", errorParam) } diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index c3ac6609..d85c01ac 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -6,10 +6,13 @@ package oidcclient import ( "context" "encoding/json" + "errors" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -24,6 +27,7 @@ import ( "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" @@ -55,7 +59,7 @@ func (m *mockSessionCache) PutToken(key SessionCacheKey, token *oidctypes.Token) m.sawPutTokens = append(m.sawPutTokens, token) } -func TestLogin(t *testing.T) { +func TestLogin(t *testing.T) { // nolint:gocyclo time1 := time.Date(2035, 10, 12, 13, 14, 15, 16, time.UTC) time1Unix := int64(2075807775) require.Equal(t, time1Unix, time1.Add(2*time.Minute).Unix()) @@ -202,6 +206,51 @@ func TestLogin(t *testing.T) { require.NoError(t, json.NewEncoder(w).Encode(&response)) }) + defaultDiscoveryResponse := func(req *http.Request) (*http.Response, error) { // nolint:unparam + // Call the handler function from the test server to calculate the response. + handler, _ := providerMux.Handler(req) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder.Result(), nil + } + + defaultLDAPTestOpts := func(t *testing.T, h *handlerState, authResponse *http.Response, authError error) error { // nolint:unparam + h.generateState = func() (state.State, error) { return "test-state", nil } + h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil } + h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil } + h.promptForValue = func(promptLabel string) (string, error) { return "some-upstream-username", nil } + h.promptForSecret = func(promptLabel string) (string, error) { return "some-upstream-password", nil } + + cache := &mockSessionCache{t: t, getReturnsToken: nil} + cacheKey := SessionCacheKey{ + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + } + t.Cleanup(func() { + require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) + }) + require.NoError(t, WithSessionCache(cache)(h)) + require.NoError(t, WithCLISendingCredentials()(h)) + require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "ldap")(h)) + + require.NoError(t, WithClient(&http.Client{ + Transport: roundtripper.Func(func(req *http.Request) (*http.Response, error) { + switch req.URL.Scheme + "://" + req.URL.Host + req.URL.Path { + case "http://" + successServer.Listener.Addr().String() + "/.well-known/openid-configuration": + return defaultDiscoveryResponse(req) + case "http://" + successServer.Listener.Addr().String() + "/authorize": + return authResponse, authError + default: + require.FailNow(t, fmt.Sprintf("saw unexpected http call from the CLI: %s", req.URL.String())) + return nil, nil + } + }), + })(h)) + return nil + } + tests := []struct { name string opt func(t *testing.T) Option @@ -531,6 +580,357 @@ func TestLogin(t *testing.T) { wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, wantToken: &testToken, }, + { + name: "upstream name and type are included in authorize request if upstream name is provided", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + h.generateState = func() (state.State, error) { return "test-state", nil } + h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil } + h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil } + + cache := &mockSessionCache{t: t, getReturnsToken: nil} + cacheKey := SessionCacheKey{ + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + } + t.Cleanup(func() { + require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) + require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys) + require.Equal(t, []*oidctypes.Token{&testToken}, cache.sawPutTokens) + }) + require.NoError(t, WithSessionCache(cache)(h)) + require.NoError(t, WithClient(&http.Client{Timeout: 10 * time.Second})(h)) + require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "oidc")(h)) + + h.openURL = func(actualURL string) error { + parsedActualURL, err := url.Parse(actualURL) + require.NoError(t, err) + actualParams := parsedActualURL.Query() + + require.Contains(t, actualParams.Get("redirect_uri"), "http://127.0.0.1:") + actualParams.Del("redirect_uri") + + require.Equal(t, url.Values{ + // This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example: + // $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1 + // VVaezYqum7reIhoavCHD1n2d+piN3r/mywoYj7fCR7g + "code_challenge": []string{"VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g"}, + "code_challenge_method": []string{"S256"}, + "response_type": []string{"code"}, + "scope": []string{"test-scope"}, + "nonce": []string{"test-nonce"}, + "state": []string{"test-state"}, + "access_type": []string{"offline"}, + "client_id": []string{"test-client-id"}, + "pinniped_idp_name": []string{"some-upstream-name"}, + "pinniped_idp_type": []string{"oidc"}, + }, actualParams) + + parsedActualURL.RawQuery = "" + require.Equal(t, successServer.URL+"/authorize", parsedActualURL.String()) + + go func() { + h.callbacks <- callbackResult{token: &testToken} + }() + return nil + } + return nil + } + }, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantToken: &testToken, + }, + { + name: "ldap login when prompting for username returns an error", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + _ = defaultLDAPTestOpts(t, h, nil, nil) + h.promptForValue = func(promptLabel string) (string, error) { + require.Equal(t, "Username: ", promptLabel) + return "", errors.New("some prompt error") + } + return nil + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + _ = defaultLDAPTestOpts(t, h, nil, nil) + h.promptForSecret = func(promptLabel string) (string, error) { return "", errors.New("some prompt error") } + return nil + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + _ = defaultLDAPTestOpts(t, h, nil, nil) + require.NoError(t, WithClient(&http.Client{ + Transport: roundtripper.Func(func(req *http.Request) (*http.Response, error) { + switch req.URL.Scheme + "://" + req.URL.Host + req.URL.Path { + case "http://" + successServer.Listener.Addr().String() + "/.well-known/openid-configuration": + type providerJSON struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + JWKSURL string `json:"jwks_uri"` + } + jsonResponseBody, err := json.Marshal(&providerJSON{ + Issuer: successServer.URL, + AuthURL: "%", // this is not a legal URL! + TokenURL: successServer.URL + "/token", + JWKSURL: successServer.URL + "/keys", + }) + require.NoError(t, err) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"content-type": []string{"application/json"}}, + Body: ioutil.NopCloser(strings.NewReader(string(jsonResponseBody))), + }, nil + default: + require.FailNow(t, fmt.Sprintf("saw unexpected http call from the CLI: %s", req.URL.String())) + return nil, nil + } + }), + })(h)) + return nil + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + return defaultLDAPTestOpts(t, h, nil, errors.New("some error fetching authorize endpoint")) + } + }, + 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`, + }, + { + name: "ldap login when the OIDC provider authorization endpoint returns something other than a 302 redirect", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + return defaultLDAPTestOpts(t, h, &http.Response{StatusCode: http.StatusBadGateway, Status: "502 Bad Gateway"}, nil) + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + return defaultLDAPTestOpts(t, h, &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{ + "http://127.0.0.1:0/callback?error=access_denied&error_description=optional-error-description&state=test-state", + }}, + }, nil) + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + return defaultLDAPTestOpts(t, h, &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{ + "http://other-server.example.com/callback?code=foo&state=test-state", + }}, + }, nil) + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + return defaultLDAPTestOpts(t, h, &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{ + "http://127.0.0.1:0/callback?error=access_denied&state=test-state", + }}, + }, nil) + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + return defaultLDAPTestOpts(t, h, &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{"http://127.0.0.1:0/callback?code=foo&state=wrong-state"}}, + }, nil) + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + fakeAuthCode := "test-authcode-value" + _ = defaultLDAPTestOpts(t, h, &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{ + fmt.Sprintf("http://127.0.0.1:0/callback?code=%s&state=test-state", fakeAuthCode), + }}, + }, nil) + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + mock := mockUpstream(t) + mock.EXPECT(). + ExchangeAuthcodeAndValidateTokens( + gomock.Any(), fakeAuthCode, pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), "http://127.0.0.1:0/callback"). + Return(nil, errors.New("some authcode exchange or token validation error")) + return mock + } + return nil + } + }, + 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", + clientID: "test-client-id", + opt: func(t *testing.T) Option { + return func(h *handlerState) error { + fakeAuthCode := "test-authcode-value" + + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + mock := mockUpstream(t) + mock.EXPECT(). + ExchangeAuthcodeAndValidateTokens( + gomock.Any(), fakeAuthCode, pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), "http://127.0.0.1:0/callback"). + Return(&testToken, nil) + return mock + } + + h.generateState = func() (state.State, error) { return "test-state", nil } + h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil } + h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil } + h.promptForValue = func(promptLabel string) (string, error) { + require.Equal(t, "Username: ", promptLabel) + return "some-upstream-username", nil + } + h.promptForSecret = func(promptLabel string) (string, error) { + require.Equal(t, "Password: ", promptLabel) + return "some-upstream-password", nil + } + + cache := &mockSessionCache{t: t, getReturnsToken: nil} + cacheKey := SessionCacheKey{ + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + } + t.Cleanup(func() { + require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) + require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys) + require.Equal(t, []*oidctypes.Token{&testToken}, cache.sawPutTokens) + }) + require.NoError(t, WithSessionCache(cache)(h)) + require.NoError(t, WithCLISendingCredentials()(h)) + require.NoError(t, WithUpstreamIdentityProvider("some-upstream-name", "ldap")(h)) + + discoveryRequestWasMade := false + authorizeRequestWasMade := false + t.Cleanup(func() { + require.True(t, discoveryRequestWasMade, "should have made an discovery request") + require.True(t, authorizeRequestWasMade, "should have made an authorize request") + }) + + require.NoError(t, WithClient(&http.Client{ + Transport: roundtripper.Func(func(req *http.Request) (*http.Response, error) { + switch req.URL.Scheme + "://" + req.URL.Host + req.URL.Path { + case "http://" + successServer.Listener.Addr().String() + "/.well-known/openid-configuration": + discoveryRequestWasMade = true + return defaultDiscoveryResponse(req) + case "http://" + successServer.Listener.Addr().String() + "/authorize": + authorizeRequestWasMade = true + require.Equal(t, "some-upstream-username", req.Header.Get("Pinniped-Username")) + require.Equal(t, "some-upstream-password", req.Header.Get("Pinniped-Password")) + require.Equal(t, url.Values{ + // This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example: + // $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1 + // VVaezYqum7reIhoavCHD1n2d+piN3r/mywoYj7fCR7g + "code_challenge": []string{"VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g"}, + "code_challenge_method": []string{"S256"}, + "response_type": []string{"code"}, + "scope": []string{"test-scope"}, + "nonce": []string{"test-nonce"}, + "state": []string{"test-state"}, + "access_type": []string{"offline"}, + "client_id": []string{"test-client-id"}, + "redirect_uri": []string{"http://127.0.0.1:0/callback"}, + "pinniped_idp_name": []string{"some-upstream-name"}, + "pinniped_idp_type": []string{"ldap"}, + }, req.URL.Query()) + return &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{ + fmt.Sprintf("http://127.0.0.1:0/callback?code=%s&state=test-state", fakeAuthCode), + }}, + }, nil + default: + // Note that "/token" requests should not be made. They are mocked by mocking calls to ExchangeAuthcodeAndValidateTokens(). + require.FailNow(t, fmt.Sprintf("saw unexpected http call from the CLI: %s", req.URL.String())) + return nil, nil + } + }), + })(h)) + return nil + } + }, + issuer: successServer.URL, + wantLogs: []string{"\"level\"=4 \"msg\"=\"Pinniped: Performing OIDC discovery\" \"issuer\"=\"" + successServer.URL + "\""}, + wantToken: &testToken, + }, { name: "with requested audience, session cache hit with valid token, but discovery fails", clientID: "test-client-id", @@ -954,6 +1354,12 @@ func TestHandleAuthCodeCallback(t *testing.T) { wantErr: `login failed with code "some_error"`, wantHTTPStatus: http.StatusBadRequest, }, + { + name: "error code with a description from provider", + query: "state=test-state&error=some_error&error_description=optional%20error%20description", + wantErr: `login failed with code "some_error": optional error description`, + wantHTTPStatus: http.StatusBadRequest, + }, { name: "invalid code", query: "state=test-state&code=invalid", diff --git a/test/deploy/tools/cert-issuer.yaml b/test/deploy/tools/cert-issuer.yaml index c1c39ee8..e044b1e7 100644 --- a/test/deploy/tools/cert-issuer.yaml +++ b/test/deploy/tools/cert-issuer.yaml @@ -76,20 +76,31 @@ spec: /tmp/csr.json \ | cfssljson -bare dex + # Cheat and add 127.0.0.1 as an IP SAN so we can use the ldaps port through port forwarding. echo "generating LDAP server certificate..." cfssl gencert \ -ca ca.pem -ca-key ca-key.pem \ -config /tmp/cfssl-default.json \ -profile www \ -cn "ldap.tools.svc.cluster.local" \ - -hostname "ldap.tools.svc.cluster.local" \ + -hostname "ldap.tools.svc.cluster.local,127.0.0.1" \ /tmp/csr.json \ | cfssljson -bare ldap chmod -R 777 /var/certs + echo echo "generated certificates:" ls -l /var/certs + echo + echo "CA cert..." + cat ca.pem | openssl x509 -text + echo + echo "Dex cert..." + cat dex.pem | openssl x509 -text + echo + echo "LDAP cert..." + cat ldap.pem | openssl x509 -text volumeMounts: - name: certs mountPath: /var/certs @@ -100,8 +111,8 @@ spec: args: - -c - | - kubectl get secrets -n tools certs -o jsonpath='created: {.metadata.creationTimestamp}' || \ - kubectl create secret generic -n tools certs --from-file=/var/certs + kubectl create secret generic -n tools certs --from-file=/var/certs \ + --dry-run=client --output yaml | kubectl apply -f - volumeMounts: - name: certs mountPath: /var/certs diff --git a/test/deploy/tools/ldap.yaml b/test/deploy/tools/ldap.yaml new file mode 100644 index 00000000..affb7c61 --- /dev/null +++ b/test/deploy/tools/ldap.yaml @@ -0,0 +1,233 @@ +#! Copyright 2021 the Pinniped contributors. All Rights Reserved. +#! SPDX-License-Identifier: Apache-2.0 + +#@ load("@ytt:data", "data") +#@ load("@ytt:sha256", "sha256") +#@ load("@ytt:yaml", "yaml") + +#@ def ldapLIDIF(): +#@yaml/text-templated-strings +ldap.ldif: | + # ** CAUTION: Blank lines separate entries in the LDIF format! Do not remove them! *** + # Here's a good explanation of LDIF: + # https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system + + # pinniped.dev (organization, root) + dn: dc=pinniped,dc=dev + objectClass: dcObject + objectClass: organization + dc: pinniped + o: example + + # users, pinniped.dev (organization unit) + dn: ou=users,dc=pinniped,dc=dev + objectClass: organizationalUnit + ou: users + + # groups, pinniped.dev (organization unit) + dn: ou=groups,dc=pinniped,dc=dev + objectClass: organizationalUnit + ou: groups + + # beach-groups, groups, pinniped.dev (organization unit) + dn: ou=beach-groups,ou=groups,dc=pinniped,dc=dev + objectClass: organizationalUnit + ou: beach-groups + + # pinny, users, pinniped.dev (user) + dn: cn=pinny,ou=users,dc=pinniped,dc=dev + objectClass: inetOrgPerson + objectClass: posixAccount + objectClass: shadowAccount + cn: pinny + sn: Seal + givenName: Pinny + mail: pinny.ldap@example.com + userPassword: (@= data.values.pinny_ldap_password @) + uid: pinny + uidNumber: 1000 + gidNumber: 1000 + homeDirectory: /home/pinny + loginShell: /bin/bash + gecos: pinny-the-seal + + # wally, users, pinniped.dev (user without password) + dn: cn=wally,ou=users,dc=pinniped,dc=dev + objectClass: inetOrgPerson + objectClass: posixAccount + objectClass: shadowAccount + cn: wally + sn: Walrus + givenName: Wally + mail: wally.ldap@example.com + mail: wally.alternate@example.com + uid: wally + uidNumber: 1001 + gidNumber: 1001 + homeDirectory: /home/wally + loginShell: /bin/bash + gecos: wally-the-walrus + + # olive, users, pinniped.dev (user without password) + dn: cn=olive,ou=users,dc=pinniped,dc=dev + objectClass: inetOrgPerson + objectClass: posixAccount + objectClass: shadowAccount + cn: olive + sn: Boston Terrier + givenName: Olive + mail: olive.ldap@example.com + uid: olive + uidNumber: 1002 + gidNumber: 1002 + homeDirectory: /home/olive + loginShell: /bin/bash + gecos: olive-the-dog + + # ball-game-players, beach-groups, groups, pinniped.dev (group of users) + dn: cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev + cn: ball-game-players + objectClass: groupOfNames + member: cn=pinny,ou=users,dc=pinniped,dc=dev + member: cn=olive,ou=users,dc=pinniped,dc=dev + + # seals, groups, pinniped.dev (group of users) + dn: cn=seals,ou=groups,dc=pinniped,dc=dev + cn: seals + objectClass: groupOfNames + member: cn=pinny,ou=users,dc=pinniped,dc=dev + + # walruses, groups, pinniped.dev (group of users) + dn: cn=walruses,ou=groups,dc=pinniped,dc=dev + cn: walruses + objectClass: groupOfNames + member: cn=wally,ou=users,dc=pinniped,dc=dev + + # pinnipeds, users, pinniped.dev (group of groups) + dn: cn=pinnipeds,ou=groups,dc=pinniped,dc=dev + cn: pinnipeds + objectClass: groupOfNames + member: cn=seals,ou=groups,dc=pinniped,dc=dev + member: cn=walruses,ou=groups,dc=pinniped,dc=dev + + # mammals, groups, pinniped.dev (group of both groups and users) + dn: cn=mammals,ou=groups,dc=pinniped,dc=dev + cn: mammals + objectClass: groupOfNames + member: cn=pinninpeds,ou=groups,dc=pinniped,dc=dev + member: cn=olive,ou=users,dc=pinniped,dc=dev +#@ end + +--- +apiVersion: v1 +kind: Secret +metadata: + name: ldap-ldif-files + namespace: tools +type: Opaque +stringData: #@ ldapLIDIF() +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ldap + namespace: tools + labels: + app: ldap +spec: + replicas: 1 + selector: + matchLabels: + app: ldap + template: + metadata: + labels: + app: ldap + annotations: + #! Cause the pod to get recreated whenever the LDIF file changes. + ldifConfigHash: #@ sha256.sum(yaml.encode(ldapLIDIF())) + spec: + containers: + - name: ldap + image: docker.io/bitnami/openldap + imagePullPolicy: Always + ports: + - name: ldap + containerPort: 1389 + - name: ldaps + containerPort: 1636 + resources: + requests: + cpu: "100m" #! one-tenth of one CPU + memory: "64Mi" + limits: + cpu: "200m" #! slapd needs a reasonable amount of CPU during initial startup or else it is slow to start + memory: "64Mi" + readinessProbe: + tcpSocket: + port: ldap + initialDelaySeconds: 2 + timeoutSeconds: 90 + periodSeconds: 2 + failureThreshold: 9 + env: + #! Example ldapsearch commands that can be run from within the container based on these env vars. + #! These will print the whole LDAP tree starting at our root. + #! ldapsearch -x -H 'ldap://ldap.tools.svc.cluster.local' -D 'cn=admin,dc=pinniped,dc=dev' -w password -b 'dc=pinniped,dc=dev' + #! LDAPTLS_CACERT=/var/certs/ca.pem ldapsearch -x -H 'ldaps://ldap.tools.svc.cluster.local' -D 'cn=admin,dc=pinniped,dc=dev' -w password -b 'dc=pinniped,dc=dev' + - name: BITNAMI_DEBUG + value: "true" + - name: LDAP_ADMIN_USERNAME + value: "admin" + - name: LDAP_ADMIN_PASSWORD + value: "password" #! ok to hardcode: the LDAP server will not be available from outside the cluster + - name: LDAP_ENABLE_TLS + value: "yes" + - name: LDAP_TLS_CERT_FILE + value: "/var/certs/ldap.pem" + - name: LDAP_TLS_KEY_FILE + value: "/var/certs/ldap-key.pem" + - name: LDAP_TLS_CA_FILE + value: "/var/certs/ca.pem" + #! Note that the custom LDIF file is only read at pod start-up time. + - name: LDAP_CUSTOM_LDIF_DIR + value: "/var/ldifs" + #! Seems like LDAP_ROOT is still required when using LDAP_CUSTOM_LDIF_DIR because it effects the admin user. + #! Presumably this needs to match the root that we create in the LDIF file. + - name: LDAP_ROOT + value: "dc=pinniped,dc=dev" + volumeMounts: + - name: certs + mountPath: /var/certs + readOnly: true + - name: ldifs + mountPath: /var/ldifs + readOnly: true + volumes: + - name: certs + secret: + secretName: certs + - name: ldifs + secret: + secretName: ldap-ldif-files +--- +apiVersion: v1 +kind: Service +metadata: + name: ldap + namespace: tools + labels: + app: ldap +spec: + type: ClusterIP + selector: + app: ldap + ports: + - protocol: TCP + port: 389 + targetPort: 1389 + name: ldap + - protocol: TCP + port: 636 + targetPort: 1636 + name: ldaps diff --git a/test/deploy/tools/proxy.yaml b/test/deploy/tools/proxy.yaml index 3a70b3dd..ae293a8a 100644 --- a/test/deploy/tools/proxy.yaml +++ b/test/deploy/tools/proxy.yaml @@ -32,10 +32,10 @@ spec: containerPort: 3128 resources: requests: - cpu: "10m" + cpu: "100m" #! one-tenth of one CPU memory: "64Mi" limits: - cpu: "10m" + cpu: "100m" #! one-tenth of one CPU memory: "64Mi" volumeMounts: - name: log-dir diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index e840b34d..f580fe09 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -192,9 +192,9 @@ func TestCLILoginOIDC(t *testing.T) { require.NoError(t, err) claims := map[string]interface{}{} require.NoError(t, json.Unmarshal(jws.UnsafePayloadWithoutVerification(), &claims)) - require.Equal(t, env.CLITestUpstream.Issuer, claims["iss"]) - require.Equal(t, env.CLITestUpstream.ClientID, claims["aud"]) - require.Equal(t, env.CLITestUpstream.Username, claims["email"]) + require.Equal(t, env.CLIUpstreamOIDC.Issuer, claims["iss"]) + require.Equal(t, env.CLIUpstreamOIDC.ClientID, claims["aud"]) + require.Equal(t, env.CLIUpstreamOIDC.Username, claims["email"]) require.NotEmpty(t, claims["nonce"]) // Run the CLI again with the same session cache and login parameters. @@ -215,10 +215,10 @@ func TestCLILoginOIDC(t *testing.T) { t.Logf("overwriting cache to remove valid ID token") cache := filesession.New(sessionCachePath) cacheKey := oidcclient.SessionCacheKey{ - Issuer: env.CLITestUpstream.Issuer, - ClientID: env.CLITestUpstream.ClientID, + Issuer: env.CLIUpstreamOIDC.Issuer, + ClientID: env.CLIUpstreamOIDC.ClientID, Scopes: []string{"email", "offline_access", "openid", "profile"}, - RedirectURI: strings.ReplaceAll(env.CLITestUpstream.CallbackURL, "127.0.0.1", "localhost"), + RedirectURI: strings.ReplaceAll(env.CLIUpstreamOIDC.CallbackURL, "127.0.0.1", "localhost"), } cached := cache.GetToken(cacheKey) require.NotNil(t, cached) @@ -352,11 +352,11 @@ func runPinnipedLoginOIDC( require.NoError(t, page.Navigate(loginURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.CLITestUpstream) + browsertest.LoginToUpstream(t, page, env.CLIUpstreamOIDC) // Expect to be redirected to the localhost callback. t.Logf("waiting for redirect to callback") - callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(env.CLITestUpstream.CallbackURL) + `\?.+\z`) + callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(env.CLIUpstreamOIDC.CallbackURL) + `\?.+\z`) browsertest.WaitForURL(t, page, callbackURLPattern) // Wait for the "pre" element that gets rendered for a `text/plain` page, and @@ -402,11 +402,11 @@ func spawnTestGoroutine(t *testing.T, f func() error) { func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, sessionCachePath string) *exec.Cmd { env := library.IntegrationEnv(t) - callbackURL, err := url.Parse(env.CLITestUpstream.CallbackURL) + callbackURL, err := url.Parse(env.CLIUpstreamOIDC.CallbackURL) require.NoError(t, err) cmd := exec.CommandContext(ctx, pinnipedExe, "login", "oidc", - "--issuer", env.CLITestUpstream.Issuer, - "--client-id", env.CLITestUpstream.ClientID, + "--issuer", env.CLIUpstreamOIDC.Issuer, + "--client-id", env.CLIUpstreamOIDC.ClientID, "--scopes", "offline_access,openid,email,profile", "--listen-port", callbackURL.Port(), "--session-cache", sessionCachePath, @@ -415,9 +415,9 @@ func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, ses ) // If there is a custom CA bundle, pass it via --ca-bundle and a temporary file. - if env.CLITestUpstream.CABundle != "" { + if env.CLIUpstreamOIDC.CABundle != "" { path := filepath.Join(testutil.TempDir(t), "test-ca.pem") - require.NoError(t, ioutil.WriteFile(path, []byte(env.CLITestUpstream.CABundle), 0600)) + require.NoError(t, ioutil.WriteFile(path, []byte(env.CLIUpstreamOIDC.CABundle), 0600)) cmd.Args = append(cmd.Args, "--ca-bundle", path) } diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 20b86347..8931859d 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -16,6 +16,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/pointer" auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" @@ -147,7 +148,7 @@ func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthentic require.Empty(t, response.Spec) require.Nil(t, response.Status.Credential) - require.Equal(t, stringPtr("authentication failed"), response.Status.Message) + require.Equal(t, pointer.StringPtr("authentication failed"), response.Status.Message) } func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T) { @@ -177,10 +178,6 @@ func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T require.Nil(t, response.Status.Credential) } -func stringPtr(s string) *string { - return &s -} - func getCommonName(t *testing.T, certPEM string) string { t.Helper() diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index 118bf324..faf44e95 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -1040,7 +1040,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl dialer.Proxy = func(req *http.Request) (*url.URL, error) { proxyURL, err := url.Parse(env.Proxy) require.NoError(t, err) - t.Logf("passing request for %s through proxy %s", req.URL, proxyURL.String()) + t.Logf("passing request for %s through proxy %s", library.RedactURLParams(req.URL), proxyURL.String()) return proxyURL, nil } } @@ -1119,7 +1119,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl httpTransport.Proxy = func(req *http.Request) (*url.URL, error) { proxyURL, err := url.Parse(env.Proxy) require.NoError(t, err) - t.Logf("passing request for %s through proxy %s", req.URL, proxyURL.String()) + t.Logf("passing request for %s through proxy %s", library.RedactURLParams(req.URL), proxyURL.String()) return proxyURL, nil } } @@ -1445,7 +1445,7 @@ func kubeconfigProxyFunc(t *testing.T, squidProxyURL string) func(req *http.Requ parsedSquidProxyURL, err := url.Parse(squidProxyURL) require.NoError(t, err) - t.Logf("passing request for %s through proxy %s", req.URL, parsedSquidProxyURL.String()) + t.Logf("passing request for %s through proxy %s", library.RedactURLParams(req.URL), parsedSquidProxyURL.String()) return parsedSquidProxyURL, nil } } diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index be1220d6..d2fc5e46 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "errors" "fmt" + "io" "io/ioutil" "net/url" "os" @@ -21,7 +22,10 @@ import ( "testing" "time" + "go.pinniped.dev/pkg/oidcclient/oidctypes" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/creack/pty" "github.com/stretchr/testify/require" authorizationv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" @@ -33,6 +37,7 @@ import ( idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient" @@ -56,7 +61,7 @@ func TestE2EFullIntegration(t *testing.T) { page := browsertest.Open(t) // Infer the downstream issuer URL from the callback associated with the upstream test client registration. - issuerURL, err := url.Parse(env.SupervisorTestUpstream.CallbackURL) + issuerURL, err := url.Parse(env.SupervisorUpstreamOIDC.CallbackURL) require.NoError(t, err) require.True(t, strings.HasSuffix(issuerURL.Path, "/callback")) issuerURL.Path = strings.TrimSuffix(issuerURL.Path, "/callback") @@ -69,7 +74,7 @@ func TestE2EFullIntegration(t *testing.T) { // Save that bundle plus the one that signs the upstream issuer, for test purposes. testCABundlePath := filepath.Join(tempDir, "test-ca.pem") - testCABundlePEM := []byte(string(ca.Bundle()) + "\n" + env.SupervisorTestUpstream.CABundle) + testCABundlePEM := []byte(string(ca.Bundle()) + "\n" + env.SupervisorUpstreamOIDC.CABundle) testCABundleBase64 := base64.StdEncoding.EncodeToString(testCABundlePEM) require.NoError(t, ioutil.WriteFile(testCABundlePath, testCABundlePEM, 0600)) @@ -95,24 +100,6 @@ func TestE2EFullIntegration(t *testing.T) { configv1alpha1.SuccessFederationDomainStatusCondition, ) - // Create upstream OIDC provider and wait for it to become ready. - library.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ - Issuer: env.SupervisorTestUpstream.Issuer, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)), - }, - AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ - AdditionalScopes: env.SupervisorTestUpstream.AdditionalScopes, - }, - Claims: idpv1alpha1.OIDCClaims{ - Username: env.SupervisorTestUpstream.UsernameClaim, - Groups: env.SupervisorTestUpstream.GroupsClaim, - }, - Client: idpv1alpha1.OIDCClient{ - SecretName: library.CreateClientCredsSecret(t, env.SupervisorTestUpstream.ClientID, env.SupervisorTestUpstream.ClientSecret).Name, - }, - }, idpv1alpha1.PhaseReady) - // Create a JWTAuthenticator that will validate the tokens from the downstream issuer. clusterAudience := "test-cluster-" + library.RandHex(t, 8) authenticator := library.CreateTestJWTAuthenticator(ctx, t, authv1alpha.JWTAuthenticatorSpec{ @@ -121,158 +108,312 @@ func TestE2EFullIntegration(t *testing.T) { TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64}, }) - // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. - library.CreateTestClusterRoleBinding(t, - rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: env.SupervisorTestUpstream.Username}, - rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, - ) - library.WaitForUserToHaveAccess(t, env.SupervisorTestUpstream.Username, []string{}, &authorizationv1.ResourceAttributes{ - Verb: "get", - Group: "", - Version: "v1", - Resource: "namespaces", + // Add an OIDC upstream IDP and try using it to authenticate during kubectl commands. + t.Run("with Supervisor OIDC upstream IDP", func(t *testing.T) { + expectedUsername := env.SupervisorUpstreamOIDC.Username + expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups + + // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. + library.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, + ) + library.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "namespaces", + }) + + // Create upstream OIDC provider and wait for it to become ready. + library.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: env.SupervisorUpstreamOIDC.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), + }, + AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, + }, + Claims: idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: library.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/oidc-test-sessions.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + start := time.Now() + kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + stderrPipe, err := kubectlCmd.StderrPipe() + require.NoError(t, err) + stdoutPipe, err := kubectlCmd.StdoutPipe() + require.NoError(t, err) + + t.Logf("starting kubectl subprocess") + require.NoError(t, kubectlCmd.Start()) + t.Cleanup(func() { + err := kubectlCmd.Wait() + t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) + stdout, stdoutErr := ioutil.ReadAll(stdoutPipe) + if stdoutErr != nil { + stdout = []byte("") + } + stderr, stderrErr := ioutil.ReadAll(stderrPipe) + if stderrErr != nil { + stderr = []byte("") + } + require.NoErrorf(t, err, "kubectl process did not exit cleanly, stdout/stderr: %q/%q", string(stdout), string(stderr)) + }) + + // 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) { + defer func() { + closeErr := stderrPipe.Close() + if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { + return + } + if err == nil { + err = fmt.Errorf("stderr stream closed with error: %w", closeErr) + } + }() + + reader := bufio.NewReader(library.NewLoggerReader(t, "stderr", stderrPipe)) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("could not read login URL line from stderr: %w", err) + } + const prompt = "Please log in: " + if !strings.HasPrefix(line, prompt) { + return fmt.Errorf("expected %q to have prefix %q", line, prompt) + } + loginURLChan <- strings.TrimPrefix(line, prompt) + return readAndExpectEmpty(reader) + }) + + // Start a background goroutine to read stdout from kubectl and return the result as a string. + kubectlOutputChan := make(chan string) + spawnTestGoroutine(t, func() (err error) { + defer func() { + closeErr := stdoutPipe.Close() + if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { + return + } + if err == nil { + err = fmt.Errorf("stdout stream closed with error: %w", closeErr) + } + }() + output, err := ioutil.ReadAll(stdoutPipe) + if err != nil { + return err + } + t.Logf("kubectl output:\n%s\n", output) + kubectlOutputChan <- string(output) + return nil + }) + + // Wait for the CLI to print out the login URL and open the browser to it. + t.Logf("waiting for CLI to output login URL") + var loginURL string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for login URL") + case loginURL = <-loginURLChan: + } + t.Logf("navigating to login page") + require.NoError(t, page.Navigate(loginURL)) + + // Expect to be redirected to the upstream provider and log in. + browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + + // Expect to be redirected to the localhost callback. + t.Logf("waiting for redirect to callback") + browsertest.WaitForURL(t, page, regexp.MustCompile(`\Ahttp://127\.0\.0\.1:[0-9]+/callback\?.+\z`)) + + // Wait for the "pre" element that gets rendered for a `text/plain` page, and + // assert that it contains the success message. + t.Logf("verifying success page") + browsertest.WaitForVisibleElements(t, page, "pre") + msg, err := page.First("pre").Text() + require.NoError(t, err) + require.Equal(t, "you have been logged in and may now close this tab", msg) + + // Expect the CLI to output a list of namespaces in JSON format. + t.Logf("waiting for kubectl to output namespace list JSON") + var kubectlOutput string + select { + case <-time.After(10 * time.Second): + require.Fail(t, "timed out waiting for kubectl output") + case kubectlOutput = <-kubectlOutputChan: + } + require.Greaterf(t, len(strings.Split(kubectlOutput, "\n")), 2, "expected some namespaces to be returned, got %q", kubectlOutput) + t.Logf("first kubectl command took %s", time.Since(start).String()) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env, + downstream, + kubeconfigPath, + sessionCachePath, + pinnipedExe, + expectedUsername, + expectedGroups, + ) }) - // Use a specific session cache for this test. - sessionCachePath := tempDir + "/sessions.yaml" + // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands. + t.Run("with Supervisor LDAP upstream IDP", func(t *testing.T) { + expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue + expectedGroups := []string{} // LDAP groups are not implemented yet - // Run "pinniped get kubeconfig" to get a kubeconfig YAML. - kubeconfigYAML, stderr := runPinnipedCLI(t, nil, pinnipedExe, "get", "kubeconfig", - "--concierge-api-group-suffix", env.APIGroupSuffix, - "--concierge-authenticator-type", "jwt", - "--concierge-authenticator-name", authenticator.Name, - "--oidc-skip-browser", - "--oidc-ca-bundle", testCABundlePath, - "--oidc-session-cache", sessionCachePath, - ) - t.Logf("stderr output from 'pinniped get kubeconfig':\n%s\n\n", stderr) - t.Logf("test kubeconfig:\n%s\n\n", kubeconfigYAML) + // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. + library.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, + ) + library.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "namespaces", + }) - restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML) - require.NotNil(t, restConfig.ExecProvider) - require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2]) - kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml") - require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600)) + // Put the bind service account's info into a Secret. + bindSecret := library.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", corev1.SecretTypeBasicAuth, + map[string]string{ + corev1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, + corev1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, + }, + ) - // Run "kubectl get namespaces" which should trigger a browser login via the plugin. - start := time.Now() + // Create upstream LDAP provider and wait for it to become ready. + library.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ + Host: env.SupervisorUpstreamLDAP.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), + }, + Bind: idpv1alpha1.LDAPIdentityProviderBind{ + SecretName: bindSecret.Name, + }, + UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ + Base: env.SupervisorUpstreamLDAP.UserSearchBase, + Filter: "", + Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ + Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, + UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, + }, + }, + }, idpv1alpha1.LDAPPhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/ldap-test-sessions.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin. + start := time.Now() + kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + ptyFile, err := pty.Start(kubectlCmd) + require.NoError(t, err) + + // Wait for the subprocess to print the username prompt, then type the user's username. + readFromFileUntilStringIsSeen(t, ptyFile, "Username: ") + _, err = ptyFile.WriteString(expectedUsername + "\n") + require.NoError(t, err) + + // Wait for the subprocess to print the password prompt, then type the user's password. + readFromFileUntilStringIsSeen(t, ptyFile, "Password: ") + _, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n") + require.NoError(t, err) + + // Read all of the remaining output from the subprocess until EOF. + remainingOutput, _ := ioutil.ReadAll(ptyFile) + // Ignore any errors returned because there is always an error on linux. + require.Greaterf(t, len(remainingOutput), 0, "expected to get some more output from the kubectl subcommand, but did not") + require.Greaterf(t, len(strings.Split(string(remainingOutput), "\n")), 2, "expected some namespaces to be returned, got %q", string(remainingOutput)) + t.Logf("first kubectl command took %s", time.Since(start).String()) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env, + downstream, + kubeconfigPath, + sessionCachePath, + pinnipedExe, + expectedUsername, + expectedGroups, + ) + }) +} + +func readFromFileUntilStringIsSeen(t *testing.T, f *os.File, until string) { + readFromFile := "" + + library.RequireEventuallyWithoutError(t, func() (bool, error) { + someOutput, foundEOF := readAvailableOutput(t, f) + readFromFile += someOutput + if strings.Contains(readFromFile, until) { + return true, nil // found it! finished. + } + if foundEOF { + return false, fmt.Errorf("reached EOF of subcommand's output without seeing expected string %q", until) + } + return false, nil // keep waiting and reading + }, 1*time.Minute, 1*time.Second) +} + +func readAvailableOutput(t *testing.T, r io.Reader) (string, bool) { + buf := make([]byte, 1024) + n, err := r.Read(buf) + if err != nil { + if err == io.EOF { + return string(buf[:n]), true + } + require.NoError(t, err) + } + return string(buf[:n]), false +} + +func requireUserCanUseKubectlWithoutAuthenticatingAgain( + ctx context.Context, + t *testing.T, + env *library.TestEnv, + downstream *configv1alpha1.FederationDomain, + kubeconfigPath string, + sessionCachePath string, + pinnipedExe string, + expectedUsername string, + expectedGroups []string, +) { + // Run kubectl, which should work without any prompting for authentication. kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) - stderrPipe, err := kubectlCmd.StderrPipe() - require.NoError(t, err) - stdoutPipe, err := kubectlCmd.StdoutPipe() - require.NoError(t, err) - - t.Logf("starting kubectl subprocess") - require.NoError(t, kubectlCmd.Start()) - t.Cleanup(func() { - err := kubectlCmd.Wait() - t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) - stdout, stdoutErr := ioutil.ReadAll(stdoutPipe) - if stdoutErr != nil { - stdout = []byte("") - } - stderr, stderrErr := ioutil.ReadAll(stderrPipe) - if stderrErr != nil { - stderr = []byte("") - } - require.NoErrorf(t, err, "kubectl process did not exit cleanly, stdout/stderr: %q/%q", string(stdout), string(stderr)) - }) - - // 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) { - defer func() { - closeErr := stderrPipe.Close() - if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { - return - } - if err == nil { - err = fmt.Errorf("stderr stream closed with error: %w", closeErr) - } - }() - - reader := bufio.NewReader(library.NewLoggerReader(t, "stderr", stderrPipe)) - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("could not read login URL line from stderr: %w", err) - } - const prompt = "Please log in: " - if !strings.HasPrefix(line, prompt) { - return fmt.Errorf("expected %q to have prefix %q", line, prompt) - } - loginURLChan <- strings.TrimPrefix(line, prompt) - return readAndExpectEmpty(reader) - }) - - // Start a background goroutine to read stdout from kubectl and return the result as a string. - kubectlOutputChan := make(chan string) - spawnTestGoroutine(t, func() (err error) { - defer func() { - closeErr := stdoutPipe.Close() - if closeErr == nil || errors.Is(closeErr, os.ErrClosed) { - return - } - if err == nil { - err = fmt.Errorf("stdout stream closed with error: %w", closeErr) - } - }() - output, err := ioutil.ReadAll(stdoutPipe) - if err != nil { - return err - } - t.Logf("kubectl output:\n%s\n", output) - kubectlOutputChan <- string(output) - return nil - }) - - // Wait for the CLI to print out the login URL and open the browser to it. - t.Logf("waiting for CLI to output login URL") - var loginURL string - select { - case <-time.After(1 * time.Minute): - require.Fail(t, "timed out waiting for login URL") - case loginURL = <-loginURLChan: - } - t.Logf("navigating to login page") - require.NoError(t, page.Navigate(loginURL)) - - // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorTestUpstream) - - // Expect to be redirected to the localhost callback. - t.Logf("waiting for redirect to callback") - browsertest.WaitForURL(t, page, regexp.MustCompile(`\Ahttp://127\.0\.0\.1:[0-9]+/callback\?.+\z`)) - - // Wait for the "pre" element that gets rendered for a `text/plain` page, and - // assert that it contains the success message. - t.Logf("verifying success page") - browsertest.WaitForVisibleElements(t, page, "pre") - msg, err := page.First("pre").Text() - require.NoError(t, err) - require.Equal(t, "you have been logged in and may now close this tab", msg) - - // Expect the CLI to output a list of namespaces in JSON format. - t.Logf("waiting for kubectl to output namespace list JSON") - var kubectlOutput string - select { - case <-time.After(10 * time.Second): - require.Fail(t, "timed out waiting for kubectl output") - case kubectlOutput = <-kubectlOutputChan: - } - require.Greaterf(t, len(strings.Split(kubectlOutput, "\n")), 2, "expected some namespaces to be returned, got %q", kubectlOutput) - t.Logf("first kubectl command took %s", time.Since(start).String()) - - // Run kubectl again, which should work with no browser interaction. - kubectlCmd2 := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath) - kubectlCmd2.Env = append(os.Environ(), env.ProxyEnv()...) - start = time.Now() - kubectlOutput2, err := kubectlCmd2.CombinedOutput() + startTime := time.Now() + kubectlOutput2, err := kubectlCmd.CombinedOutput() require.NoError(t, err) require.Greaterf(t, len(bytes.Split(kubectlOutput2, []byte("\n"))), 2, "expected some namespaces to be returned again") - t.Logf("second kubectl command took %s", time.Since(start).String()) + t.Logf("second kubectl command took %s", time.Since(startTime).String()) - // probe our cache for the current ID token as a proxy for a whoami API + // Probe our cache for the current ID token as a proxy for a whoami API. cache := filesession.New(sessionCachePath, filesession.WithErrorReporter(func(err error) { require.NoError(t, err) })) @@ -287,15 +428,78 @@ func TestE2EFullIntegration(t *testing.T) { }) require.NotNil(t, token) + requireGCAnnotationsOnSessionStorage(ctx, t, env.SupervisorNamespace, startTime, token) + + idTokenClaims := token.IDToken.Claims + require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim]) + + // The groups claim in the file ends up as an []interface{}, so adjust our expectation to match. + expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups)) + for _, g := range expectedGroups { + expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g) + } + require.Equal(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim]) + + expectedYAMLGroups := func() string { + var b strings.Builder + for _, g := range expectedGroups { + b.WriteString("\n") + b.WriteString(` - `) + b.WriteString(g) + } + return b.String() + }() + + // Confirm we are the right user according to Kube by calling the whoami API. + kubectlCmd3 := exec.CommandContext(ctx, "kubectl", "create", "-f", "-", "-o", "yaml", "--kubeconfig", kubeconfigPath) + kubectlCmd3.Env = append(os.Environ(), env.ProxyEnv()...) + kubectlCmd3.Stdin = strings.NewReader(here.Docf(` + apiVersion: identity.concierge.%s/v1alpha1 + kind: WhoAmIRequest + `, env.APIGroupSuffix)) + + kubectlOutput3, err := kubectlCmd3.CombinedOutput() + require.NoError(t, err) + + require.Equal(t, here.Docf(` + apiVersion: identity.concierge.%s/v1alpha1 + kind: WhoAmIRequest + metadata: + creationTimestamp: null + spec: {} + status: + kubernetesUserInfo: + user: + groups:%s + - system:authenticated + username: %s + `, env.APIGroupSuffix, expectedYAMLGroups, expectedUsername), + string(kubectlOutput3)) + + expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...) + expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated") + // Validate that `pinniped whoami` returns the correct identity. + assertWhoami( + ctx, + t, + true, + pinnipedExe, + kubeconfigPath, + expectedUsername, + expectedGroupsPlusAuthenticated, + ) +} + +func requireGCAnnotationsOnSessionStorage(ctx context.Context, t *testing.T, supervisorNamespace string, startTime time.Time, token *oidctypes.Token) { // check that the access token is new (since it's just been refreshed) and has close to two minutes left. - testutil.RequireTimeInDelta(t, start.Add(2*time.Minute), token.AccessToken.Expiry.Time, 15*time.Second) + testutil.RequireTimeInDelta(t, startTime.Add(2*time.Minute), token.AccessToken.Expiry.Time, 15*time.Second) kubeClient := library.NewKubernetesClientset(t).CoreV1() // get the access token secret that matches the signature from the cache accessTokenSignature := strings.Split(token.AccessToken.Token, ".")[1] accessSecretName := getSecretNameFromSignature(t, accessTokenSignature, "access-token") - accessTokenSecret, err := kubeClient.Secrets(env.SupervisorNamespace).Get(ctx, accessSecretName, metav1.GetOptions{}) + accessTokenSecret, err := kubeClient.Secrets(supervisorNamespace).Get(ctx, accessSecretName, metav1.GetOptions{}) require.NoError(t, err) // Check that the access token garbage-collect-after value is 9 hours from now @@ -307,7 +511,7 @@ func TestE2EFullIntegration(t *testing.T) { // get the refresh token secret that matches the signature from the cache refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] refreshSecretName := getSecretNameFromSignature(t, refreshTokenSignature, "refresh-token") - refreshTokenSecret, err := kubeClient.Secrets(env.SupervisorNamespace).Get(ctx, refreshSecretName, metav1.GetOptions{}) + refreshTokenSecret, err := kubeClient.Secrets(supervisorNamespace).Get(ctx, refreshSecretName, metav1.GetOptions{}) require.NoError(t, err) // Check that the refresh token garbage-collect-after value is 9 hours @@ -318,60 +522,23 @@ func TestE2EFullIntegration(t *testing.T) { // the access token and the refresh token should be garbage collected at essentially the same time testutil.RequireTimeInDelta(t, accessTokenGCTime, refreshTokenGCTime, 1*time.Minute) +} - idTokenClaims := token.IDToken.Claims - require.Equal(t, env.SupervisorTestUpstream.Username, idTokenClaims[oidc.DownstreamUsernameClaim]) +func runPinnipedGetKubeconfig(t *testing.T, env *library.TestEnv, pinnipedExe string, tempDir string, pinnipedCLICommand []string) string { + // Run "pinniped get kubeconfig" to get a kubeconfig YAML. + envVarsWithProxy := append(os.Environ(), env.ProxyEnv()...) + kubeconfigYAML, stderr := runPinnipedCLI(t, envVarsWithProxy, pinnipedExe, pinnipedCLICommand...) + t.Logf("stderr output from 'pinniped get kubeconfig':\n%s\n\n", stderr) + t.Logf("test kubeconfig:\n%s\n\n", kubeconfigYAML) - // The groups claim in the file ends up as an []interface{}, so adjust our expectation to match. - expectedGroups := make([]interface{}, 0, len(env.SupervisorTestUpstream.ExpectedGroups)) - for _, g := range env.SupervisorTestUpstream.ExpectedGroups { - expectedGroups = append(expectedGroups, g) - } - require.Equal(t, expectedGroups, idTokenClaims[oidc.DownstreamGroupsClaim]) + restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML) + require.NotNil(t, restConfig.ExecProvider) + require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2]) - // confirm we are the right user according to Kube - expectedYAMLGroups := func() string { - var b strings.Builder - for _, g := range env.SupervisorTestUpstream.ExpectedGroups { - b.WriteString("\n") - b.WriteString(` - `) - b.WriteString(g) - } - return b.String() - }() - kubectlCmd3 := exec.CommandContext(ctx, "kubectl", "create", "-f", "-", "-o", "yaml", "--kubeconfig", kubeconfigPath) - kubectlCmd3.Env = append(os.Environ(), env.ProxyEnv()...) - kubectlCmd3.Stdin = strings.NewReader(` -apiVersion: identity.concierge.` + env.APIGroupSuffix + `/v1alpha1 -kind: WhoAmIRequest -`) - kubectlOutput3, err := kubectlCmd3.CombinedOutput() - require.NoError(t, err) - require.Equal(t, - `apiVersion: identity.concierge.`+env.APIGroupSuffix+`/v1alpha1 -kind: WhoAmIRequest -metadata: - creationTimestamp: null -spec: {} -status: - kubernetesUserInfo: - user: - groups:`+expectedYAMLGroups+` - - system:authenticated - username: `+env.SupervisorTestUpstream.Username+` -`, - string(kubectlOutput3)) + kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml") + require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600)) - // Validate that `pinniped whoami` returns the correct identity. - assertWhoami( - ctx, - t, - true, - pinnipedExe, - kubeconfigPath, - env.SupervisorTestUpstream.Username, - append(env.SupervisorTestUpstream.ExpectedGroups, "system:authenticated"), - ) + return kubeconfigPath } func getSecretNameFromSignature(t *testing.T, signature string, typeLabel string) string { diff --git a/test/integration/kube_api_discovery_test.go b/test/integration/kube_api_discovery_test.go index b7a652a2..c0917fdc 100644 --- a/test/integration/kube_api_discovery_test.go +++ b/test/integration/kube_api_discovery_test.go @@ -170,6 +170,20 @@ func TestGetAPIResourceList(t *testing.T) { Kind: "OIDCIdentityProvider", Verbs: []string{"get", "patch", "update"}, }, + { + Name: "ldapidentityproviders", + SingularName: "ldapidentityprovider", + Namespaced: true, + Kind: "LDAPIdentityProvider", + Verbs: []string{"delete", "deletecollection", "get", "list", "patch", "create", "update", "watch"}, + Categories: []string{"pinniped", "pinniped-idp", "pinniped-idps"}, + }, + { + Name: "ldapidentityproviders/status", + Namespaced: true, + Kind: "LDAPIdentityProvider", + Verbs: []string{"get", "patch", "update"}, + }, }, }, }, diff --git a/test/integration/ldap_client_test.go b/test/integration/ldap_client_test.go new file mode 100644 index 00000000..fb49a099 --- /dev/null +++ b/test/integration/ldap_client_test.go @@ -0,0 +1,553 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + + "go.pinniped.dev/internal/upstreamldap" + "go.pinniped.dev/test/library" +) + +func TestLDAPSearch(t *testing.T) { + env := library.IntegrationEnv(t) + + // Note that these tests depend on the values hard-coded in the LDIF file in test/deploy/tools/ldap.yaml. + // It requires the test LDAP server from the tools deployment. + if len(env.ToolsNamespace) == 0 { + t.Skip("Skipping test because it requires the test LDAP server in the tools namespace.") + } + + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancelFunc() // this will send SIGKILL to the subprocess, just in case + }) + + hostPorts := findRecentlyUnusedLocalhostPorts(t, 2) + ldapHostPort := hostPorts[0] + unusedHostPort := hostPorts[1] + + // Expose the the test LDAP server's TLS port on the localhost. + startKubectlPortForward(ctx, t, ldapHostPort, "ldaps", "ldap", env.ToolsNamespace) + + providerConfig := func(editFunc func(p *upstreamldap.ProviderConfig)) *upstreamldap.ProviderConfig { + providerConfig := defaultProviderConfig(env, ldapHostPort) + if editFunc != nil { + editFunc(providerConfig) + } + return providerConfig + } + + pinnyPassword := env.SupervisorUpstreamLDAP.TestUserPassword + + tests := []struct { + name string + username string + password string + provider *upstreamldap.Provider + wantError string + wantAuthResponse *authenticator.Response + wantUnauthenticated bool + }{ + { + name: "happy path", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(nil)), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, + }, + }, + { + name: "using a different user search base", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, + }, + }, + { + name: "when the user search filter is already wrapped by parenthesis", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, + }, + }, + { + name: "when the UsernameAttribute is dn and a user search filter is provided", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.UsernameAttribute = "dn" + p.UserSearch.Filter = "cn={}" + })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: "1000", Groups: []string{}}, + }, + }, + { + name: "when the user search filter allows for different ways of logging in and the first one is used", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.Filter = "(|(cn={})(mail={}))" + })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, + }, + }, + { + name: "when the user search filter allows for different ways of logging in and the second one is used", + username: "pinny.ldap@example.com", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.Filter = "(|(cn={})(mail={}))" + })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, + }, + }, + { + name: "when the UIDAttribute is dn", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "cn=pinny,ou=users,dc=pinniped,dc=dev", Groups: []string{}}, + }, + }, + { + name: "when the UIDAttribute is sn", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "Seal", Groups: []string{}}, + }, + }, + { + name: "when the UsernameAttribute is sn", + username: "seAl", // note that this is not case-sensitive! sn=Seal. The server decides which fields are compared case-sensitive. + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })), + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{Name: "Seal", UID: "1000", Groups: []string{}}, // note that the final answer has case preserved from the entry + }, + }, + { + name: "when the UsernameAttribute is dn and there is no user search filter provided", + username: "cn=pinny,ou=users,dc=pinniped,dc=dev", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.UsernameAttribute = "dn" + p.UserSearch.Filter = "" + })), + wantError: `must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`, + }, + { + name: "when the bind user username is not a valid DN", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.BindUsername = "invalid-dn" })), + wantError: `error binding as "invalid-dn" before user search: LDAP Result Code 34 "Invalid DN Syntax": invalid DN`, + }, + { + name: "when the bind user username is wrong", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.BindUsername = "cn=wrong,dc=pinniped,dc=dev" })), + wantError: `error binding as "cn=wrong,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": `, + }, + { + name: "when the bind user password is wrong", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.BindPassword = "wrong-password" })), + wantError: `error binding as "cn=admin,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": `, + }, + { + name: "when the end user password is wrong", + username: "pinny", + password: "wrong-pinny-password", + provider: upstreamldap.New(*providerConfig(nil)), + wantUnauthenticated: true, + }, + { + name: "when the end user password has the wrong case (passwords are compared as case-sensitive)", + username: "pinny", + password: strings.ToUpper(pinnyPassword), + provider: upstreamldap.New(*providerConfig(nil)), + wantUnauthenticated: true, + }, + { + name: "when the end user username is wrong", + username: "wrong-username", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(nil)), + wantUnauthenticated: true, + }, + { + name: "when the user search filter does not compile", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "*" })), + wantError: `error searching for user "pinny": LDAP Result Code 201 "Filter Compile Error": ldap: error parsing filter`, + }, + { + name: "when there are too many search results for the user", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.Filter = "objectClass=*" // overly broad search filter + })), + wantError: `error searching for user "pinny": LDAP Result Code 4 "Size Limit Exceeded": `, + }, + { + name: "when the server is unreachable", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.Host = "127.0.0.1:" + unusedHostPort })), + wantError: fmt.Sprintf(`error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": dial tcp 127.0.0.1:%s: connect: connection refused`, unusedHostPort, unusedHostPort), + }, + { + name: "when the server is not parsable", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.Host = "too:many:ports" })), + wantError: `error dialing host "too:many:ports": LDAP Result Code 200 "Network Error": address too:many:ports: too many colons in address`, + }, + { + name: "when the CA bundle is not parsable", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.CABundle = []byte("invalid-pem") })), + wantError: fmt.Sprintf(`error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": could not parse CA bundle`, ldapHostPort), + }, + { + name: "when the CA bundle does not cause the host to be trusted", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.CABundle = nil })), + wantError: fmt.Sprintf(`error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": x509: certificate signed by unknown authority`, ldapHostPort), + }, + { + name: "when the UsernameAttribute attribute has multiple values in the entry", + username: "wally.ldap@example.com", + password: "unused-because-error-is-before-bind", + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "mail" })), + wantError: `found 2 values for attribute "mail" while searching for user "wally.ldap@example.com", but expected 1 result`, + }, + { + name: "when the UIDAttribute attribute has multiple values in the entry", + username: "wally", + password: "unused-because-error-is-before-bind", + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "mail" })), + wantError: `found 2 values for attribute "mail" while searching for user "wally", but expected 1 result`, + }, + { + name: "when the UsernameAttribute attribute is not found in the entry", + username: "wally", + password: "unused-because-error-is-before-bind", + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.Filter = "cn={}" + p.UserSearch.UsernameAttribute = "attr-does-not-exist" + })), + wantError: `found 0 values for attribute "attr-does-not-exist" while searching for user "wally", but expected 1 result`, + }, + { + name: "when the UIDAttribute attribute is not found in the entry", + username: "wally", + password: "unused-because-error-is-before-bind", + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "attr-does-not-exist" })), + wantError: `found 0 values for attribute "attr-does-not-exist" while searching for user "wally", but expected 1 result`, + }, + { + name: "when the UsernameAttribute has the wrong case", + username: "Seal", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "SN" })), // this is case-sensitive + wantError: `found 0 values for attribute "SN" while searching for user "Seal", but expected 1 result`, + }, + { + name: "when the UIDAttribute has the wrong case", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "SN" })), // this is case-sensitive + wantError: `found 0 values for attribute "SN" while searching for user "pinny", but expected 1 result`, + }, + { + name: "when the UsernameAttribute is DN and has the wrong case", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.UsernameAttribute = "DN" // dn must be lower-case + p.UserSearch.Filter = "cn={}" + })), + wantError: `found 0 values for attribute "DN" while searching for user "pinny", but expected 1 result`, + }, + { + name: "when the UIDAttribute is DN and has the wrong case", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { + p.UserSearch.UIDAttribute = "DN" // dn must be lower-case + })), + wantError: `found 0 values for attribute "DN" while searching for user "pinny", but expected 1 result`, + }, + { + name: "when the search base is invalid", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "invalid-base" })), + wantError: `error searching for user "pinny": LDAP Result Code 34 "Invalid DN Syntax": invalid DN`, + }, + { + name: "when the search base does not exist", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "ou=does-not-exist,dc=pinniped,dc=dev" })), + wantError: `error searching for user "pinny": LDAP Result Code 32 "No Such Object": `, + }, + { + name: "when the search base causes no search results", + username: "pinny", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "ou=groups,dc=pinniped,dc=dev" })), + wantUnauthenticated: true, + }, + { + name: "when there is no username specified", + username: "", + password: pinnyPassword, + provider: upstreamldap.New(*providerConfig(nil)), + wantUnauthenticated: true, + }, + { + name: "when there is no password specified", + username: "pinny", + password: "", + provider: upstreamldap.New(*providerConfig(nil)), + wantError: `error binding for user "pinny" using provided password against DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 206 "Empty password not allowed by the client": ldap: empty password not allowed by the client`, + }, + { + name: "when the user has no password in their entry", + username: "olive", + password: "anything", + provider: upstreamldap.New(*providerConfig(nil)), + wantUnauthenticated: true, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + authResponse, authenticated, err := tt.provider.AuthenticateUser(ctx, tt.username, tt.password) + + switch { + case tt.wantError != "": + require.EqualError(t, err, tt.wantError) + require.False(t, authenticated, "expected the user not to be authenticated, but they were") + require.Nil(t, authResponse) + case tt.wantUnauthenticated: + require.NoError(t, err) + require.False(t, authenticated, "expected the user not to be authenticated, but they were") + require.Nil(t, authResponse) + default: + require.NoError(t, err) + require.True(t, authenticated, "expected the user to be authenticated, but they were not") + require.Equal(t, tt.wantAuthResponse, authResponse) + } + }) + } +} + +func TestSimultaneousRequestsOnSingleProvider(t *testing.T) { + env := library.IntegrationEnv(t) + + // Note that these tests depend on the values hard-coded in the LDIF file in test/deploy/tools/ldap.yaml. + // It requires the test LDAP server from the tools deployment. + if len(env.ToolsNamespace) == 0 { + t.Skip("Skipping test because it requires the test LDAP server in the tools namespace.") + } + + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancelFunc() // this will send SIGKILL to the subprocess, just in case + }) + + ldapHostPort := findRecentlyUnusedLocalhostPorts(t, 1)[0] + + // Expose the the test LDAP server's TLS port on the localhost. + startKubectlPortForward(ctx, t, ldapHostPort, "ldaps", "ldap", env.ToolsNamespace) + + provider := upstreamldap.New(*defaultProviderConfig(env, ldapHostPort)) + + // Making multiple simultaneous requests on the same upstreamldap.Provider instance should all succeed + // without triggering the race detector. + iterations := 150 + resultCh := make(chan authUserResult, iterations) + for i := 0; i < iterations; i++ { + go func() { + authResponse, authenticated, err := provider.AuthenticateUser(ctx, + env.SupervisorUpstreamLDAP.TestUserCN, env.SupervisorUpstreamLDAP.TestUserPassword, + ) + resultCh <- authUserResult{ + response: authResponse, + authenticated: authenticated, + err: err, + } + }() + } + for i := 0; i < iterations; i++ { + result := <-resultCh + require.NoError(t, result.err) + require.True(t, result.authenticated, "expected the user to be authenticated, but they were not") + require.Equal(t, &authenticator.Response{ + User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, + }, result.response) + } +} + +type authUserResult struct { + response *authenticator.Response + authenticated bool + err error +} + +func defaultProviderConfig(env *library.TestEnv, ldapHostPort string) *upstreamldap.ProviderConfig { + return &upstreamldap.ProviderConfig{ + Name: "test-ldap-provider", + Host: "127.0.0.1:" + ldapHostPort, + CABundle: []byte(env.SupervisorUpstreamLDAP.CABundle), + BindUsername: "cn=admin,dc=pinniped,dc=dev", + BindPassword: "password", + UserSearch: upstreamldap.UserSearchConfig{ + Base: "ou=users,dc=pinniped,dc=dev", + Filter: "", // defaults to UsernameAttribute={}, i.e. "cn={}" in this case + UsernameAttribute: "cn", + UIDAttribute: "uidNumber", + }, + } +} + +func startKubectlPortForward(ctx context.Context, t *testing.T, hostPort, remotePort, serviceName, namespace string) { + t.Helper() + startLongRunningCommandAndWaitForInitialOutput(ctx, t, + "kubectl", + []string{ + "port-forward", + fmt.Sprintf("service/%s", serviceName), + fmt.Sprintf("%s:%s", hostPort, remotePort), + "-n", namespace, + }, + "Forwarding from ", + "stdout", + ) +} + +func findRecentlyUnusedLocalhostPorts(t *testing.T, howManyPorts int) []string { + t.Helper() + + listeners := []net.Listener{} + for i := 0; i < howManyPorts; i++ { + unusedPortGrabbingListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + listeners = append(listeners, unusedPortGrabbingListener) + } + + ports := make([]string, len(listeners)) + for i, listener := range listeners { + splitHostAndPort := strings.Split(listener.Addr().String(), ":") + require.Len(t, splitHostAndPort, 2) + ports[i] = splitHostAndPort[1] + } + + for _, listener := range listeners { + require.NoError(t, listener.Close()) + } + + return ports +} + +func startLongRunningCommandAndWaitForInitialOutput( + ctx context.Context, + t *testing.T, + command string, + args []string, + waitForOutputToContain string, + waitForOutputOnFd string, // can be either "stdout" or "stderr" +) { + t.Helper() + + t.Logf("Starting: %s %s", command, strings.Join(args, " ")) + + cmd := exec.CommandContext(ctx, command, args...) + + var stdoutBuf, stderrBuf syncBuffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + cmd.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf) + cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + + var watchOn *syncBuffer + switch waitForOutputOnFd { + case "stdout": + watchOn = &stdoutBuf + case "stderr": + watchOn = &stderrBuf + default: + t.Fatalf("oops bad argument") + } + + err := cmd.Start() + require.NoError(t, err) + t.Cleanup(func() { + // If the cancellation of ctx was already scheduled in a t.Cleanup, then this + // t.Cleanup is registered after the one, so this one will happen first. + // Cancelling ctx will send SIGKILL, which will act as a backup in case + // the process ignored this SIGINT. + err := cmd.Process.Signal(os.Interrupt) + require.NoError(t, err) + }) + + earlyTerminationCh := make(chan bool, 1) + go func() { + err = cmd.Wait() + earlyTerminationCh <- true + }() + + terminatedEarly := false + require.Eventually(t, func() bool { + t.Logf(`Waiting for %s to emit output: "%s"`, command, waitForOutputToContain) + if strings.Contains(watchOn.String(), waitForOutputToContain) { + return true + } + select { + case <-earlyTerminationCh: + terminatedEarly = true + return true + default: // ignore when this non-blocking read found no message + } + return false + }, 1*time.Minute, 1*time.Second) + + require.Falsef(t, terminatedEarly, "subcommand ended sooner than expected") + + t.Logf("Detected that %s has started successfully", command) +} diff --git a/test/integration/supervisor_discovery_test.go b/test/integration/supervisor_discovery_test.go index 2bcfd03d..6740d644 100644 --- a/test/integration/supervisor_discovery_test.go +++ b/test/integration/supervisor_discovery_test.go @@ -482,10 +482,11 @@ func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, superviso "scopes_supported": ["openid", "offline"], "response_types_supported": ["code"], "claims_supported": ["groups"], + "discovery.supervisor.pinniped.dev/v1alpha1": {"pinniped_identity_providers_endpoint": "%s/v1alpha1/pinniped_identity_providers"}, "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["ES256"] }`) - expectedJSON := fmt.Sprintf(expectedResultTemplate, issuerName, issuerName, issuerName, issuerName) + expectedJSON := fmt.Sprintf(expectedResultTemplate, issuerName, issuerName, issuerName, issuerName, issuerName) require.Equal(t, "application/json", response.Header.Get("content-type")) require.JSONEq(t, expectedJSON, responseBody) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 962895da..fbfa8d7b 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "net/url" @@ -17,12 +18,11 @@ import ( "testing" "time" - v1 "k8s.io/api/core/v1" - coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + v1 "k8s.io/api/core/v1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" @@ -39,11 +39,183 @@ import ( func TestSupervisorLogin(t *testing.T) { env := library.IntegrationEnv(t) + tests := []struct { + name string + createIDP func(t *testing.T) + requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) + wantDownstreamIDTokenSubjectToMatch string + wantDownstreamIDTokenUsernameToMatch string + }{ + { + name: "oidc", + createIDP: func(t *testing.T) { + t.Helper() + library.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: env.SupervisorUpstreamOIDC.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: library.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + }, + requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider, + // the ID token Subject should include the upstream user ID after the upstream issuer name + wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + // the ID token Username should include the upstream user ID after the upstream issuer name + wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + }, + { + name: "ldap with email as username", + createIDP: func(t *testing.T) { + t.Helper() + secret := library.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, + }, + ) + ldapIDP := library.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ + Host: env.SupervisorUpstreamLDAP.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), + }, + Bind: idpv1alpha1.LDAPIdentityProviderBind{ + SecretName: secret.Name, + }, + UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ + Base: env.SupervisorUpstreamLDAP.UserSearchBase, + Filter: "", + Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ + Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, + UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, + }, + }, + }, idpv1alpha1.LDAPPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorizationUsingLDAPIdentityProvider(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login + httpClient, + ) + }, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta( + "ldaps://" + env.SupervisorUpstreamLDAP.Host + "?sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue, + ), + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue), + }, + { + name: "ldap with CN as username ", // try another variation of configuration options + createIDP: func(t *testing.T) { + t.Helper() + secret := library.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, + }, + ) + ldapIDP := library.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ + Host: env.SupervisorUpstreamLDAP.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), + }, + Bind: idpv1alpha1.LDAPIdentityProviderBind{ + SecretName: secret.Name, + }, + UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ + Base: env.SupervisorUpstreamLDAP.UserSearchBase, + Filter: "cn={}", // try using a non-default search filter + Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ + Username: "dn", // try using the user's DN as the downstream username + UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, + }, + }, + }, idpv1alpha1.LDAPPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorizationUsingLDAPIdentityProvider(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login + httpClient, + ) + }, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta( + "ldaps://" + env.SupervisorUpstreamLDAP.Host + "?sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue, + ), + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN), + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + testSupervisorLogin(t, + test.createIDP, + test.requestAuthorization, + test.wantDownstreamIDTokenSubjectToMatch, + test.wantDownstreamIDTokenUsernameToMatch, + ) + }) + } +} + +func requireSuccessfulLDAPIdentityProviderConditions(t *testing.T, ldapIDP *idpv1alpha1.LDAPIdentityProvider, expectedLDAPConnectionValidMessage string) { + require.Len(t, ldapIDP.Status.Conditions, 3) + + conditionsSummary := [][]string{} + for _, condition := range ldapIDP.Status.Conditions { + conditionsSummary = append(conditionsSummary, []string{condition.Type, string(condition.Status), condition.Reason}) + t.Logf("Saw LDAPIdentityProvider Status.Condition Type=%s Status=%s Reason=%s Message=%s", + condition.Type, string(condition.Status), condition.Reason, condition.Message) + switch condition.Type { + case "BindSecretValid": + require.Equal(t, "loaded bind secret", condition.Message) + case "TLSConfigurationValid": + require.Equal(t, "loaded TLS configuration", condition.Message) + case "LDAPConnectionValid": + require.Equal(t, expectedLDAPConnectionValidMessage, condition.Message) + } + } + + require.ElementsMatch(t, [][]string{ + {"BindSecretValid", "True", "Success"}, + {"TLSConfigurationValid", "True", "Success"}, + {"LDAPConnectionValid", "True", "Success"}, + }, conditionsSummary) +} + +func testSupervisorLogin( + t *testing.T, + createIDP func(t *testing.T), + requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client), + wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, +) { + env := library.IntegrationEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Infer the downstream issuer URL from the callback associated with the upstream test client registration. - issuerURL, err := url.Parse(env.SupervisorTestUpstream.CallbackURL) + issuerURL, err := url.Parse(env.SupervisorUpstreamOIDC.CallbackURL) require.NoError(t, err) require.True(t, strings.HasSuffix(issuerURL.Path, "/callback")) issuerURL.Path = strings.TrimSuffix(issuerURL.Path, "/callback") @@ -59,13 +231,17 @@ func TestSupervisorLogin(t *testing.T) { Transport: &http.Transport{ TLSClientConfig: &tls.Config{RootCAs: ca.Pool()}, Proxy: func(req *http.Request) (*url.URL, error) { + if strings.HasPrefix(req.URL.Host, "127.0.0.1") { + // don't proxy requests to localhost to avoid proxying calls to our local callback listener + return nil, nil + } if env.Proxy == "" { - t.Logf("passing request for %s with no proxy", req.URL) + t.Logf("passing request for %s with no proxy", library.RedactURLParams(req.URL)) return nil, nil } proxyURL, err := url.Parse(env.Proxy) require.NoError(t, err) - t.Logf("passing request for %s through proxy %s", req.URL, proxyURL.String()) + t.Logf("passing request for %s through proxy %s", library.RedactURLParams(req.URL), proxyURL.String()) return proxyURL, nil }, }, @@ -119,16 +295,8 @@ func TestSupervisorLogin(t *testing.T) { }, 30*time.Second, 200*time.Millisecond) require.Equal(t, http.StatusOK, jwksRequestStatus) - // Create upstream OIDC provider and wait for it to become ready. - library.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ - Issuer: env.SupervisorTestUpstream.Issuer, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)), - }, - Client: idpv1alpha1.OIDCClient{ - SecretName: library.CreateClientCredsSecret(t, env.SupervisorTestUpstream.ClientID, env.SupervisorTestUpstream.ClientSecret).Name, - }, - }, idpv1alpha1.PhaseReady) + // Create upstream IDP and wait for it to become ready. + createIDP(t) // Perform OIDC discovery for our downstream. var discovery *coreosoidc.Provider @@ -164,26 +332,8 @@ func TestSupervisorLogin(t *testing.T) { pkceParam.Method(), ) - // Make the authorize request one "manually" so we can check its response headers. - authorizeRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil) - require.NoError(t, err) - authorizeResp, err := httpClient.Do(authorizeRequest) - require.NoError(t, err) - require.NoError(t, authorizeResp.Body.Close()) - expectSecurityHeaders(t, authorizeResp) - - // Open the web browser and navigate to the downstream authorize URL. - page := browsertest.Open(t) - t.Logf("opening browser to downstream authorize URL %s", library.MaskTokens(downstreamAuthorizeURL)) - require.NoError(t, page.Navigate(downstreamAuthorizeURL)) - - // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorTestUpstream) - - // Wait for the login to happen and us be redirected back to a localhost callback. - t.Logf("waiting for redirect to callback") - callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(localCallbackServer.URL) + `\?.+\z`) - browsertest.WaitForURL(t, page, callbackURLPattern) + // Perform parameterized auth code acquisition. + requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, httpClient) // Expect that our callback handler was invoked. callback := localCallbackServer.waitForCallback(10 * time.Second) @@ -198,7 +348,9 @@ func TestSupervisorLogin(t *testing.T) { require.NoError(t, err) expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"} - verifyTokenResponse(t, tokenResponse, discovery, downstreamOAuth2Config, env.SupervisorTestUpstream.Issuer, nonceParam, expectedIDTokenClaims) + verifyTokenResponse(t, + tokenResponse, discovery, downstreamOAuth2Config, nonceParam, + expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch) // token exchange on the original token doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) @@ -209,7 +361,9 @@ func TestSupervisorLogin(t *testing.T) { require.NoError(t, err) expectedIDTokenClaims = append(expectedIDTokenClaims, "at_hash") - verifyTokenResponse(t, refreshedTokenResponse, discovery, downstreamOAuth2Config, env.SupervisorTestUpstream.Issuer, "", expectedIDTokenClaims) + verifyTokenResponse(t, + refreshedTokenResponse, discovery, downstreamOAuth2Config, "", + expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch) require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) @@ -224,9 +378,9 @@ func verifyTokenResponse( tokenResponse *oauth2.Token, discovery *coreosoidc.Provider, downstreamOAuth2Config oauth2.Config, - upstreamIssuerName string, nonceParam nonce.Nonce, expectedIDTokenClaims []string, + wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, ) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -238,14 +392,17 @@ func verifyTokenResponse( idToken, err := verifier.Verify(ctx, rawIDToken) require.NoError(t, err) - // Check the claims of the ID token. - expectedSubjectPrefix := upstreamIssuerName + "?sub=" - require.True(t, strings.HasPrefix(idToken.Subject, expectedSubjectPrefix)) - require.Greater(t, len(idToken.Subject), len(expectedSubjectPrefix), - "the ID token Subject should include the upstream user ID after the upstream issuer name") + // Check the sub claim of the ID token. + require.Regexp(t, wantDownstreamIDTokenSubjectToMatch, idToken.Subject) + + // Check the nonce claim of the ID token. require.NoError(t, nonceParam.Validate(idToken)) + + // Check the exp claim of the ID token. expectedIDTokenLifetime := oidc.DefaultOIDCTimeoutsConfiguration().IDTokenLifespan testutil.RequireTimeInDelta(t, time.Now().UTC().Add(expectedIDTokenLifetime), idToken.Expiry, time.Second*30) + + // Check the full list of claim names of the ID token. idTokenClaims := map[string]interface{}{} err = idToken.Claims(&idTokenClaims) require.NoError(t, err) @@ -254,10 +411,9 @@ func verifyTokenResponse( idTokenClaimNames = append(idTokenClaimNames, k) } require.ElementsMatch(t, expectedIDTokenClaims, idTokenClaimNames) - expectedUsernamePrefix := upstreamIssuerName + "?sub=" - require.True(t, strings.HasPrefix(idTokenClaims["username"].(string), expectedUsernamePrefix)) - require.Greater(t, len(idTokenClaims["username"].(string)), len(expectedUsernamePrefix), - "the ID token Username should include the upstream user ID after the upstream issuer name") + + // Check username claim of the ID token. + require.Regexp(t, wantDownstreamIDTokenUsernameToMatch, idTokenClaims["username"].(string)) // Some light verification of the other tokens that were returned. require.NotEmpty(t, tokenResponse.AccessToken) @@ -269,6 +425,73 @@ func verifyTokenResponse( require.NotEmpty(t, tokenResponse.RefreshToken) } +func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { + t.Helper() + env := library.IntegrationEnv(t) + + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) + defer cancelFunc() + + // Make the authorize request once "manually" so we can check its response security headers. + authorizeRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil) + require.NoError(t, err) + authorizeResp, err := httpClient.Do(authorizeRequest) + require.NoError(t, err) + require.NoError(t, authorizeResp.Body.Close()) + expectSecurityHeaders(t, authorizeResp, false) + + // Open the web browser and navigate to the downstream authorize URL. + page := browsertest.Open(t) + t.Logf("opening browser to downstream authorize URL %s", library.MaskTokens(downstreamAuthorizeURL)) + require.NoError(t, page.Navigate(downstreamAuthorizeURL)) + + // Expect to be redirected to the upstream provider and log in. + browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + + // Wait for the login to happen and us be redirected back to a localhost callback. + t.Logf("waiting for redirect to callback") + callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(downstreamCallbackURL) + `\?.+\z`) + browsertest.WaitForURL(t, page, callbackURLPattern) +} + +func requestAuthorizationUsingLDAPIdentityProvider(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) { + t.Helper() + + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) + defer cancelFunc() + + authRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil) + require.NoError(t, err) + + // Set the custom username/password headers for the LDAP authorize request. + authRequest.Header.Set("Pinniped-Username", upstreamUsername) + authRequest.Header.Set("Pinniped-Password", upstreamPassword) + + authResponse, err := httpClient.Do(authRequest) + require.NoError(t, err) + responseBody, err := ioutil.ReadAll(authResponse.Body) + defer authResponse.Body.Close() + require.NoError(t, err) + expectSecurityHeaders(t, authResponse, true) + + // A successful authorize request results in a redirect to our localhost callback listener with an authcode param. + require.Equalf(t, http.StatusFound, authResponse.StatusCode, "response body was: %s", string(responseBody)) + redirectLocation := authResponse.Header.Get("Location") + require.Contains(t, redirectLocation, "127.0.0.1") + require.Contains(t, redirectLocation, "/callback") + require.Contains(t, redirectLocation, "code=") + + // Follow the redirect. + callbackRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, redirectLocation, nil) + require.NoError(t, err) + + // Our localhost callback listener should have returned 200 OK. + callbackResponse, err := httpClient.Do(callbackRequest) + require.NoError(t, err) + defer callbackResponse.Body.Close() + require.Equal(t, http.StatusOK, callbackResponse.StatusCode) +} + func startLocalCallbackServer(t *testing.T) *localCallbackServer { // Handle the callback by sending the *http.Request object back through a channel. callbacks := make(chan *http.Request, 1) @@ -336,7 +559,7 @@ func doTokenExchange(t *testing.T, config *oauth2.Config, tokenResponse *oauth2. t.Logf("exchanged token claims:\n%s", string(indentedClaims)) } -func expectSecurityHeaders(t *testing.T, response *http.Response) { +func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeToOverrideSome bool) { h := response.Header assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", h.Get("Content-Security-Policy")) assert.Equal(t, "DENY", h.Get("X-Frame-Options")) @@ -344,7 +567,11 @@ func expectSecurityHeaders(t *testing.T, response *http.Response) { assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options")) assert.Equal(t, "no-referrer", h.Get("Referrer-Policy")) assert.Equal(t, "off", h.Get("X-DNS-Prefetch-Control")) - assert.Equal(t, "no-cache,no-store,max-age=0,must-revalidate", h.Get("Cache-Control")) + if expectFositeToOverrideSome { + assert.Equal(t, "no-store", h.Get("Cache-Control")) + } else { + assert.Equal(t, "no-cache,no-store,max-age=0,must-revalidate", h.Get("Cache-Control")) + } assert.Equal(t, "no-cache", h.Get("Pragma")) assert.Equal(t, "0", h.Get("Expires")) } diff --git a/test/integration/supervisor_upstream_test.go b/test/integration/supervisor_upstream_test.go index 6dfd5503..0a9b9767 100644 --- a/test/integration/supervisor_upstream_test.go +++ b/test/integration/supervisor_upstream_test.go @@ -46,9 +46,9 @@ Get "https://127.0.0.1:444444/issuer/.well-known/openid-configuration": dial tcp t.Run("invalid issuer with trailing slash", func(t *testing.T) { t.Parallel() spec := v1alpha1.OIDCIdentityProviderSpec{ - Issuer: env.SupervisorTestUpstream.Issuer + "/", + Issuer: env.SupervisorUpstreamOIDC.Issuer + "/", TLS: &v1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)), + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), }, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ AdditionalScopes: []string{"email", "profile"}, @@ -69,8 +69,8 @@ Get "https://127.0.0.1:444444/issuer/.well-known/openid-configuration": dial tcp Type: "OIDCDiscoverySucceeded", Status: v1alpha1.ConditionFalse, Reason: "Unreachable", - Message: `failed to perform OIDC discovery against "` + env.SupervisorTestUpstream.Issuer + `/": -oidc: issuer did not match the issuer returned by provider, expected "` + env.SupervisorTestUpstream.Issuer + `/" got "` + env.SupervisorTestUpstream.Issuer + `"`, + 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 + `"`, }, }) }) @@ -78,9 +78,9 @@ oidc: issuer did not match the issuer returned by provider, expected "` + env.Su t.Run("valid", func(t *testing.T) { t.Parallel() spec := v1alpha1.OIDCIdentityProviderSpec{ - Issuer: env.SupervisorTestUpstream.Issuer, + Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &v1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorTestUpstream.CABundle)), + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), }, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ AdditionalScopes: []string{"email", "profile"}, diff --git a/test/library/client.go b/test/library/client.go index 789ffb2d..ef242613 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -190,13 +190,13 @@ func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.Ty // test's lifetime. It returns a corev1.TypedLocalObjectReference which describes the test JWT // authenticator within the test namespace. // -// CreateTestJWTAuthenticatorForCLIUpstream gets the OIDC issuer info from IntegrationEnv().CLITestUpstream. +// CreateTestJWTAuthenticatorForCLIUpstream gets the OIDC issuer info from IntegrationEnv().CLIUpstreamOIDC. func CreateTestJWTAuthenticatorForCLIUpstream(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { t.Helper() testEnv := IntegrationEnv(t) spec := auth1alpha1.JWTAuthenticatorSpec{ - Issuer: testEnv.CLITestUpstream.Issuer, - Audience: testEnv.CLITestUpstream.ClientID, + Issuer: testEnv.CLIUpstreamOIDC.Issuer, + Audience: testEnv.CLIUpstreamOIDC.ClientID, // The default UsernameClaim is "username" but the upstreams that we use for // integration tests won't necessarily have that claim, so use "sub" here. Claims: auth1alpha1.JWTTokenClaims{Username: "sub"}, @@ -204,9 +204,9 @@ func CreateTestJWTAuthenticatorForCLIUpstream(ctx context.Context, t *testing.T) // If the test upstream does not have a CA bundle specified, then don't configure one in the // JWTAuthenticator. Leaving TLSSpec set to nil will result in OIDC discovery using the OS's root // CA store. - if testEnv.CLITestUpstream.CABundle != "" { + if testEnv.CLIUpstreamOIDC.CABundle != "" { spec.TLS = &auth1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(testEnv.CLITestUpstream.CABundle)), + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(testEnv.CLIUpstreamOIDC.CABundle)), } } return CreateTestJWTAuthenticator(ctx, t, spec) @@ -250,8 +250,7 @@ func CreateTestJWTAuthenticator(ctx context.Context, t *testing.T, spec auth1alp // CreateTestFederationDomain creates and returns a test FederationDomain in // $PINNIPED_TEST_SUPERVISOR_NAMESPACE, which will be automatically deleted at the end of the -// current test's lifetime. It generates a random, valid, issuer for the FederationDomain. -// +// current test's lifetime. // If the provided issuer is not the empty string, then it will be used for the // FederationDomain.Spec.Issuer field. Else, a random issuer will be generated. func CreateTestFederationDomain(ctx context.Context, t *testing.T, issuer string, certSecretName string, expectStatus configv1alpha1.FederationDomainStatusCondition) *configv1alpha1.FederationDomain { @@ -377,7 +376,7 @@ func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityP upstreams := client.IDPV1alpha1().OIDCIdentityProviders(env.SupervisorNamespace) created, err := upstreams.Create(ctx, &idpv1alpha1.OIDCIdentityProvider{ - ObjectMeta: testObjectMeta(t, "upstream"), + ObjectMeta: testObjectMeta(t, "upstream-oidc-idp"), Spec: spec, }, metav1.CreateOptions{}) require.NoError(t, err) @@ -401,6 +400,41 @@ func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityP return result } +func CreateTestLDAPIdentityProvider(t *testing.T, spec idpv1alpha1.LDAPIdentityProviderSpec, expectedPhase idpv1alpha1.LDAPIdentityProviderPhase) *idpv1alpha1.LDAPIdentityProvider { + t.Helper() + env := IntegrationEnv(t) + client := NewSupervisorClientset(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // Create the LDAPIdentityProvider using GenerateName to get a random name. + upstreams := client.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace) + + created, err := upstreams.Create(ctx, &idpv1alpha1.LDAPIdentityProvider{ + ObjectMeta: testObjectMeta(t, "upstream-ldap-idp"), + Spec: spec, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + // Always clean this up after this point. + t.Cleanup(func() { + t.Logf("cleaning up test LDAPIdentityProvider %s/%s", created.Namespace, created.Name) + err := upstreams.Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + t.Logf("created test LDAPIdentityProvider %s", created.Name) + + // Wait for the LDAPIdentityProvider to enter the expected phase (or time out). + var result *idpv1alpha1.LDAPIdentityProvider + require.Eventuallyf(t, func() bool { + var err error + result, err = upstreams.Get(ctx, created.Name, metav1.GetOptions{}) + require.NoError(t, err) + return result.Status.Phase == expectedPhase + }, 60*time.Second, 1*time.Second, "expected the LDAPIdentityProvider to go into phase %s", expectedPhase) + return result +} + func CreateTestClusterRoleBinding(t *testing.T, subject rbacv1.Subject, roleRef rbacv1.RoleRef) *rbacv1.ClusterRoleBinding { t.Helper() client := NewKubernetesClientset(t) diff --git a/test/library/env.go b/test/library/env.go index a806d5ff..0e730d05 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -51,8 +51,9 @@ type TestEnv struct { ExpectedGroups []string `json:"expectedGroups"` } `json:"testUser"` - CLITestUpstream TestOIDCUpstream `json:"cliOIDCUpstream"` - SupervisorTestUpstream TestOIDCUpstream `json:"supervisorOIDCUpstream"` + CLIUpstreamOIDC TestOIDCUpstream `json:"cliOIDCUpstream"` + SupervisorUpstreamOIDC TestOIDCUpstream `json:"supervisorOIDCUpstream"` + SupervisorUpstreamLDAP TestLDAPUpstream `json:"supervisorLDAPUpstream"` } type TestOIDCUpstream struct { @@ -69,6 +70,21 @@ type TestOIDCUpstream struct { ExpectedGroups []string `json:"expectedGroups"` } +type TestLDAPUpstream struct { + Host string `json:"host"` + CABundle string `json:"caBundle"` + BindUsername string `json:"bindUsername"` + BindPassword string `json:"bindPassword"` + UserSearchBase string `json:"userSearchBase"` + TestUserDN string `json:"testUserDN"` + TestUserCN string `json:"testUserCN"` + TestUserPassword string `json:"testUserPassword"` + TestUserMailAttributeName string `json:"testUserMailAttributeName"` + TestUserMailAttributeValue string `json:"testUserMailAttributeValue"` + TestUserUniqueIDAttributeName string `json:"testUserUniqueIDAttributeName"` + TestUserUniqueIDAttributeValue string `json:"testUserUniqueIDAttributeValue"` +} + // ProxyEnv returns a set of environment variable strings (e.g., to combine with os.Environ()) which set up the configured test HTTP proxy. func (e *TestEnv) ProxyEnv() []string { if e.Proxy == "" { @@ -195,7 +211,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) { result.Proxy = os.Getenv("PINNIPED_TEST_PROXY") result.APIGroupSuffix = wantEnv("PINNIPED_TEST_API_GROUP_SUFFIX", "pinniped.dev") - result.CLITestUpstream = TestOIDCUpstream{ + result.CLIUpstreamOIDC = TestOIDCUpstream{ Issuer: needEnv(t, "PINNIPED_TEST_CLI_OIDC_ISSUER"), CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_CLI_OIDC_ISSUER_CA_BUNDLE")), ClientID: needEnv(t, "PINNIPED_TEST_CLI_OIDC_CLIENT_ID"), @@ -204,7 +220,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) { Password: needEnv(t, "PINNIPED_TEST_CLI_OIDC_PASSWORD"), } - result.SupervisorTestUpstream = TestOIDCUpstream{ + result.SupervisorUpstreamOIDC = TestOIDCUpstream{ Issuer: needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER"), CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER_CA_BUNDLE")), AdditionalScopes: strings.Fields(os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES")), @@ -217,6 +233,21 @@ func loadEnvVars(t *testing.T, result *TestEnv) { Password: needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD"), ExpectedGroups: filterEmpty(strings.Split(strings.ReplaceAll(os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_EXPECTED_GROUPS"), " ", ""), ",")), } + + result.SupervisorUpstreamLDAP = TestLDAPUpstream{ + Host: needEnv(t, "PINNIPED_TEST_LDAP_HOST"), + CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_LDAP_LDAPS_CA_BUNDLE")), + BindUsername: needEnv(t, "PINNIPED_TEST_LDAP_BIND_ACCOUNT_USERNAME"), + BindPassword: needEnv(t, "PINNIPED_TEST_LDAP_BIND_ACCOUNT_PASSWORD"), + UserSearchBase: needEnv(t, "PINNIPED_TEST_LDAP_USERS_SEARCH_BASE"), + TestUserDN: needEnv(t, "PINNIPED_TEST_LDAP_USER_DN"), + TestUserCN: needEnv(t, "PINNIPED_TEST_LDAP_USER_CN"), + TestUserUniqueIDAttributeName: needEnv(t, "PINNIPED_TEST_LDAP_USER_UNIQUE_ID_ATTRIBUTE_NAME"), + TestUserUniqueIDAttributeValue: needEnv(t, "PINNIPED_TEST_LDAP_USER_UNIQUE_ID_ATTRIBUTE_VALUE"), + TestUserMailAttributeName: needEnv(t, "PINNIPED_TEST_LDAP_USER_EMAIL_ATTRIBUTE_NAME"), + TestUserMailAttributeValue: needEnv(t, "PINNIPED_TEST_LDAP_USER_EMAIL_ATTRIBUTE_VALUE"), + TestUserPassword: needEnv(t, "PINNIPED_TEST_LDAP_USER_PASSWORD"), + } } func (e *TestEnv) HasCapability(cap Capability) bool { diff --git a/test/library/iotest.go b/test/library/iotest.go index 7ac175b3..d103550d 100644 --- a/test/library/iotest.go +++ b/test/library/iotest.go @@ -6,6 +6,7 @@ package library import ( "fmt" "io" + "net/url" "regexp" "strings" "testing" @@ -50,3 +51,15 @@ func MaskTokens(in string) string { return fmt.Sprintf("[...%d bytes...]", len(t)) }) } + +// Remove any potentially sensitive query param and fragment values for test logging. +func RedactURLParams(fullURL *url.URL) string { + copyOfURL, _ := url.Parse(fullURL.String()) + if len(copyOfURL.RawQuery) > 0 { + copyOfURL.RawQuery = "redacted" + } + if len(copyOfURL.Fragment) > 0 { + copyOfURL.Fragment = "redacted" + } + return copyOfURL.String() +}