From 84c3c3aa9cf5d37551d5fda67137fa59357a1e9c Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 12 Aug 2021 10:00:18 -0700 Subject: [PATCH 01/21] Optionally allow OIDC password grant for CLI-based login experience - Add `AllowPasswordGrant` boolean field to OIDCIdentityProvider's spec - The oidc upstream watcher controller copies the value of `AllowPasswordGrant` into the configuration of the cached provider - Add password grant to the UpstreamOIDCIdentityProviderI interface which is implemented by the cached provider instance for use in the authorization endpoint - Enhance the IDP discovery endpoint to return the supported "flows" for each IDP ("cli_password" and/or "browser_authcode") - Enhance `pinniped get kubeconfig` to help the user choose the desired flow for the selected IDP, and to write the flow into the resulting kubeconfg - Enhance `pinniped login oidc` to have a flow flag to tell it which client-side flow it should use for auth (CLI-based or browser-based) - In the Dex config, allow the resource owner password grant, which Dex implements to also return ID tokens, for use in integration tests - Enhance the authorize endpoint to perform password grant when requested by the incoming headers. This commit does not include unit tests for the enhancements to the authorize endpoint, which will come in the next commit - Extract some shared helpers from the callback endpoint to share the code with the authorize endpoint - Add new integration tests --- .../types_oidcidentityprovider.go.tmpl | 26 +- cmd/pinniped/cmd/kubeconfig.go | 149 ++-- cmd/pinniped/cmd/kubeconfig_test.go | 385 +++++++++- cmd/pinniped/cmd/login_oidc.go | 58 +- cmd/pinniped/cmd/login_oidc_test.go | 69 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 37 +- generated/1.17/README.adoc | 3 +- .../v1alpha1/types_oidcidentityprovider.go | 26 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 37 +- generated/1.18/README.adoc | 3 +- .../v1alpha1/types_oidcidentityprovider.go | 26 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 37 +- generated/1.19/README.adoc | 3 +- .../v1alpha1/types_oidcidentityprovider.go | 26 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 37 +- generated/1.20/README.adoc | 3 +- .../v1alpha1/types_oidcidentityprovider.go | 26 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 37 +- .../v1alpha1/types_oidcidentityprovider.go | 26 +- .../oidc_upstream_watcher.go | 5 +- .../oidc_upstream_watcher_test.go | 68 +- .../mockupstreamoidcidentityprovider.go | 29 + internal/oidc/auth/auth_handler.go | 80 ++- internal/oidc/callback/callback_handler.go | 176 +---- .../downstreamsession/downstream_session.go | 177 +++++ .../idpdiscovery/idp_discovery_handler.go | 24 +- .../idp_discovery_handler_test.go | 24 +- .../provider/dynamic_upstream_idp_provider.go | 32 +- .../oidc/provider/manager/manager_test.go | 23 +- .../testutil/oidctestutil/oidctestutil.go | 10 + internal/upstreamoidc/upstreamoidc.go | 40 +- internal/upstreamoidc/upstreamoidc_test.go | 675 ++++++++++++------ pkg/oidcclient/login.go | 3 +- test/deploy/tools/dex.yaml | 2 + test/integration/e2e_test.go | 161 ++++- test/integration/supervisor_login_test.go | 45 +- 36 files changed, 2012 insertions(+), 576 deletions(-) diff --git a/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl b/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl index 21945fcf2..3211d2e58 100644 --- a/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl +++ b/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl @@ -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 v1alpha1 @@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct { // request parameters. type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. By default only the "openid" scope will be requested. + // request flow with an OIDC identity provider. + // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes + // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` + + // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a + // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. + // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be + // supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password + // Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose + // to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the + // cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be + // convenient for users, especially for identities from your OIDC provider which are not intended to represent a human + // actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, + // you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this + // OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password + // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords + // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other + // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. + // AllowPasswordGrant defaults to false. + // +optional + AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } // OIDCClaims provides a mapping from upstream claims into identities. diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 54042b348..c6abc1b6a 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -68,6 +68,7 @@ type getKubeconfigOIDCParams struct { requestAudience string upstreamIDPName string upstreamIDPType string + upstreamIDPFlow string } type getKubeconfigConciergeParams struct { @@ -110,8 +111,9 @@ type supervisorIDPsDiscoveryResponseV1Alpha1 struct { } type pinnipedIDPResponse struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` } func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { @@ -154,6 +156,7 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { 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.oidc.upstreamIDPFlow, "upstream-identity-provider-flow", "", "The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode')") 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)") @@ -243,8 +246,10 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f 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 there is an issuer, and if any upstream IDP flags are not already set, then try to discover Supervisor upstream IDP details. + // When all the upstream IDP flags are set by the user, then skip discovery and don't validate their input. Maybe they know something + // that we can't know, like the name of an IDP that they are going to define in the future. + if len(flags.oidc.issuer) > 0 && (flags.oidc.upstreamIDPType == "" || flags.oidc.upstreamIDPName == "" || flags.oidc.upstreamIDPFlow == "") { if err := discoverSupervisorUpstreamIDP(ctx, &flags); err != nil { return err } @@ -346,6 +351,9 @@ func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdap if flags.oidc.upstreamIDPType != "" { execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-type="+flags.oidc.upstreamIDPType) } + if flags.oidc.upstreamIDPFlow != "" { + execConfig.Args = append(execConfig.Args, "--upstream-identity-provider-flow="+flags.oidc.upstreamIDPFlow) + } return execConfig, nil } @@ -758,21 +766,31 @@ func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigPara return nil } - upstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient) + discoveredUpstreamIDPs, 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 + + if len(discoveredUpstreamIDPs) == 0 { + // Discovered that the Supervisor does not have any upstream IDPs defined. Continue without putting one into the + // kubeconfig. This kubeconfig will only work if the user defines one (and only one) OIDC IDP in the Supervisor + // later and wants to use the default client flow for OIDC (browser-based auth). + return nil } + + selectedIDPName, selectedIDPType, discoveredIDPFlows, err := selectUpstreamIDPNameAndType(discoveredUpstreamIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType) + if err != nil { + return err + } + + selectedIDPFlow, err := selectUpstreamIDPFlow(discoveredIDPFlows, selectedIDPName, selectedIDPType, flags.oidc.upstreamIDPFlow) + if err != nil { + return err + } + + flags.oidc.upstreamIDPName = selectedIDPName + flags.oidc.upstreamIDPType = selectedIDPType + flags.oidc.upstreamIDPFlow = selectedIDPFlow return nil } @@ -840,53 +858,104 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP return body.PinnipedIDPs, nil } -func selectUpstreamIDP(pinnipedIDPs []pinnipedIDPResponse, idpName, idpType string) (string, string, error) { +func selectUpstreamIDPNameAndType(pinnipedIDPs []pinnipedIDPResponse, specifiedIDPName, specifiedIDPType string) (string, string, []string, error) { pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) + var discoveredFlows []string switch { - case idpType != "": + case specifiedIDPName != "" && specifiedIDPType != "": + // The user specified both name and type, so check to see if there exists an exact match. + for _, idp := range pinnipedIDPs { + if idp.Name == specifiedIDPName && idp.Type == specifiedIDPType { + return specifiedIDPName, specifiedIDPType, idp.Flows, nil + } + } + return "", "", nil, fmt.Errorf( + "no Supervisor upstream identity providers with name %q of type %q were found. "+ + "Found these upstreams: %s", specifiedIDPName, specifiedIDPType, pinnipedIDPsString) + case specifiedIDPType != "": + // The user specified only a type, so check if there is only one of that type found. discoveredName := "" for _, idp := range pinnipedIDPs { - if idp.Type == idpType { + if idp.Type == specifiedIDPType { 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. "+ + return "", "", nil, fmt.Errorf( + "multiple Supervisor upstream identity providers of type %q were found, "+ + "so the --upstream-identity-provider-name flag must be specified. "+ "Found these upstreams: %s", - idpType, pinnipedIDPsString) + specifiedIDPType, pinnipedIDPsString) } discoveredName = idp.Name + discoveredFlows = idp.Flows } } if discoveredName == "" { - return "", "", fmt.Errorf( - "no Supervisor upstream identity providers of type \"%s\" were found."+ - " Found these upstreams: %s", idpType, pinnipedIDPsString) + return "", "", nil, fmt.Errorf( + "no Supervisor upstream identity providers of type %q were found. "+ + "Found these upstreams: %s", specifiedIDPType, pinnipedIDPsString) } - return discoveredName, idpType, nil - case idpName != "": + return discoveredName, specifiedIDPType, discoveredFlows, nil + case specifiedIDPName != "": + // The user specified only a name, so check if there is only one of that name found. discoveredType := "" for _, idp := range pinnipedIDPs { - if idp.Name == idpName { + if idp.Name == specifiedIDPName { 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) + return "", "", nil, fmt.Errorf( + "multiple Supervisor upstream identity providers with name %q were found, "+ + "so the --upstream-identity-provider-type flag must be specified. Found these upstreams: %s", + specifiedIDPName, pinnipedIDPsString) } discoveredType = idp.Type + discoveredFlows = idp.Flows } } if discoveredType == "" { - return "", "", fmt.Errorf( - "no Supervisor upstream identity providers with name \"%s\" were found."+ - " Found these upstreams: %s", idpName, pinnipedIDPsString) + return "", "", nil, fmt.Errorf( + "no Supervisor upstream identity providers with name %q were found. "+ + "Found these upstreams: %s", specifiedIDPName, pinnipedIDPsString) } - return idpName, discoveredType, nil + return specifiedIDPName, discoveredType, discoveredFlows, nil + case len(pinnipedIDPs) == 1: + // The user did not specify any name or type, but there is only one found, so select it. + return pinnipedIDPs[0].Name, pinnipedIDPs[0].Type, pinnipedIDPs[0].Flows, 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", + // The user did not specify any name or type, and there is more than one found. + return "", "", nil, 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) } } + +func selectUpstreamIDPFlow(discoveredIDPFlows []string, selectedIDPName string, selectedIDPType string, specifiedFlow string) (string, error) { + switch { + case len(discoveredIDPFlows) == 0: + // No flows listed by discovery means that we are talking to an old Supervisor from before this feature existed. + // If the user specified a flow on the CLI flag then use it without validation, otherwise skip flow selection + // and return empty string. + return specifiedFlow, nil + case specifiedFlow != "": + // The user specified a flow, so validate that it is available for the selected IDP. + for _, flow := range discoveredIDPFlows { + if flow == specifiedFlow { + // Found it, so use it as specified by the user. + return specifiedFlow, nil + } + } + return "", fmt.Errorf( + "no client flow %q for Supervisor upstream identity provider %q of type %q were found. "+ + "Found these flows: %v", + specifiedFlow, selectedIDPName, selectedIDPType, discoveredIDPFlows) + case len(discoveredIDPFlows) == 1: + // The user did not specify a flow, but there is only one found, so select it. + return discoveredIDPFlows[0], nil + default: + // The user did not specify a flow, and more than one was found. + return "", fmt.Errorf( + "multiple client flows for Supervisor upstream identity provider %q of type %q were found, "+ + "so the --upstream-identity-provider-flow flag must be specified. "+ + "Found these flows: %v", + selectedIDPName, selectedIDPType, discoveredIDPFlows) + } +} diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index cb9f7b837..5cf79148c 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -149,6 +149,7 @@ func TestGetKubeconfig(t *testing.T) { --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-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode') --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') `) @@ -814,7 +815,7 @@ func TestGetKubeconfig(t *testing.T) { }, }, { - name: "when IDP discovery document contains multiple pinniped_idps and no name or type flags are given", + name: "when IDP discovery document contains multiple IDPs and no name or type flags are given", args: func(issuerCABundle string, issuerURL string) []string { return []string{ "--kubeconfig", "./testdata/kubeconfig.yaml", @@ -1033,6 +1034,33 @@ func TestGetKubeconfig(t *testing.T) { 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 does not find matching IDP when name and type are both specified", + 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", "does-not-exist-idp", + "--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"} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: no Supervisor upstream identity providers with name "does-not-exist-idp" of type "ldap" were found.` + + ` Found these upstreams: [{"name":"some-ldap-idp","type":"ldap"},{"name":"some-other-ldap-idp","type":"ldap"}]` + "\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 { @@ -1091,7 +1119,7 @@ func TestGetKubeconfig(t *testing.T) { }, }, { - name: "supervisor upstream IDP discovery fails to find any matching idps when type is specified but name is not", + 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{ @@ -1117,7 +1145,32 @@ func TestGetKubeconfig(t *testing.T) { }, }, { - name: "supervisor upstream IDP discovery fails to find any matching idps when name is specified but type is not", + name: "supervisor upstream IDP discovery fails to find any matching IDPs when type is specified but name is not and there is only one IDP found", + 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"} + ] + }`), + 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"}]` + "\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{ @@ -1142,6 +1195,80 @@ func TestGetKubeconfig(t *testing.T) { ` 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 and there is only one IDP found", + 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"} + ] + }`), + 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"}]` + "\n" + }, + }, + { + name: "supervisor upstream IDP discovery when flow is specified but it does not match any flow returned by discovery", + 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-flow", "my-nonexistent-flow", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-oidc-idp", "type": "oidc", "flows": ["non-matching-flow-1", "non-matching-flow-2"]} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: no client flow "my-nonexistent-flow" for Supervisor upstream identity provider "some-oidc-idp" of type "oidc" were found.` + + ` Found these flows: [non-matching-flow-1 non-matching-flow-2]` + "\n" + }, + }, + { + name: "supervisor upstream IDP discovery when no flow is specified and more than one flow is returned by discovery", + 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-oidc-idp", "type": "oidc", "flows": ["flow1", "flow2"]} + ] + }`), + wantError: true, + wantStderr: func(issuerCABundle string, issuerURL string) string { + return `Error: multiple client flows for Supervisor upstream identity provider "some-oidc-idp" of type "oidc" were found, so the --upstream-identity-provider-flow flag must be specified.` + + ` Found these flows: [flow1 flow2]` + "\n" + }, + }, { name: "valid static token", args: func(issuerCABundle string, issuerURL string) []string { @@ -1535,7 +1662,7 @@ func TestGetKubeconfig(t *testing.T) { }, }, { - name: "autodetect impersonation proxy with autodiscovered JWT authenticator", + name: "autodetect impersonation proxy with auto-discovered JWT authenticator", args: func(issuerCABundle string, issuerURL string) []string { return []string{ "--kubeconfig", "./testdata/kubeconfig.yaml", @@ -1958,7 +2085,7 @@ func TestGetKubeconfig(t *testing.T) { } }`, issuerURL) }, - idpsDiscoveryStatusCode: http.StatusBadRequest, // IDPs endpoint shouldn't be called by this test + idpsDiscoveryStatusCode: http.StatusBadRequest, // IDP discovery 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"`, @@ -2015,13 +2142,14 @@ func TestGetKubeconfig(t *testing.T) { }, }, { - name: "when upstream idp related flags are sent, pass them through", + name: "when all upstream IDP related flags are sent, pass them through without performing IDP discovery", 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", + "--upstream-identity-provider-flow=foobar", } }, conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { @@ -2030,7 +2158,7 @@ func TestGetKubeconfig(t *testing.T) { jwtAuthenticator(issuerCABundle, issuerURL), } }, - oidcDiscoveryStatusCode: http.StatusNotFound, + oidcDiscoveryStatusCode: http.StatusNotFound, // should not get called by the client in this case wantLogs: func(issuerCABundle string, issuerURL string) []string { return []string{ `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, @@ -2080,6 +2208,7 @@ func TestGetKubeconfig(t *testing.T) { - --request-audience=test-audience - --upstream-identity-provider-name=some-oidc-idp - --upstream-identity-provider-type=oidc + - --upstream-identity-provider-flow=foobar command: '.../path/to/pinniped' env: [] provideClusterInfo: true @@ -2089,13 +2218,14 @@ func TestGetKubeconfig(t *testing.T) { }, }, { - name: "when upstream IDP related flags are sent, pass them through even when IDP discovery shows a different IDP", + name: "when all 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", + "--upstream-identity-provider-flow=foobar", } }, conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object { @@ -2159,6 +2289,7 @@ func TestGetKubeconfig(t *testing.T) { - --request-audience=test-audience - --upstream-identity-provider-name=some-oidc-idp - --upstream-identity-provider-type=oidc + - --upstream-identity-provider-flow=foobar command: '.../path/to/pinniped' env: [] provideClusterInfo: true @@ -2341,6 +2472,244 @@ func TestGetKubeconfig(t *testing.T) { base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) }, }, + { + name: "supervisor upstream IDP discovery when both name and type are specified but flow is not and a matching IDP is found", + 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", + "--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 when flow is specified and no flows were returned by discovery uses the specified flow", + 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-flow", "foobar", + "--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 + - --upstream-identity-provider-flow=foobar + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "supervisor upstream IDP discovery when flow is specified and it matches a flow returned by discovery uses the specified flow", + 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-flow", "cli_password", + "--upstream-identity-provider-type", "ldap", + } + }, + oidcDiscoveryResponse: happyOIDCDiscoveryResponse, + idpsDiscoveryResponse: here.Docf(`{ + "pinniped_identity_providers": [ + {"name": "some-ldap-idp", "type": "ldap", "flows": ["some_flow", "cli_password", "some_other_flow"]} + ] + }`), + 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 + - --upstream-identity-provider-flow=cli_password + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, + { + name: "supervisor upstream IDP discovery when no flow is specified but there is only one flow returned by discovery uses the discovered flow", + 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", "flows": ["cli_password"]} + ] + }`), + 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 + - --upstream-identity-provider-flow=cli_password + command: '.../path/to/pinniped' + env: [] + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + }, } for _, tt := range tests { tt := tt diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 9141d26ae..25ebcc6d4 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -37,6 +38,13 @@ func init() { loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps())) } +const ( + idpTypeOIDC = "oidc" + idpTypeLDAP = "ldap" + idpFlowCLIPassword = "cli_password" + idpFlowBrowserAuthcode = "browser_authcode" +) + type oidcLoginCommandDeps struct { lookupEnv func(string) (string, bool) login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error) @@ -74,6 +82,7 @@ type oidcLoginFlags struct { credentialCachePath string upstreamIdentityProviderName string upstreamIdentityProviderType string + upstreamIdentityProviderFlow string } func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { @@ -107,7 +116,8 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { 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')") + cmd.Flags().StringVar(&flags.upstreamIdentityProviderType, "upstream-identity-provider-type", idpTypeOIDC, fmt.Sprintf("The type of the upstream identity provider used during login with a Supervisor (e.g. '%s', '%s')", idpTypeOIDC, idpTypeLDAP)) + cmd.Flags().StringVar(&flags.upstreamIdentityProviderFlow, "upstream-identity-provider-flow", "", fmt.Sprintf("The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. '%s', '%s')", idpFlowBrowserAuthcode, idpFlowCLIPassword)) // --skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case. mustMarkHidden(cmd, "skip-listen") @@ -160,17 +170,11 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin 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) + flowOpts, err := flowOptions(flags.upstreamIdentityProviderType, flags.upstreamIdentityProviderFlow) + if err != nil { + return err } + opts = append(opts, flowOpts...) var concierge *conciergeclient.Client if flags.conciergeEnabled { @@ -251,6 +255,38 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) } +func flowOptions(requestedIDPType string, requestedFlow string) ([]oidcclient.Option, error) { + useCLIFlow := []oidcclient.Option{oidcclient.WithCLISendingCredentials()} + + switch requestedIDPType { + case idpTypeOIDC: + switch requestedFlow { + case idpFlowCLIPassword: + return useCLIFlow, nil + case idpFlowBrowserAuthcode, "": + return nil, nil // browser authcode flow is the default Option, so don't need to return an Option here + default: + return nil, fmt.Errorf( + "--upstream-identity-provider-flow value not recognized for identity provider type %q: %s (supported values: %s)", + requestedIDPType, requestedFlow, strings.Join([]string{idpFlowBrowserAuthcode, idpFlowCLIPassword}, ", ")) + } + case idpTypeLDAP: + switch requestedFlow { + case idpFlowCLIPassword, "": + return useCLIFlow, nil + default: + return nil, fmt.Errorf( + "--upstream-identity-provider-flow value not recognized for identity provider type %q: %s (supported values: %s)", + requestedIDPType, requestedFlow, []string{idpFlowCLIPassword}) + } + default: + // Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236 + return nil, fmt.Errorf( + "--upstream-identity-provider-type value not recognized: %s (supported values: %s)", + requestedIDPType, strings.Join([]string{idpTypeOIDC, idpTypeLDAP}, ", ")) + } +} + func makeClient(caBundlePaths []string, caBundleData []string) (*http.Client, error) { pool := x509.NewCertPool() for _, p := range caBundlePaths { diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 055dcec68..1064f8b27 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -77,6 +77,7 @@ func TestLoginOIDCCommand(t *testing.T) { --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-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'browser_authcode', 'cli_password') --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") `), @@ -152,7 +153,7 @@ func TestLoginOIDCCommand(t *testing.T) { `), }, { - name: "oidc upstream type is allowed", + name: "oidc upstream type with default flow is allowed", args: []string{ "--issuer", "test-issuer", "--client-id", "test-client-id", @@ -163,7 +164,45 @@ func TestLoginOIDCCommand(t *testing.T) { 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", + name: "oidc upstream type with CLI flow is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "oidc", + "--upstream-identity-provider-flow", "cli_password", + "--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: "oidc upstream type with browser flow is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "oidc", + "--upstream-identity-provider-flow", "browser_authcode", + "--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: "oidc upstream type with unsupported flow is an error", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "oidc", + "--upstream-identity-provider-flow", "foobar", + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution + }, + wantError: true, + wantStderr: here.Doc(` + Error: --upstream-identity-provider-flow value not recognized for identity provider type "oidc": foobar (supported values: browser_authcode, cli_password) + `), + }, + { + name: "ldap upstream type with default flow is allowed", args: []string{ "--issuer", "test-issuer", "--client-id", "test-client-id", @@ -173,6 +212,32 @@ func TestLoginOIDCCommand(t *testing.T) { 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: "ldap upstream type with CLI flow is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "ldap", + "--upstream-identity-provider-flow", "cli_password", + "--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: "ldap upstream type with unsupported flow is an error", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "ldap", + "--upstream-identity-provider-flow", "browser_authcode", // "browser_authcode" is only supported for OIDC upstreams + "--credential-cache", "", // must specify --credential-cache or else the cache file on disk causes test pollution + }, + wantError: true, + wantStderr: here.Doc(` + Error: --upstream-identity-provider-flow value not recognized for identity provider type "ldap": browser_authcode (supported values: [cli_password]) + `), + }, { name: "login error", args: []string{ diff --git a/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 75cf37d05..d9ea45f01 100644 --- a/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -59,11 +59,44 @@ spec: additionalScopes: description: AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request - flow with an OIDC identity provider. By default only the "openid" - scope will be requested. + flow with an OIDC identity provider. In the case of a Resource + Owner Password Credentials Grant flow, AdditionalScopes are + the scopes in addition to "openid" that will be requested as + part of the token request (see also the AllowPasswordGrant field). + By default, only the "openid" scope will be requested. items: type: string type: array + allowPasswordGrant: + description: AllowPasswordGrant, when true, will allow the use + of OAuth 2.0's Resource Owner Password Credentials Grant (see + https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to + authenticate to the OIDC provider using a username and password + without a web browser, in addition to the usual browser-based + OIDC Authorization Code Flow. The Resource Owner Password Credentials + Grant is not officially part of the OIDC specification, so it + may not be supported by your OIDC provider. If your OIDC provider + supports returning ID tokens from a Resource Owner Password + Credentials Grant token request, then you can choose to set + this field to true. This will allow end users to choose to present + their username and password to the kubectl CLI (using the Pinniped + plugin) to authenticate to the cluster, without using a web + browser to log in as is customary in OIDC Authorization Code + Flow. This may be convenient for users, especially for identities + from your OIDC provider which are not intended to represent + a human actor, such as service accounts performing actions in + a CI/CD environment. Even if your OIDC provider supports it, + you may wish to disable this behavior by setting this field + to false when you prefer to only allow users of this OIDCIdentityProvider + to log in via the browser-based OIDC Authorization Code Flow. + Using the Resource Owner Password Credentials Grant means that + the Pinniped CLI and Pinniped Supervisor will directly handle + your end users' passwords (similar to LDAPIdentityProvider), + and you will not be able to require multi-factor authentication + or use the other web-based login features of your OIDC provider + during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + defaults to false. + type: boolean type: object claims: description: Claims provides the names of token claims that will be diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc index cb311448b..b709175f2 100644 --- a/generated/1.17/README.adoc +++ b/generated/1.17/README.adoc @@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 21945fcf2..3211d2e58 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.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 v1alpha1 @@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct { // request parameters. type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. By default only the "openid" scope will be requested. + // request flow with an OIDC identity provider. + // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes + // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` + + // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a + // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. + // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be + // supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password + // Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose + // to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the + // cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be + // convenient for users, especially for identities from your OIDC provider which are not intended to represent a human + // actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, + // you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this + // OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password + // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords + // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other + // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. + // AllowPasswordGrant defaults to false. + // +optional + AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } // OIDCClaims provides a mapping from upstream claims into identities. diff --git a/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 75cf37d05..d9ea45f01 100644 --- a/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -59,11 +59,44 @@ spec: additionalScopes: description: AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request - flow with an OIDC identity provider. By default only the "openid" - scope will be requested. + flow with an OIDC identity provider. In the case of a Resource + Owner Password Credentials Grant flow, AdditionalScopes are + the scopes in addition to "openid" that will be requested as + part of the token request (see also the AllowPasswordGrant field). + By default, only the "openid" scope will be requested. items: type: string type: array + allowPasswordGrant: + description: AllowPasswordGrant, when true, will allow the use + of OAuth 2.0's Resource Owner Password Credentials Grant (see + https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to + authenticate to the OIDC provider using a username and password + without a web browser, in addition to the usual browser-based + OIDC Authorization Code Flow. The Resource Owner Password Credentials + Grant is not officially part of the OIDC specification, so it + may not be supported by your OIDC provider. If your OIDC provider + supports returning ID tokens from a Resource Owner Password + Credentials Grant token request, then you can choose to set + this field to true. This will allow end users to choose to present + their username and password to the kubectl CLI (using the Pinniped + plugin) to authenticate to the cluster, without using a web + browser to log in as is customary in OIDC Authorization Code + Flow. This may be convenient for users, especially for identities + from your OIDC provider which are not intended to represent + a human actor, such as service accounts performing actions in + a CI/CD environment. Even if your OIDC provider supports it, + you may wish to disable this behavior by setting this field + to false when you prefer to only allow users of this OIDCIdentityProvider + to log in via the browser-based OIDC Authorization Code Flow. + Using the Resource Owner Password Credentials Grant means that + the Pinniped CLI and Pinniped Supervisor will directly handle + your end users' passwords (similar to LDAPIdentityProvider), + and you will not be able to require multi-factor authentication + or use the other web-based login features of your OIDC provider + during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + defaults to false. + type: boolean type: object claims: description: Claims provides the names of token claims that will be diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc index ade6ea1b2..95ed5d611 100644 --- a/generated/1.18/README.adoc +++ b/generated/1.18/README.adoc @@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 21945fcf2..3211d2e58 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.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 v1alpha1 @@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct { // request parameters. type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. By default only the "openid" scope will be requested. + // request flow with an OIDC identity provider. + // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes + // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` + + // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a + // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. + // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be + // supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password + // Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose + // to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the + // cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be + // convenient for users, especially for identities from your OIDC provider which are not intended to represent a human + // actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, + // you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this + // OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password + // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords + // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other + // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. + // AllowPasswordGrant defaults to false. + // +optional + AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } // OIDCClaims provides a mapping from upstream claims into identities. diff --git a/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 75cf37d05..d9ea45f01 100644 --- a/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -59,11 +59,44 @@ spec: additionalScopes: description: AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request - flow with an OIDC identity provider. By default only the "openid" - scope will be requested. + flow with an OIDC identity provider. In the case of a Resource + Owner Password Credentials Grant flow, AdditionalScopes are + the scopes in addition to "openid" that will be requested as + part of the token request (see also the AllowPasswordGrant field). + By default, only the "openid" scope will be requested. items: type: string type: array + allowPasswordGrant: + description: AllowPasswordGrant, when true, will allow the use + of OAuth 2.0's Resource Owner Password Credentials Grant (see + https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to + authenticate to the OIDC provider using a username and password + without a web browser, in addition to the usual browser-based + OIDC Authorization Code Flow. The Resource Owner Password Credentials + Grant is not officially part of the OIDC specification, so it + may not be supported by your OIDC provider. If your OIDC provider + supports returning ID tokens from a Resource Owner Password + Credentials Grant token request, then you can choose to set + this field to true. This will allow end users to choose to present + their username and password to the kubectl CLI (using the Pinniped + plugin) to authenticate to the cluster, without using a web + browser to log in as is customary in OIDC Authorization Code + Flow. This may be convenient for users, especially for identities + from your OIDC provider which are not intended to represent + a human actor, such as service accounts performing actions in + a CI/CD environment. Even if your OIDC provider supports it, + you may wish to disable this behavior by setting this field + to false when you prefer to only allow users of this OIDCIdentityProvider + to log in via the browser-based OIDC Authorization Code Flow. + Using the Resource Owner Password Credentials Grant means that + the Pinniped CLI and Pinniped Supervisor will directly handle + your end users' passwords (similar to LDAPIdentityProvider), + and you will not be able to require multi-factor authentication + or use the other web-based login features of your OIDC provider + during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + defaults to false. + type: boolean type: object claims: description: Claims provides the names of token claims that will be diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc index 7f47bdeb1..94587807c 100644 --- a/generated/1.19/README.adoc +++ b/generated/1.19/README.adoc @@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 21945fcf2..3211d2e58 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.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 v1alpha1 @@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct { // request parameters. type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. By default only the "openid" scope will be requested. + // request flow with an OIDC identity provider. + // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes + // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` + + // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a + // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. + // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be + // supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password + // Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose + // to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the + // cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be + // convenient for users, especially for identities from your OIDC provider which are not intended to represent a human + // actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, + // you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this + // OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password + // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords + // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other + // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. + // AllowPasswordGrant defaults to false. + // +optional + AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } // OIDCClaims provides a mapping from upstream claims into identities. diff --git a/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 75cf37d05..d9ea45f01 100644 --- a/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -59,11 +59,44 @@ spec: additionalScopes: description: AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request - flow with an OIDC identity provider. By default only the "openid" - scope will be requested. + flow with an OIDC identity provider. In the case of a Resource + Owner Password Credentials Grant flow, AdditionalScopes are + the scopes in addition to "openid" that will be requested as + part of the token request (see also the AllowPasswordGrant field). + By default, only the "openid" scope will be requested. items: type: string type: array + allowPasswordGrant: + description: AllowPasswordGrant, when true, will allow the use + of OAuth 2.0's Resource Owner Password Credentials Grant (see + https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to + authenticate to the OIDC provider using a username and password + without a web browser, in addition to the usual browser-based + OIDC Authorization Code Flow. The Resource Owner Password Credentials + Grant is not officially part of the OIDC specification, so it + may not be supported by your OIDC provider. If your OIDC provider + supports returning ID tokens from a Resource Owner Password + Credentials Grant token request, then you can choose to set + this field to true. This will allow end users to choose to present + their username and password to the kubectl CLI (using the Pinniped + plugin) to authenticate to the cluster, without using a web + browser to log in as is customary in OIDC Authorization Code + Flow. This may be convenient for users, especially for identities + from your OIDC provider which are not intended to represent + a human actor, such as service accounts performing actions in + a CI/CD environment. Even if your OIDC provider supports it, + you may wish to disable this behavior by setting this field + to false when you prefer to only allow users of this OIDCIdentityProvider + to log in via the browser-based OIDC Authorization Code Flow. + Using the Resource Owner Password Credentials Grant means that + the Pinniped CLI and Pinniped Supervisor will directly handle + your end users' passwords (similar to LDAPIdentityProvider), + and you will not be able to require multi-factor authentication + or use the other web-based login features of your OIDC provider + during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + defaults to false. + type: boolean type: object claims: description: Claims provides the names of token claims that will be diff --git a/generated/1.20/README.adoc b/generated/1.20/README.adoc index 1bcf7d081..dba89ffcd 100644 --- a/generated/1.20/README.adoc +++ b/generated/1.20/README.adoc @@ -947,7 +947,8 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. By default only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 21945fcf2..3211d2e58 100644 --- a/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.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 v1alpha1 @@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct { // request parameters. type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. By default only the "openid" scope will be requested. + // request flow with an OIDC identity provider. + // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes + // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` + + // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a + // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. + // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be + // supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password + // Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose + // to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the + // cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be + // convenient for users, especially for identities from your OIDC provider which are not intended to represent a human + // actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, + // you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this + // OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password + // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords + // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other + // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. + // AllowPasswordGrant defaults to false. + // +optional + AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } // OIDCClaims provides a mapping from upstream claims into identities. diff --git a/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 75cf37d05..d9ea45f01 100644 --- a/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -59,11 +59,44 @@ spec: additionalScopes: description: AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request - flow with an OIDC identity provider. By default only the "openid" - scope will be requested. + flow with an OIDC identity provider. In the case of a Resource + Owner Password Credentials Grant flow, AdditionalScopes are + the scopes in addition to "openid" that will be requested as + part of the token request (see also the AllowPasswordGrant field). + By default, only the "openid" scope will be requested. items: type: string type: array + allowPasswordGrant: + description: AllowPasswordGrant, when true, will allow the use + of OAuth 2.0's Resource Owner Password Credentials Grant (see + https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to + authenticate to the OIDC provider using a username and password + without a web browser, in addition to the usual browser-based + OIDC Authorization Code Flow. The Resource Owner Password Credentials + Grant is not officially part of the OIDC specification, so it + may not be supported by your OIDC provider. If your OIDC provider + supports returning ID tokens from a Resource Owner Password + Credentials Grant token request, then you can choose to set + this field to true. This will allow end users to choose to present + their username and password to the kubectl CLI (using the Pinniped + plugin) to authenticate to the cluster, without using a web + browser to log in as is customary in OIDC Authorization Code + Flow. This may be convenient for users, especially for identities + from your OIDC provider which are not intended to represent + a human actor, such as service accounts performing actions in + a CI/CD environment. Even if your OIDC provider supports it, + you may wish to disable this behavior by setting this field + to false when you prefer to only allow users of this OIDCIdentityProvider + to log in via the browser-based OIDC Authorization Code Flow. + Using the Resource Owner Password Credentials Grant means that + the Pinniped CLI and Pinniped Supervisor will directly handle + your end users' passwords (similar to LDAPIdentityProvider), + and you will not be able to require multi-factor authentication + or use the other web-based login features of your OIDC provider + during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + defaults to false. + type: boolean type: object claims: description: Claims provides the names of token claims that will be diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 21945fcf2..3211d2e58 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.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 v1alpha1 @@ -39,9 +39,31 @@ type OIDCIdentityProviderStatus struct { // request parameters. type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. By default only the "openid" scope will be requested. + // request flow with an OIDC identity provider. + // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes + // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` + + // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a + // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. + // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be + // supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password + // Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose + // to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the + // cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be + // convenient for users, especially for identities from your OIDC provider which are not intended to represent a human + // actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, + // you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this + // OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password + // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords + // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other + // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. + // AllowPasswordGrant defaults to false. + // +optional + AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } // OIDCClaims provides a mapping from upstream claims into identities. diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go index 889d66fc0..9b1e76532 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go @@ -172,8 +172,9 @@ func (c *oidcWatcherController) validateUpstream(ctx controllerlib.Context, upst Config: &oauth2.Config{ Scopes: computeScopes(upstream.Spec.AuthorizationConfig.AdditionalScopes), }, - UsernameClaim: upstream.Spec.Claims.Username, - GroupsClaim: upstream.Spec.Claims.Groups, + UsernameClaim: upstream.Spec.Claims.Username, + GroupsClaim: upstream.Spec.Claims.Groups, + AllowPasswordGrant: upstream.Spec.AuthorizationConfig.AllowPasswordGrant, } conditions := []*v1alpha1.Condition{ c.validateSecret(upstream, &result), diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go index 8712af26e..e18435c2f 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go @@ -506,15 +506,18 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }}, }, { - name: "upstream becomes valid", + name: "upstream with error becomes valid", inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL, - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, - Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: append(testAdditionalScopes, "xyz", "openid"), + AllowPasswordGrant: true, + }, + Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, }, Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", @@ -535,12 +538,13 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ &oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: testName, - ClientID: testClientID, - AuthorizationURL: *testIssuerAuthorizeURL, - Scopes: append(testExpectedScopes, "xyz"), - UsernameClaim: testUsernameClaim, - GroupsClaim: testGroupsClaim, + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: append(testExpectedScopes, "xyz"), + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: true, }, }, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -559,11 +563,14 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL, - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, - Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: testAdditionalScopes, + AllowPasswordGrant: false, + }, + Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, }, Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Ready", @@ -584,12 +591,13 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ &oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: testName, - ClientID: testClientID, - AuthorizationURL: *testIssuerAuthorizeURL, - Scopes: testExpectedScopes, - UsernameClaim: testUsernameClaim, - GroupsClaim: testGroupsClaim, + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: testExpectedScopes, + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: false, }, }, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -633,12 +641,13 @@ Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": u }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ &oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: testName, - ClientID: testClientID, - AuthorizationURL: *testIssuerAuthorizeURL, - Scopes: testExpectedScopes, - UsernameClaim: testUsernameClaim, - GroupsClaim: testGroupsClaim, + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: testExpectedScopes, + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: false, }, }, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ @@ -797,6 +806,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs require.Equal(t, tt.wantResultingCache[i].GetAuthorizationURL().String(), actualIDP.GetAuthorizationURL().String()) require.Equal(t, tt.wantResultingCache[i].GetUsernameClaim(), actualIDP.GetUsernameClaim()) require.Equal(t, tt.wantResultingCache[i].GetGroupsClaim(), actualIDP.GetGroupsClaim()) + require.Equal(t, tt.wantResultingCache[i].AllowsPasswordGrant(), actualIDP.AllowsPasswordGrant()) require.ElementsMatch(t, tt.wantResultingCache[i].GetScopes(), actualIDP.GetScopes()) // We always want to use the proxy from env on these clients, so although the following assertions diff --git a/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go b/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go index 0414fd413..152f33e24 100644 --- a/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go +++ b/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go @@ -43,6 +43,20 @@ func (m *MockUpstreamOIDCIdentityProviderI) EXPECT() *MockUpstreamOIDCIdentityPr return m.recorder } +// AllowsPasswordGrant mocks base method. +func (m *MockUpstreamOIDCIdentityProviderI) AllowsPasswordGrant() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AllowsPasswordGrant") + ret0, _ := ret[0].(bool) + return ret0 +} + +// AllowsPasswordGrant indicates an expected call of AllowsPasswordGrant. +func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) AllowsPasswordGrant() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllowsPasswordGrant", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).AllowsPasswordGrant)) +} + // ExchangeAuthcodeAndValidateTokens mocks base method. func (m *MockUpstreamOIDCIdentityProviderI) ExchangeAuthcodeAndValidateTokens(arg0 context.Context, arg1 string, arg2 pkce.Code, arg3 nonce.Nonce, arg4 string) (*oidctypes.Token, error) { m.ctrl.T.Helper() @@ -142,6 +156,21 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetUsernameClaim() *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsernameClaim", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetUsernameClaim)) } +// PasswordCredentialsGrantAndValidateTokens mocks base method. +func (m *MockUpstreamOIDCIdentityProviderI) PasswordCredentialsGrantAndValidateTokens(arg0 context.Context, arg1, arg2 string) (*oidctypes.Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PasswordCredentialsGrantAndValidateTokens", arg0, arg1, arg2) + ret0, _ := ret[0].(*oidctypes.Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PasswordCredentialsGrantAndValidateTokens indicates an expected call of PasswordCredentialsGrantAndValidateTokens. +func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) PasswordCredentialsGrantAndValidateTokens(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCredentialsGrantAndValidateTokens", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).PasswordCredentialsGrantAndValidateTokens), arg0, arg1, arg2) +} + // ValidateToken mocks base method. func (m *MockUpstreamOIDCIdentityProviderI) ValidateToken(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce) (*oidctypes.Token, error) { m.ctrl.T.Helper() diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 0f21ad051..9a2632051 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -59,7 +59,12 @@ func NewHandler( } if oidcUpstream != nil { - return handleAuthRequestForOIDCUpstream(r, w, + if len(r.Header.Values(CustomUsernameHeaderName)) > 0 { + // The client set a username header, so they are trying to log in with a username/password. + // TODO unit test this + return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) + } + return handleAuthRequestForOIDCUpstreamAuthcodeGrant(r, w, oauthHelperWithoutStorage, generateCSRF, generateNonce, generatePKCE, oidcUpstream, @@ -86,13 +91,8 @@ func handleAuthRequestForLDAPUpstream( 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) + username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) + if !hadUsernamePasswordValues { return nil } @@ -128,7 +128,69 @@ func handleAuthRequestForLDAPUpstream( return nil } -func handleAuthRequestForOIDCUpstream( +func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { + 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 "", "", false + } + return username, password, true +} + +func handleAuthRequestForOIDCUpstreamPasswordGrant( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + oidcUpstream provider.UpstreamOIDCIdentityProviderI, +) error { + authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) + if !created { + return nil + } + + username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) + if !hadUsernamePasswordValues { + return nil + } + + token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) + if err != nil { + // Return an error according to OIDC spec 3.1.2.6 (second paragraph). + // TODO do not return the full details of the error to the client, but let them know if it is because password grants are disallowed + err := errors.WithStack(fosite.ErrAccessDenied.WithHintf(err.Error())) + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil + } + + subject, username, err := downstreamsession.GetSubjectAndUsernameFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) + if err != nil { + return err + } + + groups, err := downstreamsession.GetGroupsFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) + if err != nil { + return err + } + + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) + + authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) + if err != nil { + plog.WarningErr("error while generating and saving authcode", err, "upstreamName", oidcUpstream.GetName()) + return httperr.Wrap(http.StatusInternalServerError, "error while generating and saving authcode", err) + } + + oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) + + return nil +} + +func handleAuthRequestForOIDCUpstreamAuthcodeGrant( r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index 8b9ab93ef..e4912da72 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -6,7 +6,6 @@ package callback import ( "crypto/subtle" - "fmt" "net/http" "net/url" @@ -22,14 +21,6 @@ import ( "go.pinniped.dev/internal/plog" ) -const ( - // The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - emailClaimName = "email" - - // The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - emailVerifiedClaimName = "email_verified" -) - func NewHandler( upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister, oauthHelper fosite.OAuth2Provider, @@ -77,12 +68,12 @@ func NewHandler( return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens") } - subject, username, err := getSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) + subject, username, err := downstreamsession.GetSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) if err != nil { return err } - groups, err := getGroupsFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) + groups, err := downstreamsession.GetGroupsFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) if err != nil { return err } @@ -182,166 +173,3 @@ func readState(r *http.Request, stateDecoder oidc.Decoder) (*oidc.UpstreamStateP return &state, nil } - -func getSubjectAndUsernameFromUpstreamIDToken( - upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, - idTokenClaims map[string]interface{}, -) (string, string, error) { - // The spec says the "sub" claim is only unique per issuer, - // so we will prepend the issuer string to make it globally unique. - upstreamIssuer := idTokenClaims[oidc.IDTokenIssuerClaim] - if upstreamIssuer == "" { - plog.Warning( - "issuer claim in upstream ID token missing", - "upstreamName", upstreamIDPConfig.GetName(), - "issClaim", upstreamIssuer, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token missing") - } - upstreamIssuerAsString, ok := upstreamIssuer.(string) - if !ok { - plog.Warning( - "issuer claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - "issClaim", upstreamIssuer, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token has invalid format") - } - - subjectAsInterface, ok := idTokenClaims[oidc.IDTokenSubjectClaim] - if !ok { - plog.Warning( - "no subject claim in upstream ID token", - "upstreamName", upstreamIDPConfig.GetName(), - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "no subject claim in upstream ID token") - } - - upstreamSubject, ok := subjectAsInterface.(string) - if !ok { - plog.Warning( - "subject claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "subject claim in upstream ID token has invalid format") - } - - subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString, upstreamSubject) - - usernameClaimName := upstreamIDPConfig.GetUsernameClaim() - if usernameClaimName == "" { - return subject, subject, nil - } - - // If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified" - // claim is present, then validate that the "email_verified" claim is true. - emailVerifiedAsInterface, ok := idTokenClaims[emailVerifiedClaimName] - if usernameClaimName == emailClaimName && ok { - emailVerified, ok := emailVerifiedAsInterface.(bool) - if !ok { - plog.Warning( - "username claim configured as \"email\" and upstream email_verified claim is not a boolean", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredUsernameClaim", usernameClaimName, - "emailVerifiedClaim", emailVerifiedAsInterface, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format") - } - if !emailVerified { - plog.Warning( - "username claim configured as \"email\" and upstream email_verified claim has false value", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredUsernameClaim", usernameClaimName, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value") - } - } - - usernameAsInterface, ok := idTokenClaims[usernameClaimName] - if !ok { - plog.Warning( - "no username claim in upstream ID token", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredUsernameClaim", usernameClaimName, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token") - } - - username, ok := usernameAsInterface.(string) - if !ok { - plog.Warning( - "username claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredUsernameClaim", usernameClaimName, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format") - } - - return subject, username, nil -} - -func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string { - return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject)) -} - -func getGroupsFromUpstreamIDToken( - upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, - idTokenClaims map[string]interface{}, -) ([]string, error) { - groupsClaimName := upstreamIDPConfig.GetGroupsClaim() - if groupsClaimName == "" { - return nil, nil - } - - groupsAsInterface, ok := idTokenClaims[groupsClaimName] - if !ok { - plog.Warning( - "no groups claim in upstream ID token", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredGroupsClaim", groupsClaimName, - ) - return nil, nil // the upstream IDP may have omitted the claim if the user has no groups - } - - groupsAsArray, okAsArray := extractGroups(groupsAsInterface) - if !okAsArray { - plog.Warning( - "groups claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredGroupsClaim", groupsClaimName, - ) - return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format") - } - - return groupsAsArray, nil -} - -func extractGroups(groupsAsInterface interface{}) ([]string, bool) { - groupsAsString, okAsString := groupsAsInterface.(string) - if okAsString { - return []string{groupsAsString}, true - } - - groupsAsStringArray, okAsStringArray := groupsAsInterface.([]string) - if okAsStringArray { - return groupsAsStringArray, true - } - - groupsAsInterfaceArray, okAsArray := groupsAsInterface.([]interface{}) - if !okAsArray { - return nil, false - } - - var groupsAsStrings []string - for _, groupAsInterface := range groupsAsInterfaceArray { - groupAsString, okAsString := groupAsInterface.(string) - if !okAsString { - return nil, false - } - if groupAsString != "" { - groupsAsStrings = append(groupsAsStrings, groupAsString) - } - } - - return groupsAsStrings, true -} diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index 6f36c070e..e398908a1 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -5,6 +5,9 @@ package downstreamsession import ( + "fmt" + "net/http" + "net/url" "time" oidc2 "github.com/coreos/go-oidc/v3/oidc" @@ -12,7 +15,18 @@ import ( "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" + "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/plog" +) + +const ( + // The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + emailClaimName = "email" + + // The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + emailVerifiedClaimName = "email_verified" ) // MakeDownstreamSession creates a downstream OIDC session. @@ -41,3 +55,166 @@ func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) { oidc.GrantScopeIfRequested(authorizeRequester, oidc2.ScopeOfflineAccess) oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience") } + +func GetSubjectAndUsernameFromUpstreamIDToken( + upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + idTokenClaims map[string]interface{}, +) (string, string, error) { + // The spec says the "sub" claim is only unique per issuer, + // so we will prepend the issuer string to make it globally unique. + upstreamIssuer := idTokenClaims[oidc.IDTokenIssuerClaim] + if upstreamIssuer == "" { + plog.Warning( + "issuer claim in upstream ID token missing", + "upstreamName", upstreamIDPConfig.GetName(), + "issClaim", upstreamIssuer, + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token missing") + } + upstreamIssuerAsString, ok := upstreamIssuer.(string) + if !ok { + plog.Warning( + "issuer claim in upstream ID token has invalid format", + "upstreamName", upstreamIDPConfig.GetName(), + "issClaim", upstreamIssuer, + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token has invalid format") + } + + subjectAsInterface, ok := idTokenClaims[oidc.IDTokenSubjectClaim] + if !ok { + plog.Warning( + "no subject claim in upstream ID token", + "upstreamName", upstreamIDPConfig.GetName(), + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "no subject claim in upstream ID token") + } + + upstreamSubject, ok := subjectAsInterface.(string) + if !ok { + plog.Warning( + "subject claim in upstream ID token has invalid format", + "upstreamName", upstreamIDPConfig.GetName(), + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "subject claim in upstream ID token has invalid format") + } + + subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString, upstreamSubject) + + usernameClaimName := upstreamIDPConfig.GetUsernameClaim() + if usernameClaimName == "" { + return subject, subject, nil + } + + // If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified" + // claim is present, then validate that the "email_verified" claim is true. + emailVerifiedAsInterface, ok := idTokenClaims[emailVerifiedClaimName] + if usernameClaimName == emailClaimName && ok { + emailVerified, ok := emailVerifiedAsInterface.(bool) + if !ok { + plog.Warning( + "username claim configured as \"email\" and upstream email_verified claim is not a boolean", + "upstreamName", upstreamIDPConfig.GetName(), + "configuredUsernameClaim", usernameClaimName, + "emailVerifiedClaim", emailVerifiedAsInterface, + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format") + } + if !emailVerified { + plog.Warning( + "username claim configured as \"email\" and upstream email_verified claim has false value", + "upstreamName", upstreamIDPConfig.GetName(), + "configuredUsernameClaim", usernameClaimName, + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value") + } + } + + usernameAsInterface, ok := idTokenClaims[usernameClaimName] + if !ok { + plog.Warning( + "no username claim in upstream ID token", + "upstreamName", upstreamIDPConfig.GetName(), + "configuredUsernameClaim", usernameClaimName, + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token") + } + + username, ok := usernameAsInterface.(string) + if !ok { + plog.Warning( + "username claim in upstream ID token has invalid format", + "upstreamName", upstreamIDPConfig.GetName(), + "configuredUsernameClaim", usernameClaimName, + ) + return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format") + } + + return subject, username, nil +} + +func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string { + return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject)) +} + +func GetGroupsFromUpstreamIDToken( + upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + idTokenClaims map[string]interface{}, +) ([]string, error) { + groupsClaimName := upstreamIDPConfig.GetGroupsClaim() + if groupsClaimName == "" { + return nil, nil + } + + groupsAsInterface, ok := idTokenClaims[groupsClaimName] + if !ok { + plog.Warning( + "no groups claim in upstream ID token", + "upstreamName", upstreamIDPConfig.GetName(), + "configuredGroupsClaim", groupsClaimName, + ) + return nil, nil // the upstream IDP may have omitted the claim if the user has no groups + } + + groupsAsArray, okAsArray := extractGroups(groupsAsInterface) + if !okAsArray { + plog.Warning( + "groups claim in upstream ID token has invalid format", + "upstreamName", upstreamIDPConfig.GetName(), + "configuredGroupsClaim", groupsClaimName, + ) + return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format") + } + + return groupsAsArray, nil +} + +func extractGroups(groupsAsInterface interface{}) ([]string, bool) { + groupsAsString, okAsString := groupsAsInterface.(string) + if okAsString { + return []string{groupsAsString}, true + } + + groupsAsStringArray, okAsStringArray := groupsAsInterface.([]string) + if okAsStringArray { + return groupsAsStringArray, true + } + + groupsAsInterfaceArray, okAsArray := groupsAsInterface.([]interface{}) + if !okAsArray { + return nil, false + } + + var groupsAsStrings []string + for _, groupAsInterface := range groupsAsInterfaceArray { + groupAsString, okAsString := groupAsInterface.(string) + if !okAsString { + return nil, false + } + if groupAsString != "" { + groupsAsStrings = append(groupsAsStrings, groupAsString) + } + } + + return groupsAsStrings, true +} diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/oidc/idpdiscovery/idp_discovery_handler.go index 9ee0bf767..8e4535a8a 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler.go @@ -16,6 +16,9 @@ import ( const ( idpDiscoveryTypeLDAP = "ldap" idpDiscoveryTypeOIDC = "oidc" + + flowOIDCBrowser = "browser_authcode" + flowCLIPassword = "cli_password" ) type response struct { @@ -23,8 +26,9 @@ type response struct { } type identityProviderResponse struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows"` } // NewHandler returns an http.Handler that serves the upstream IDP discovery endpoint. @@ -56,10 +60,22 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, // 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}) + r.IDPs = append(r.IDPs, identityProviderResponse{ + Name: provider.GetName(), + Type: idpDiscoveryTypeLDAP, + Flows: []string{flowCLIPassword}, + }) } for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() { - r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeOIDC}) + flows := []string{flowOIDCBrowser} + if provider.AllowsPasswordGrant() { + flows = append(flows, flowCLIPassword) + } + r.IDPs = append(r.IDPs, identityProviderResponse{ + Name: provider.GetName(), + Type: idpDiscoveryTypeOIDC, + Flows: flows, + }) } // Nobody like an API that changes the results unnecessarily. :) diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go index 3912f9c99..8f7b270ad 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go @@ -37,20 +37,20 @@ func TestIDPDiscovery(t *testing.T) { 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"}, + {Name: "a-some-ldap-idp", Type: "ldap", Flows: []string{"cli_password"}}, + {Name: "a-some-oidc-idp", Type: "oidc", Flows: []string{"browser_authcode"}}, + {Name: "x-some-idp", Type: "ldap", Flows: []string{"cli_password"}}, + {Name: "x-some-idp", Type: "oidc", Flows: []string{"browser_authcode"}}, + {Name: "z-some-ldap-idp", Type: "ldap", Flows: []string{"cli_password"}}, + {Name: "z-some-oidc-idp", Type: "oidc", Flows: []string{"browser_authcode", "cli_password"}}, }, }, 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: "some-other-ldap-idp-1", Type: "ldap", Flows: []string{"cli_password"}}, + {Name: "some-other-ldap-idp-2", Type: "ldap", Flows: []string{"cli_password"}}, + {Name: "some-other-oidc-idp-1", Type: "oidc", Flows: []string{"browser_authcode", "cli_password"}}, + {Name: "some-other-oidc-idp-2", Type: "oidc", Flows: []string{"browser_authcode"}}, }, }, }, @@ -67,7 +67,7 @@ func TestIDPDiscovery(t *testing.T) { 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: "z-some-oidc-idp", AllowPasswordGrant: true}). WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}). WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}). WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}). @@ -100,7 +100,7 @@ func TestIDPDiscovery(t *testing.T) { &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-1", AllowPasswordGrant: true}, &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"}, }) diff --git a/internal/oidc/provider/dynamic_upstream_idp_provider.go b/internal/oidc/provider/dynamic_upstream_idp_provider.go index af24e7244..560bb3049 100644 --- a/internal/oidc/provider/dynamic_upstream_idp_provider.go +++ b/internal/oidc/provider/dynamic_upstream_idp_provider.go @@ -17,26 +17,36 @@ import ( ) type UpstreamOIDCIdentityProviderI interface { - // A name for this upstream provider, which will be used as a component of the path for the callback endpoint - // hosted by the Supervisor. + // GetName returns a name for this upstream provider, which will be used as a component of the path for the + // callback endpoint hosted by the Supervisor. GetName() string - // The Oauth client ID registered with the upstream provider to be used in the authorization code flow. + // GetClientID returns the OAuth client ID registered with the upstream provider to be used in the authorization code flow. GetClientID() string - // The Authorization Endpoint fetched from discovery. + // GetAuthorizationURL returns the Authorization Endpoint fetched from discovery. GetAuthorizationURL() *url.URL - // Scopes to request in authorization flow. + // GetScopes returns the scopes to request in authorization (authcode or password grant) flow. GetScopes() []string - // ID Token username claim name. May return empty string, in which case we will use some reasonable defaults. + // GetUsernameClaim returns the ID Token username claim name. May return empty string, in which case we + // will use some reasonable defaults. GetUsernameClaim() string - // ID Token groups claim name. May return empty string, in which case we won't try to read groups from the upstream provider. + // GetGroupsClaim returns the ID Token groups claim name. May return empty string, in which case we won't + // try to read groups from the upstream provider. GetGroupsClaim() string - // Performs upstream OIDC authorization code exchange and token validation. + // AllowsPasswordGrant returns true if a client should be allowed to use the resource owner password credentials grant + // flow with this upstream provider. When false, it should not be allowed. + AllowsPasswordGrant() bool + + // PasswordCredentialsGrantAndValidateTokens performs upstream OIDC resource owner password credentials grant and + // token validation. Returns the validated raw tokens as well as the parsed claims of the ID token. + PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) + + // ExchangeAuthcodeAndValidateTokens performs upstream OIDC authorization code exchange and token validation. // Returns the validated raw tokens as well as the parsed claims of the ID token. ExchangeAuthcodeAndValidateTokens( ctx context.Context, @@ -50,15 +60,15 @@ type UpstreamOIDCIdentityProviderI interface { } type UpstreamLDAPIdentityProviderI interface { - // A name for this upstream provider. + // GetName returns a name for this upstream provider. GetName() string - // Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234". + // GetURL returns 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() *url.URL - // A method for performing user authentication against the upstream LDAP provider. + // UserAuthenticator adds an interface method for performing user authentication against the upstream LDAP provider. authenticators.UserAuthenticator } diff --git a/internal/oidc/provider/manager/manager_test.go b/internal/oidc/provider/manager/manager_test.go index 469a085d3..2731b4881 100644 --- a/internal/oidc/provider/manager/manager_test.go +++ b/internal/oidc/provider/manager/manager_test.go @@ -61,6 +61,10 @@ func TestManager(t *testing.T) { downstreamPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements" ) + var ( + upstreamIDPFlows = []string{"browser_authcode"} + ) + newGetRequest := func(url string) *http.Request { return httptest.NewRequest(http.MethodGet, url, nil) } @@ -89,19 +93,22 @@ func TestManager(t *testing.T) { r.Equal(parsedDiscoveryResult.SupervisorDiscovery.PinnipedIDPsEndpoint, expectedIssuer+oidc.PinnipedIDPsPathV1Alpha1) } - requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string) { + requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string, expectedFlows []string) { recorder := httptest.NewRecorder() subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix)) r.False(fallbackHandlerWasCalled) + expectedFlowsJSON, err := json.Marshal(expectedFlows) + require.NoError(t, err) + // 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), + fmt.Sprintf(`{"pinniped_identity_providers":[{"name":"%s","type":"%s","flows":%s}]}`+"\n", expectedIDPName, expectedIDPType, expectedFlowsJSON), string(responseBody), ) } @@ -314,14 +321,14 @@ 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) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "?some=query", upstreamIDPName, upstreamIDPType, upstreamIDPFlows) // 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) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType, upstreamIDPFlows) + requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", upstreamIDPName, upstreamIDPType, upstreamIDPFlows) issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID) issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID) diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index f690af520..de5aefeb6 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -76,6 +76,7 @@ type TestUpstreamOIDCIdentityProvider struct { UsernameClaim string GroupsClaim string Scopes []string + AllowPasswordGrant bool ExchangeAuthcodeAndValidateTokensFunc func( ctx context.Context, authcode string, @@ -111,6 +112,15 @@ func (u *TestUpstreamOIDCIdentityProvider) GetGroupsClaim() string { return u.GroupsClaim } +func (u *TestUpstreamOIDCIdentityProvider) AllowsPasswordGrant() bool { + return u.AllowPasswordGrant +} + +func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) { + // TODO implement this unit test helper + return nil, nil +} + func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( ctx context.Context, authcode string, diff --git a/internal/upstreamoidc/upstreamoidc.go b/internal/upstreamoidc/upstreamoidc.go index 1c0aa0069..e7e2e8bb3 100644 --- a/internal/upstreamoidc/upstreamoidc.go +++ b/internal/upstreamoidc/upstreamoidc.go @@ -6,6 +6,7 @@ package upstreamoidc import ( "context" + "fmt" "net/http" "net/url" @@ -28,15 +29,16 @@ func New(config *oauth2.Config, provider *coreosoidc.Provider, client *http.Clie // ProviderConfig holds the active configuration of an upstream OIDC provider. type ProviderConfig struct { - Name string - UsernameClaim string - GroupsClaim string - Config *oauth2.Config - Provider interface { + Name string + UsernameClaim string + GroupsClaim string + Config *oauth2.Config + Client *http.Client + AllowPasswordGrant bool + Provider interface { Verifier(*coreosoidc.Config) *coreosoidc.IDTokenVerifier UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*coreosoidc.UserInfo, error) } - Client *http.Client } func (p *ProviderConfig) GetName() string { @@ -64,6 +66,32 @@ func (p *ProviderConfig) GetGroupsClaim() string { return p.GroupsClaim } +func (p *ProviderConfig) AllowsPasswordGrant() bool { + return p.AllowPasswordGrant +} + +func (p *ProviderConfig) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) { + // Disallow this grant when requested. + if !p.AllowPasswordGrant { + return nil, fmt.Errorf("resource owner password grant is not allowed for this upstream provider according to its configuration") + } + + // Note that this implicitly uses the scopes from p.Config.Scopes. + tok, err := p.Config.PasswordCredentialsToken( + coreosoidc.ClientContext(ctx, p.Client), + username, + password, + ) + if err != nil { + return nil, err + } + + // There is no nonce to validate for a resource owner password credentials grant because it skips using + // the authorize endpoint and goes straight to the token endpoint. + skipNonceValidation := nonce.Nonce("") + return p.ValidateToken(ctx, tok, skipNonceValidation) +} + func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string) (*oidctypes.Token, error) { tok, err := p.Config.Exchange( coreosoidc.ClientContext(ctx, p.Client), diff --git a/internal/upstreamoidc/upstreamoidc_test.go b/internal/upstreamoidc/upstreamoidc_test.go index 9a2d07ca1..baed255f8 100644 --- a/internal/upstreamoidc/upstreamoidc_test.go +++ b/internal/upstreamoidc/upstreamoidc_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 upstreamoidc @@ -45,6 +45,13 @@ func TestProviderConfig(t *testing.T) { require.ElementsMatch(t, []string{"scope1", "scope2"}, p.GetScopes()) require.Equal(t, "test-username-claim", p.GetUsernameClaim()) require.Equal(t, "test-groups-claim", p.GetGroupsClaim()) + + // AllowPasswordGrant defaults to false. + require.False(t, p.AllowsPasswordGrant()) + p.AllowPasswordGrant = true + require.True(t, p.AllowsPasswordGrant()) + p.AllowPasswordGrant = false + require.False(t, p.AllowsPasswordGrant()) }) const ( @@ -66,246 +73,468 @@ func TestProviderConfig(t *testing.T) { // if the error string for unsupported user info changes, this will hopefully catch it _, userInfoNotSupported := (&oidc.Provider{}).UserInfo(context.Background(), nil) - tests := []struct { - name string - authCode string - expectNonce nonce.Nonce - returnIDTok string - wantErr string - wantToken oidctypes.Token + t.Run("PasswordCredentialsGrantAndValidateTokens", func(t *testing.T) { + tests := []struct { + name string + disallowPasswordGrant bool + returnIDTok string + tokenStatusCode int + wantErr string + wantToken oidctypes.Token - userInfo *oidc.UserInfo - userInfoErr error - wantUserInfoCalled bool - }{ - { - name: "exchange fails with network error", - authCode: "invalid-auth-code", - wantErr: "oauth2: cannot fetch token: 403 Forbidden\nResponse: invalid authorization code\n", - }, - { - name: "missing ID token", - authCode: "valid", - wantErr: "received response missing ID token", - }, - { - name: "invalid ID token", - authCode: "valid", - returnIDTok: "invalid-jwt", - wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts", - }, - { - name: "invalid access token hash", - authCode: "valid", - returnIDTok: invalidAccessTokenHashIDToken, - wantErr: "received invalid ID token: access token hash does not match value in ID token", - }, - { - name: "invalid nonce", - authCode: "valid", - expectNonce: "test-nonce", - returnIDTok: invalidNonceIDToken, - wantErr: `received ID token with invalid nonce: invalid nonce (expected "test-nonce", got "invalid-nonce")`, - }, - { - name: "invalid nonce but not checked", - authCode: "valid", - expectNonce: "", - returnIDTok: invalidNonceIDToken, - wantToken: oidctypes.Token{ - AccessToken: &oidctypes.AccessToken{ - Token: "test-access-token", - Expiry: metav1.Time{}, - }, - RefreshToken: &oidctypes.RefreshToken{ - Token: "test-refresh-token", - }, - IDToken: &oidctypes.IDToken{ - Token: invalidNonceIDToken, - Expiry: metav1.Time{}, - Claims: map[string]interface{}{ - "aud": "test-client-id", - "iat": 1.602283741e+09, - "jti": "test-jti", - "nbf": 1.602283741e+09, - "nonce": "invalid-nonce", - "sub": "test-user", + userInfo *oidc.UserInfo + userInfoErr error + wantUserInfoCalled bool + }{ + { + name: "valid", + returnIDTok: validIDToken, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: validIDToken, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "foo": "bar", + "bat": "baz", + "aud": "test-client-id", + "iat": 1.606768593e+09, + "jti": "test-jti", + "nbf": 1.606768593e+09, + "sub": "test-user", + }, }, }, + userInfoErr: userInfoNotSupported, + wantUserInfoCalled: true, }, - userInfoErr: userInfoNotSupported, - wantUserInfoCalled: true, - }, - { - name: "valid", - authCode: "valid", - returnIDTok: validIDToken, - wantToken: oidctypes.Token{ - AccessToken: &oidctypes.AccessToken{ - Token: "test-access-token", - Expiry: metav1.Time{}, - }, - RefreshToken: &oidctypes.RefreshToken{ - Token: "test-refresh-token", - }, - IDToken: &oidctypes.IDToken{ - Token: validIDToken, - Expiry: metav1.Time{}, - Claims: map[string]interface{}{ - "foo": "bar", - "bat": "baz", - "aud": "test-client-id", - "iat": 1.606768593e+09, - "jti": "test-jti", - "nbf": 1.606768593e+09, - "sub": "test-user", + { + name: "valid with userinfo", + returnIDTok: validIDToken, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: validIDToken, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "foo": "awesomeness", // overwrite existing claim + "bat": "baz", + "aud": "test-client-id", + "iat": 1.606768593e+09, + "jti": "test-jti", + "nbf": 1.606768593e+09, + "sub": "test-user", + "groups": "fancy-group", // add a new claim + }, }, }, + // claims is private field so we have to use hacks to set it + userInfo: forceUserInfoWithClaims("test-user", `{"foo":"awesomeness","groups":"fancy-group"}`), + wantUserInfoCalled: true, }, - userInfoErr: userInfoNotSupported, - wantUserInfoCalled: true, - }, - { - name: "user info fetch error", - authCode: "valid", - returnIDTok: validIDToken, - wantErr: "could not fetch user info claims: could not get user info: some network error", - userInfoErr: errors.New("some network error"), - }, - { - name: "user info sub error", - authCode: "valid", - returnIDTok: validIDToken, - wantErr: "could not fetch user info claims: userinfo 'sub' claim (test-user-2) did not match id_token 'sub' claim (test-user)", - userInfo: &oidc.UserInfo{Subject: "test-user-2"}, - }, - { - name: "user info is not json", - authCode: "valid", - returnIDTok: validIDToken, - wantErr: "could not fetch user info claims: could not unmarshal user info claims: invalid character 'i' looking for beginning of value", - // claims is private field so we have to use hacks to set it - userInfo: forceUserInfoWithClaims("test-user", `invalid-json-data`), - }, - { - name: "valid with user info", - authCode: "valid", - returnIDTok: validIDToken, - wantToken: oidctypes.Token{ - AccessToken: &oidctypes.AccessToken{ - Token: "test-access-token", - Expiry: metav1.Time{}, - }, - RefreshToken: &oidctypes.RefreshToken{ - Token: "test-refresh-token", - }, - IDToken: &oidctypes.IDToken{ - Token: validIDToken, - Expiry: metav1.Time{}, - Claims: map[string]interface{}{ - "foo": "awesomeness", // overwrite existing claim - "bat": "baz", - "aud": "test-client-id", - "iat": 1.606768593e+09, - "jti": "test-jti", - "nbf": 1.606768593e+09, - "sub": "test-user", - "groups": "fancy-group", // add a new claim + { + name: "password grant not allowed", + disallowPasswordGrant: true, // password grant is not allowed in this ProviderConfig + wantErr: "resource owner password grant is not allowed for this upstream provider according to its configuration", + }, + { + name: "token request fails with http error", + tokenStatusCode: http.StatusForbidden, + wantErr: "oauth2: cannot fetch token: 403 Forbidden\nResponse: fake error\n", + }, + { + name: "missing ID token", + wantErr: "received response missing ID token", + }, + { + name: "invalid ID token", + returnIDTok: "invalid-jwt", + wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts", + }, + { + name: "invalid access token hash", + returnIDTok: invalidAccessTokenHashIDToken, + wantErr: "received invalid ID token: access token hash does not match value in ID token", + }, + { + name: "user info fetch error", + returnIDTok: validIDToken, + wantErr: "could not fetch user info claims: could not get user info: some network error", + userInfoErr: errors.New("some network error"), + }, + { + name: "user info sub error", + returnIDTok: validIDToken, + wantErr: "could not fetch user info claims: userinfo 'sub' claim (test-user-2) did not match id_token 'sub' claim (test-user)", + userInfo: &oidc.UserInfo{Subject: "test-user-2"}, + }, + { + name: "user info is not json", + returnIDTok: validIDToken, + wantErr: "could not fetch user info claims: could not unmarshal user info claims: invalid character 'i' looking for beginning of value", + // claims is private field so we have to use hacks to set it + userInfo: forceUserInfoWithClaims("test-user", `invalid-json-data`), + }, + { + name: "invalid sub claim", + returnIDTok: invalidSubClaim, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: invalidSubClaim, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "foo": "bar", + "bat": "baz", + "aud": "test-client-id", + "iat": 1.61021969e+09, + "jti": "test-jti", + "nbf": 1.61021969e+09, + // no sub claim + }, }, }, + wantUserInfoCalled: false, }, - // claims is private field so we have to use hacks to set it - userInfo: forceUserInfoWithClaims("test-user", `{"foo":"awesomeness","groups":"fancy-group"}`), - wantUserInfoCalled: true, - }, - { - name: "invalid sub claim", - authCode: "valid", - returnIDTok: invalidSubClaim, - wantToken: oidctypes.Token{ - AccessToken: &oidctypes.AccessToken{ - Token: "test-access-token", - Expiry: metav1.Time{}, - }, - RefreshToken: &oidctypes.RefreshToken{ - Token: "test-refresh-token", - }, - IDToken: &oidctypes.IDToken{ - Token: invalidSubClaim, - Expiry: metav1.Time{}, - Claims: map[string]interface{}{ - "foo": "bar", - "bat": "baz", - "aud": "test-client-id", - "iat": 1.61021969e+09, - "jti": "test-jti", - "nbf": 1.61021969e+09, - // no sub claim + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + require.Equal(t, 6, len(r.Form)) + require.Equal(t, "password", r.Form.Get("grant_type")) + require.Equal(t, "test-client-id", r.Form.Get("client_id")) + require.Equal(t, "test-client-secret", r.Form.Get("client_secret")) + require.Equal(t, "test-username", r.Form.Get("username")) + require.Equal(t, "test-password", r.Form.Get("password")) + require.Equal(t, "scope1 scope2", r.Form.Get("scope")) + if tt.tokenStatusCode != 0 { + http.Error(w, "fake error", http.StatusForbidden) + return + } + var response struct { + oauth2.Token + IDToken string `json:"id_token,omitempty"` + } + response.AccessToken = "test-access-token" + response.RefreshToken = "test-refresh-token" + response.Expiry = time.Now().Add(time.Hour) + response.IDToken = tt.returnIDTok + w.Header().Set("content-type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(&response)) + })) + t.Cleanup(tokenServer.Close) + + p := ProviderConfig{ + Name: "test-name", + UsernameClaim: "test-username-claim", + GroupsClaim: "test-groups-claim", + Config: &oauth2.Config{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: tokenServer.URL, + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"scope1", "scope2"}, }, - }, - }, - wantUserInfoCalled: false, - }, - } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) - require.NoError(t, r.ParseForm()) - require.Equal(t, "test-client-id", r.Form.Get("client_id")) - require.Equal(t, "test-pkce", r.Form.Get("code_verifier")) - require.Equal(t, "authorization_code", r.Form.Get("grant_type")) - require.NotEmpty(t, r.Form.Get("code")) - if r.Form.Get("code") != "valid" { - http.Error(w, "invalid authorization code", http.StatusForbidden) + Provider: &mockProvider{ + userInfo: tt.userInfo, + userInfoErr: tt.userInfoErr, + }, + AllowPasswordGrant: !tt.disallowPasswordGrant, + } + + tok, err := p.PasswordCredentialsGrantAndValidateTokens( + context.Background(), + "test-username", + "test-password", + ) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, tok) return } - var response struct { - oauth2.Token - IDToken string `json:"id_token,omitempty"` - } - response.AccessToken = "test-access-token" - response.RefreshToken = "test-refresh-token" - response.Expiry = time.Now().Add(time.Hour) - response.IDToken = tt.returnIDTok - w.Header().Set("content-type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(&response)) - })) - t.Cleanup(tokenServer.Close) + require.NoError(t, err) + require.Equal(t, &tt.wantToken, tok) + require.Equal(t, tt.wantUserInfoCalled, p.Provider.(*mockProvider).called) + }) + } + }) - p := ProviderConfig{ - Name: "test-name", - UsernameClaim: "test-username-claim", - GroupsClaim: "test-groups-claim", - Config: &oauth2.Config{ - ClientID: "test-client-id", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://example.com", - TokenURL: tokenServer.URL, - AuthStyle: oauth2.AuthStyleInParams, + t.Run("ExchangeAuthcodeAndValidateTokens", func(t *testing.T) { + tests := []struct { + name string + authCode string + expectNonce nonce.Nonce + returnIDTok string + wantErr string + wantToken oidctypes.Token + + userInfo *oidc.UserInfo + userInfoErr error + wantUserInfoCalled bool + }{ + { + name: "exchange fails with network error", + authCode: "invalid-auth-code", + wantErr: "oauth2: cannot fetch token: 403 Forbidden\nResponse: invalid authorization code\n", + }, + { + name: "missing ID token", + authCode: "valid", + wantErr: "received response missing ID token", + }, + { + name: "invalid ID token", + authCode: "valid", + returnIDTok: "invalid-jwt", + wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts", + }, + { + name: "invalid access token hash", + authCode: "valid", + returnIDTok: invalidAccessTokenHashIDToken, + wantErr: "received invalid ID token: access token hash does not match value in ID token", + }, + { + name: "invalid nonce", + authCode: "valid", + expectNonce: "test-nonce", + returnIDTok: invalidNonceIDToken, + wantErr: `received ID token with invalid nonce: invalid nonce (expected "test-nonce", got "invalid-nonce")`, + }, + { + name: "invalid nonce but not checked", + authCode: "valid", + expectNonce: "", + returnIDTok: invalidNonceIDToken, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: invalidNonceIDToken, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "aud": "test-client-id", + "iat": 1.602283741e+09, + "jti": "test-jti", + "nbf": 1.602283741e+09, + "nonce": "invalid-nonce", + "sub": "test-user", + }, }, - Scopes: []string{"scope1", "scope2"}, }, - Provider: &mockProvider{ - userInfo: tt.userInfo, - userInfoErr: tt.userInfoErr, + userInfoErr: userInfoNotSupported, + wantUserInfoCalled: true, + }, + { + name: "valid", + authCode: "valid", + returnIDTok: validIDToken, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: validIDToken, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "foo": "bar", + "bat": "baz", + "aud": "test-client-id", + "iat": 1.606768593e+09, + "jti": "test-jti", + "nbf": 1.606768593e+09, + "sub": "test-user", + }, + }, }, - } + userInfoErr: userInfoNotSupported, + wantUserInfoCalled: true, + }, + { + name: "user info fetch error", + authCode: "valid", + returnIDTok: validIDToken, + wantErr: "could not fetch user info claims: could not get user info: some network error", + userInfoErr: errors.New("some network error"), + }, + { + name: "user info sub error", + authCode: "valid", + returnIDTok: validIDToken, + wantErr: "could not fetch user info claims: userinfo 'sub' claim (test-user-2) did not match id_token 'sub' claim (test-user)", + userInfo: &oidc.UserInfo{Subject: "test-user-2"}, + }, + { + name: "user info is not json", + authCode: "valid", + returnIDTok: validIDToken, + wantErr: "could not fetch user info claims: could not unmarshal user info claims: invalid character 'i' looking for beginning of value", + // claims is private field so we have to use hacks to set it + userInfo: forceUserInfoWithClaims("test-user", `invalid-json-data`), + }, + { + name: "valid with user info", + authCode: "valid", + returnIDTok: validIDToken, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: validIDToken, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "foo": "awesomeness", // overwrite existing claim + "bat": "baz", + "aud": "test-client-id", + "iat": 1.606768593e+09, + "jti": "test-jti", + "nbf": 1.606768593e+09, + "sub": "test-user", + "groups": "fancy-group", // add a new claim + }, + }, + }, + // claims is private field so we have to use hacks to set it + userInfo: forceUserInfoWithClaims("test-user", `{"foo":"awesomeness","groups":"fancy-group"}`), + wantUserInfoCalled: true, + }, + { + name: "invalid sub claim", + authCode: "valid", + returnIDTok: invalidSubClaim, + wantToken: oidctypes.Token{ + AccessToken: &oidctypes.AccessToken{ + Token: "test-access-token", + Expiry: metav1.Time{}, + }, + RefreshToken: &oidctypes.RefreshToken{ + Token: "test-refresh-token", + }, + IDToken: &oidctypes.IDToken{ + Token: invalidSubClaim, + Expiry: metav1.Time{}, + Claims: map[string]interface{}{ + "foo": "bar", + "bat": "baz", + "aud": "test-client-id", + "iat": 1.61021969e+09, + "jti": "test-jti", + "nbf": 1.61021969e+09, + // no sub claim + }, + }, + }, + wantUserInfoCalled: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + require.Len(t, r.Form, 6) + require.Equal(t, "test-client-id", r.Form.Get("client_id")) + require.Equal(t, "test-client-secret", r.Form.Get("client_secret")) + require.Equal(t, "test-pkce", r.Form.Get("code_verifier")) + require.Equal(t, "authorization_code", r.Form.Get("grant_type")) + require.Equal(t, "https://example.com/callback", r.Form.Get("redirect_uri")) + require.NotEmpty(t, r.Form.Get("code")) + if r.Form.Get("code") != "valid" { + http.Error(w, "invalid authorization code", http.StatusForbidden) + return + } + var response struct { + oauth2.Token + IDToken string `json:"id_token,omitempty"` + } + response.AccessToken = "test-access-token" + response.RefreshToken = "test-refresh-token" + response.Expiry = time.Now().Add(time.Hour) + response.IDToken = tt.returnIDTok + w.Header().Set("content-type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(&response)) + })) + t.Cleanup(tokenServer.Close) - ctx := context.Background() + p := ProviderConfig{ + Name: "test-name", + UsernameClaim: "test-username-claim", + GroupsClaim: "test-groups-claim", + Config: &oauth2.Config{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: tokenServer.URL, + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"scope1", "scope2"}, + }, + Provider: &mockProvider{ + userInfo: tt.userInfo, + userInfoErr: tt.userInfoErr, + }, + } - tok, err := p.ExchangeAuthcodeAndValidateTokens(ctx, tt.authCode, "test-pkce", tt.expectNonce, "https://example.com/callback") - if tt.wantErr != "" { - require.EqualError(t, err, tt.wantErr) - require.Nil(t, tok) - return - } - require.NoError(t, err) - require.Equal(t, &tt.wantToken, tok) - require.Equal(t, tt.wantUserInfoCalled, p.Provider.(*mockProvider).called) - }) - } + tok, err := p.ExchangeAuthcodeAndValidateTokens( + context.Background(), + tt.authCode, + "test-pkce", + tt.expectNonce, + "https://example.com/callback", + ) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, tok) + return + } + require.NoError(t, err) + require.Equal(t, &tt.wantToken, tok) + require.Equal(t, tt.wantUserInfoCalled, p.Provider.(*mockProvider).called) + }) + } + }) } // mockVerifier returns an *oidc.IDTokenVerifier that validates any correctly serialized JWT without doing much else. @@ -350,7 +579,7 @@ func (m *mockProvider) UserInfo(_ context.Context, tokenSource oauth2.TokenSourc return m.userInfo, m.userInfoErr } -func forceUserInfoWithClaims(subject string, claims string) *oidc.UserInfo { +func forceUserInfoWithClaims(subject string, claims string) *oidc.UserInfo { //nolint:unparam userInfo := &oidc.UserInfo{Subject: subject} // this is some dark magic to set a private field diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index a1b5e0b6c..b0f738223 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -237,7 +237,8 @@ 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. +// provider type supports this style of authentication. Currently, this is supported by LDAPIdentityProviders +// and by OIDCIdentityProviders which optionally enable the resource owner password credentials grant flow. // 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 { diff --git a/test/deploy/tools/dex.yaml b/test/deploy/tools/dex.yaml index 9a7615aee..fe581be9a 100644 --- a/test/deploy/tools/dex.yaml +++ b/test/deploy/tools/dex.yaml @@ -17,6 +17,8 @@ web: tlsKey: /var/certs/dex-key.pem oauth2: skipApprovalScreen: true + #! Allow the resource owner password grant, which Dex implements to also return ID tokens. + passwordConnector: local staticClients: - id: pinniped-cli name: 'Pinniped CLI' diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 2a67726e7..d6fa6dbaa 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -271,7 +271,7 @@ func TestE2EFullIntegration(t *testing.T) { ) }) - t.Run("with Supervisor OIDC upstream IDP and manual flow", func(t *testing.T) { + t.Run("with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) { // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. page := browsertest.Open(t) @@ -365,7 +365,7 @@ func TestE2EFullIntegration(t *testing.T) { // Read all of the remaining output from the subprocess until EOF. t.Logf("waiting for kubectl to output namespace list") - // Read all of the output from the subprocess until EOF. + // Read all output from the subprocess until EOF. // Ignore any errors returned because there is always an error on linux. kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) @@ -382,6 +382,159 @@ func TestE2EFullIntegration(t *testing.T) { ) }) + t.Run("with Supervisor OIDC upstream IDP and CLI password flow without web browser", 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. + testlib.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, + ) + testlib.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "namespaces", + }) + + // Create upstream OIDC provider and wait for it to become ready. + testlib.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, + AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider + }, + Claims: idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/oidc-test-sessions-password-grant.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-skip-listen", + "--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login 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.SupervisorUpstreamOIDC.Password + "\n") + require.NoError(t, err) + + // Read all output from the subprocess until EOF. + // Ignore any errors returned because there is always an error on linux. + kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) + requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) + + t.Logf("first kubectl command took %s", time.Since(start).String()) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env, + downstream, + kubeconfigPath, + sessionCachePath, + pinnipedExe, + expectedUsername, + expectedGroups, + ) + }) + + t.Run("with Supervisor OIDC upstream IDP and CLI password flow when OIDCIdentityProvider disallows it", func(t *testing.T) { + // Create upstream OIDC provider and wait for it to become ready. + oidcIdentityProvider := testlib.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, + AllowPasswordGrant: false, // disallow the CLI password flow for this OIDCIdentityProvider! + }, + Claims: idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/oidc-test-sessions-password-grant-negative-test.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-skip-listen", + // Create a kubeconfig configured to use the cli_password flow. By specifying all + // available --upstream-identity-provider-* options the CLI should skip IDP discovery + // and use the provided values without validating them. "cli_password" will not show + // up in the list of available flows for this IDP in the discovery response. + "--upstream-identity-provider-name", oidcIdentityProvider.Name, + "--upstream-identity-provider-type", "oidc", + "--upstream-identity-provider-flow", "cli_password", + "--oidc-ca-bundle", testCABundlePath, + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin. + 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(env.SupervisorUpstreamOIDC.Username + "\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.SupervisorUpstreamOIDC.Password + "\n") + require.NoError(t, err) + + // Read all output from the subprocess until EOF. + // Ignore any errors returned because there is always an error on linux. + kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) + kubectlOutput := string(kubectlOutputBytes) + + // The output should look like an authentication failure, because the OIDCIdentityProvider disallows password grants. + t.Log("kubectl command output (expecting a login failed error):\n", kubectlOutput) + require.Contains(t, kubectlOutput, + `Error: could not complete Pinniped login: login failed with code "access_denied": `+ + `The resource owner or authorization server denied the request. `+ + `resource owner password grant is not allowed for this upstream provider according to its configuration`, + ) + }) + // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands // by interacting with the CLI's username and password prompts. t.Run("with Supervisor LDAP upstream IDP using username and password prompts", func(t *testing.T) { @@ -422,7 +575,7 @@ func TestE2EFullIntegration(t *testing.T) { _, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n") require.NoError(t, err) - // Read all of the output from the subprocess until EOF. + // Read all output from the subprocess until EOF. // Ignore any errors returned because there is always an error on linux. kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) @@ -487,7 +640,7 @@ func TestE2EFullIntegration(t *testing.T) { ptyFile, err := pty.Start(kubectlCmd) require.NoError(t, err) - // Read all of the output from the subprocess until EOF. + // Read all output from the subprocess until EOF. // Ignore any errors returned because there is always an error on linux. kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile) requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index b747beafa..4e074355b 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -65,7 +65,7 @@ func TestSupervisorLogin(t *testing.T) { }, }, idpv1alpha1.PhaseReady) }, - requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, // 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 @@ -95,11 +95,44 @@ func TestSupervisorLogin(t *testing.T) { }, }, idpv1alpha1.PhaseReady) }, - requestAuthorization: requestAuthorizationUsingOIDCIdentityProvider, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username), wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, + { + name: "oidc with CLI password flow", + maybeSkip: func(t *testing.T) { + // never need to skip this test + }, + createIDP: func(t *testing.T) { + t.Helper() + testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: env.SupervisorUpstreamOIDC.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ + AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider + }, + }, idpv1alpha1.PhaseReady) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamOIDC.Username, // username to present to server during login + env.SupervisorUpstreamOIDC.Password, // password to present to server during login + httpClient, + ) + }, + // 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 and groups names as DNs and using an LDAP provider which supports TLS", maybeSkip: func(t *testing.T) { @@ -148,7 +181,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) }, requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { - requestAuthorizationUsingLDAPIdentityProvider(t, + requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login @@ -213,7 +246,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) }, requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { - requestAuthorizationUsingLDAPIdentityProvider(t, + requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login @@ -495,7 +528,7 @@ func verifyTokenResponse( require.NotEmpty(t, tokenResponse.RefreshToken) } -func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { +func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { t.Helper() env := testlib.IntegrationEnv(t) @@ -524,7 +557,7 @@ func requestAuthorizationUsingOIDCIdentityProvider(t *testing.T, downstreamAutho browsertest.WaitForURL(t, page, callbackURLPattern) } -func requestAuthorizationUsingLDAPIdentityProvider(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) { +func requestAuthorizationUsingCLIPasswordFlow(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client) { t.Helper() ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) From 69964fc788a5dee89d2b18b8f650e1de47abb163 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 12 Aug 2021 13:35:56 -0700 Subject: [PATCH 02/21] New unit tests updated for Kube 1.22 ExecCredential changes from main After merging the new Kube 1.22 ExecCredential changes from main into this feature branch, some of the new units test on this feature branch needed to be update to account for the new ExecCredential "interactive" field. --- cmd/pinniped/cmd/login_oidc_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index de6d4ef13..bc1dd1e8d 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -173,7 +173,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--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", + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, { name: "oidc upstream type with browser flow is allowed", @@ -185,7 +185,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--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", + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, { name: "oidc upstream type with unsupported flow is an error", @@ -210,7 +210,7 @@ func TestLoginOIDCCommand(t *testing.T) { "--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", + wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", }, { name: "ldap upstream type with CLI flow is allowed", From 50085a505b25985659abad88e779e7a9ef015373 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 12 Aug 2021 17:53:14 -0700 Subject: [PATCH 03/21] First unit test for auth endpoint's password grant and related refactor --- internal/oidc/auth/auth_handler_test.go | 252 +++++--- .../oidc/callback/callback_handler_test.go | 565 ++++++++++-------- .../testutil/oidctestutil/oidctestutil.go | 230 ++++++- 3 files changed, 698 insertions(+), 349 deletions(-) diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index e6917954f..8736c84b3 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -5,6 +5,7 @@ package auth import ( "context" + "errors" "fmt" "html" "net/http" @@ -36,6 +37,16 @@ import ( func TestAuthorizationEndpoint(t *testing.T) { const ( + passwordGrantUpstreamName = "some-password-granting-oidc-idp" + + oidcUpstreamIssuer = "https://my-upstream-issuer.com" + oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL + oidcUpstreamSubjectQueryEscaped = "abc123-some+guid" + oidcUpstreamUsername = "test-oidc-pinniped-username" + oidcUpstreamPassword = "test-oidc-pinniped-password" //nolint: gosec + oidcUpstreamUsernameClaim = "the-user-claim" + oidcUpstreamGroupsClaim = "the-groups-claim" + downstreamIssuer = "https://my-downstream-issuer.com/some-path" downstreamRedirectURI = "http://127.0.0.1/callback" downstreamRedirectURIWithDifferentPort = "http://127.0.0.1:42/callback" @@ -51,6 +62,8 @@ func TestAuthorizationEndpoint(t *testing.T) { require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") var ( + oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} + fositeInvalidClientErrorBody = here.Doc(` { "error": "invalid_client", @@ -145,11 +158,31 @@ func TestAuthorizationEndpoint(t *testing.T) { upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") require.NoError(t, err) - upstreamOIDCIdentityProvider := oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: "some-oidc-idp", - ClientID: "some-client-id", - AuthorizationURL: *upstreamAuthURL, - Scopes: []string{"scope1", "scope2"}, // the scopes to request when starting the upstream authorization flow + upstreamOIDCIdentityProvider := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName("some-oidc-idp"). + WithClientID("some-client-id"). + WithAuthorizationURL(*upstreamAuthURL). + WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow + WithAllowPasswordGrant(false). + WithPasswordGrantError(errors.New("should not have used password grant on this instance")). + Build() + + passwordGrantUpstreamOIDCIdentityProviderBuilder := func() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName(passwordGrantUpstreamName). + WithClientID("some-client-id"). + WithAuthorizationURL(*upstreamAuthURL). + WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow + WithAllowPasswordGrant(false). + WithUsernameClaim(oidcUpstreamUsernameClaim). + WithGroupsClaim(oidcUpstreamGroupsClaim). + WithIDTokenClaim("iss", oidcUpstreamIssuer). + WithIDTokenClaim("sub", oidcUpstreamSubject). + WithIDTokenClaim(oidcUpstreamUsernameClaim, oidcUpstreamUsername). + WithIDTokenClaim(oidcUpstreamGroupsClaim, oidcUpstreamGroupMembership). + WithIDTokenClaim("other-claim", "should be ignored"). + WithAllowPasswordGrant(true). + WithUpstreamAuthcodeExchangeError(errors.New("should not have tried to exchange upstream authcode on this instance")) } happyLDAPUsername := "some-ldap-user" @@ -322,7 +355,7 @@ func TestAuthorizationEndpoint(t *testing.T) { type testCase struct { name string - idpLister provider.DynamicUpstreamIDPProvider + idps *oidctestutil.UpstreamIDPListerBuilder generateCSRF func() (csrftoken.CSRFToken, error) generatePKCE func() (pkce.Code, error) generateNonce func() (nonce.Nonce, error) @@ -345,7 +378,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader string wantUpstreamStateParamInLocationHeader bool - // For when the request was authenticated by an upstream LDAP provider and an authcode is being returned. + // Assertions for when an authcode should be returned, i.e. the request was authenticated by an + // upstream LDAP provider or an upstream OIDC password grant flow. wantRedirectLocationRegexp string wantDownstreamRedirectURI string wantDownstreamGrantedScopes []string @@ -357,11 +391,12 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod string wantDownstreamNonce string wantUnnecessaryStoredRecords int + wantPasswordGrantCall *expectedPasswordGrant } tests := []testCase{ { - name: "OIDC upstream happy path using GET without a CSRF cookie", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + name: "OIDC upstream browser flow happy path using GET without a CSRF cookie", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -376,9 +411,35 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "OIDC upstream password grant happy path using GET", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: passwordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, { name: "LDAP upstream happy path using GET", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -386,7 +447,6 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantBodyStringWithLocationInHref: false, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -399,7 +459,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "OIDC upstream happy path using GET with a CSRF cookie", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -416,7 +476,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "OIDC upstream happy path using POST", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -435,7 +495,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream happy path using POST", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodPost, path: "/some/path", contentType: "application/x-www-form-urlencoded", @@ -445,7 +505,6 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantBodyStringWithLocationInHref: false, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -458,7 +517,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "OIDC upstream happy path with prompt param login passed through to redirect uri", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -477,7 +536,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "OIDC upstream with error while decoding CSRF cookie just generates a new cookie and succeeds as usual", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -496,7 +555,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -516,9 +575,9 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyStringWithLocationInHref: true, }, { - 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, + name: "LDAP upstream happy path when downstream redirect uri matches what is configured for client except for the port number", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), @@ -527,7 +586,6 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, - wantBodyStringWithLocationInHref: false, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -540,7 +598,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "OIDC upstream happy path when downstream requested scopes include offline_access", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -559,7 +617,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error during upstream LDAP authentication", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -570,7 +628,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream password for LDAP authentication", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -582,7 +640,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream username for LDAP authentication", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr("wrong-username"), @@ -594,7 +652,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream username on request for LDAP authentication", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: nil, // do not send header @@ -606,7 +664,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream password on request for LDAP authentication", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -618,7 +676,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream redirect uri does not match what is configured for client when using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -633,9 +691,9 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { - 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, + name: "downstream redirect uri does not match what is configured for client when using LDAP upstream", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + 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", }), @@ -647,7 +705,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream client does not exist when using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -661,7 +719,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream client does not exist when using LDAP upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, @@ -670,7 +728,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -685,7 +743,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using LDAP upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusFound, @@ -695,7 +753,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream scopes do not match what is configured for client using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -710,7 +768,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream scopes do not match what is configured for client using LDAP upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid tuna"}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -722,7 +780,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing response type in request using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -737,7 +795,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing response type in request using LDAP upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), wantStatus: http.StatusFound, @@ -747,7 +805,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing client id in request using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -761,7 +819,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing client id in request using LDAP upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": ""}), wantStatus: http.StatusUnauthorized, @@ -770,7 +828,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -785,7 +843,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -798,7 +856,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -813,7 +871,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -826,7 +884,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -841,7 +899,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -854,7 +912,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -869,7 +927,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -884,7 +942,7 @@ func TestAuthorizationEndpoint(t *testing.T) { // 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 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -901,7 +959,7 @@ func TestAuthorizationEndpoint(t *testing.T) { // 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -914,7 +972,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -933,9 +991,9 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyStringWithLocationInHref: true, }, { - 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, + name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using LDAP upstream", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + 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), @@ -943,7 +1001,6 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted - wantBodyStringWithLocationInHref: false, wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, @@ -956,7 +1013,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream state does not have enough entropy using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -971,7 +1028,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream state does not have enough entropy using LDAP upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -983,7 +1040,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while encoding upstream state param using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -997,7 +1054,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while encoding CSRF cookie value for new cookie using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1011,7 +1068,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while generating CSRF token using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: sadCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1025,7 +1082,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while generating nonce using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: sadNonceGenerator, @@ -1039,7 +1096,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while generating PKCE using OIDC upstream", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), generateCSRF: happyCSRFGenerator, generatePKCE: sadPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1053,7 +1110,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "no upstream providers are configured", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC().Build(), // empty + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(), // empty method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -1062,7 +1119,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: multiple OIDC", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider, &upstreamOIDCIdentityProvider).Build(), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider, upstreamOIDCIdentityProvider), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -1071,7 +1128,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: multiple LDAP", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider, &upstreamLDAPIdentityProvider).Build(), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider, &upstreamLDAPIdentityProvider), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -1080,7 +1137,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: both OIDC and LDAP", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).WithLDAP(&upstreamLDAPIdentityProvider).Build(), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider).WithLDAP(&upstreamLDAPIdentityProvider), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -1089,7 +1146,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PUT is a bad method", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), method: http.MethodPut, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -1098,7 +1155,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PATCH is a bad method", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), method: http.MethodPatch, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -1107,7 +1164,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "DELETE is a bad method", - idpLister: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&upstreamOIDCIdentityProvider).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), method: http.MethodDelete, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -1117,7 +1174,8 @@ func TestAuthorizationEndpoint(t *testing.T) { } 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)) + reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)).WithContext(reqContext) req.Header.Set("Content-Type", test.contentType) if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) @@ -1137,6 +1195,15 @@ func TestAuthorizationEndpoint(t *testing.T) { testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) testutil.RequireSecurityHeaders(t, rsp) + if test.wantPasswordGrantCall != nil { + test.wantPasswordGrantCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToPasswordCredentialsGrantAndValidateTokens(t, + test.wantPasswordGrantCall.performedByUpstreamName, test.wantPasswordGrantCall.args, + ) + } else { + test.idps.RequireExactlyZeroCallsToPasswordCredentialsGrantAndValidateTokens(t) + } + actualLocation := rsp.Header().Get("Location") switch { case test.wantLocationHeader != "": @@ -1212,7 +1279,7 @@ func TestAuthorizationEndpoint(t *testing.T) { oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient) subject := NewHandler( downstreamIssuer, - test.idpLister, + test.idps.Build(), oauthHelperWithNullStorage, oauthHelperWithRealStorage, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder, @@ -1223,14 +1290,16 @@ func TestAuthorizationEndpoint(t *testing.T) { t.Run("allows upstream provider configuration to change between requests", func(t *testing.T) { test := tests[0] - require.Equal(t, "OIDC upstream happy path using GET without a CSRF cookie", test.name) // re-use the happy path test case + // Double-check that we are re-using the happy path test case here as we intend. + require.Equal(t, "OIDC upstream browser flow happy path using GET without a CSRF cookie", test.name) kubeClient := fake.NewSimpleClientset() secretsClient := kubeClient.CoreV1().Secrets("some-namespace") oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient) + idpLister := test.idps.Build() subject := NewHandler( downstreamIssuer, - test.idpLister, + idpLister, oauthHelperWithNullStorage, oauthHelperWithRealStorage, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder, @@ -1238,23 +1307,25 @@ func TestAuthorizationEndpoint(t *testing.T) { runOneTestCase(t, test, subject, kubeOauthStore, kubeClient, secretsClient) - // Call the setter to change the upstream IDP settings. - newProviderSettings := oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: "some-other-idp", - ClientID: "some-other-client-id", - AuthorizationURL: *upstreamAuthURL, - Scopes: []string{"other-scope1", "other-scope2"}, - } - test.idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{provider.UpstreamOIDCIdentityProviderI(&newProviderSettings)}) + // Call the idpLister's setter to change the upstream IDP settings. + newProviderSettings := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName("some-other-new-idp-name"). + WithClientID("some-other-new-client-id"). + WithAuthorizationURL(*upstreamAuthURL). + WithScopes([]string{"some-other-new-scope1", "some-other-new-scope2"}). + Build() + 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(), map[string]string{ - "response_type": "code", - "access_type": "offline", - "scope": "other-scope1 other-scope2", - "client_id": "some-other-client-id", - "state": expectedUpstreamStateParam(nil, "", newProviderSettings.Name), + "response_type": "code", + "access_type": "offline", + "scope": "some-other-new-scope1 some-other-new-scope2", // updated expectation + "client_id": "some-other-new-client-id", // updated expectation + "state": expectedUpstreamStateParam( + nil, "", "some-other-new-idp-name", + ), // updated expectation "nonce": happyNonce, "code_challenge": expectedUpstreamCodeChallenge, "code_challenge_method": downstreamPKCEChallengeMethod, @@ -1282,6 +1353,11 @@ func (*errorReturningEncoder) Encode(_ string, _ interface{}) (string, error) { return "", fmt.Errorf("some encoding error") } +type expectedPasswordGrant struct { + performedByUpstreamName string + args *oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs +} + func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL string, stateParamDecoder oidc.Codec) { t.Helper() actualLocationURL, err := url.Parse(actualURL) diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 23912944b..e2b9a31c7 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -21,20 +21,19 @@ import ( "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" ) const ( happyUpstreamIDPName = "upstream-idp-name" - upstreamIssuer = "https://my-upstream-issuer.com" - upstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL - queryEscapedUpstreamSubject = "abc123-some+guid" - upstreamUsername = "test-pinniped-username" + oidcUpstreamIssuer = "https://my-upstream-issuer.com" + oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL + oidcUpstreamSubjectQueryEscaped = "abc123-some+guid" + oidcUpstreamUsername = "test-pinniped-username" - upstreamUsernameClaim = "the-user-claim" - upstreamGroupsClaim = "the-groups-claim" + oidcUpstreamUsernameClaim = "the-user-claim" + oidcUpstreamGroupsClaim = "the-groups-claim" happyUpstreamAuthcode = "upstream-auth-code" happyUpstreamRedirectURI = "https://example.com/callback" @@ -56,7 +55,7 @@ const ( ) var ( - upstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} + oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"} happyDownstreamScopesRequested = []string{"openid"} happyDownstreamScopesGranted = []string{"openid"} @@ -113,7 +112,7 @@ func TestCallbackEndpoint(t *testing.T) { tests := []struct { name string - idp oidctestutil.TestUpstreamOIDCIdentityProvider + idps *oidctestutil.UpstreamIDPListerBuilder method string path string csrfCookie string @@ -132,11 +131,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge string wantDownstreamPKCEChallengeMethod string - wantExchangeAndValidateTokensCall *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs + wantAuthcodeExchangeCall *expectedAuthcodeExchange }{ { name: "GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( happyUpstreamStateParam().WithAuthorizeRequestParams( @@ -150,204 +149,254 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamUsername, - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "GET with good state and cookie and successful upstream token exchange returns 302 to downstream client callback with its state and code", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamUsername, - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", - idp: happyUpstream().WithoutUsernameClaim().WithoutGroupsClaim().Build(), + name: "upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithoutUsernameClaim().WithoutGroupsClaim().Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenGroups: []string{}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing", - idp: happyUpstream().WithUsernameClaim("email"). - WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithUsernameClaim("email").WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe@whitehouse.gov", - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value", - idp: happyUpstream().WithUsernameClaim("email"). - WithIDTokenClaim("email", "joe@whitehouse.gov"). - WithIDTokenClaim("email_verified", true).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", true).Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe@whitehouse.gov", - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value", - idp: happyUpstream().WithUsernameClaim("some-claim"). - WithIDTokenClaim("some-claim", "joe"). - WithIDTokenClaim("email", "joe@whitehouse.gov"). - WithIDTokenClaim("email_verified", false).Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithUsernameClaim("some-claim"). + WithIDTokenClaim("some-claim", "joe"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", false).Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, // succeed despite `email_verified=false` because we're not using the email claim for anything wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe", - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value", - idp: happyUpstream().WithUsernameClaim("email"). + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithUsernameClaim("email"). WithIDTokenClaim("email", "joe@whitehouse.gov"). WithIDTokenClaim("email_verified", "supposed to be boolean").Build(), - method: http.MethodGet, - 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, + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with false value", - idp: happyUpstream().WithUsernameClaim("email"). - WithIDTokenClaim("email", "joe@whitehouse.gov"). - WithIDTokenClaim("email_verified", false).Build(), - method: http.MethodGet, - 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, + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", false).Build(), + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for", - idp: happyUpstream().WithUsernameClaim("sub").Build(), + name: "upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithUsernameClaim("sub").Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamSubject, - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamSubject, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream IDP's configured groups claim in the ID token has a non-array value", - idp: happyUpstream().WithIDTokenClaim(upstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), + name: "upstream IDP's configured groups claim in the ID token has a non-array value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamUsername, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: []string{"notAnArrayGroup1 notAnArrayGroup2"}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream IDP's configured groups claim in the ID token is a slice of interfaces", - idp: happyUpstream().WithIDTokenClaim(upstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), + name: "upstream IDP's configured groups claim in the ID token is a slice of interfaces", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamUsername, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: []string{"group1", "group2"}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, // Pre-upstream-exchange verification { name: "PUT method is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodPut, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -356,6 +405,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "POST method is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodPost, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -364,6 +414,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "PATCH method is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodPatch, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -372,6 +423,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "DELETE method is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodDelete, path: newRequestPath().String(), wantStatus: http.StatusMethodNotAllowed, @@ -380,6 +432,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "code param was not included on request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).WithoutCode().String(), csrfCookie: happyCSRFCookie, @@ -389,6 +442,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state param was not included on request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithoutState().String(), csrfCookie: happyCSRFCookie, @@ -398,7 +452,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState("this-will-not-decode").String(), csrfCookie: happyCSRFCookie, @@ -410,22 +464,26 @@ func TestCallbackEndpoint(t *testing.T) { // This shouldn't happen in practice because the authorize endpoint should have already run the same // validations, but we would like to test the error handling in this endpoint anyway. name: "state param contains authorization request params which fail validation", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( happyUpstreamStateParam(). WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"prompt": "none login"}).Encode()). Build(t, happyStateCodec), ).String(), - csrfCookie: happyCSRFCookie, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, - wantStatus: http.StatusInternalServerError, - wantContentType: htmlContentType, - wantBody: "Internal Server Error: error while generating and saving authcode\n", + csrfCookie: happyCSRFCookie, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: 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(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyUpstreamStateParam().WithStateVersion("wrong-state-version").Build(t, happyStateCodec)).String(), csrfCookie: happyCSRFCookie, @@ -435,7 +493,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params element is invalid", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyUpstreamStateParam(). WithAuthorizeRequestParams("the following is an invalid url encoding token, and therefore this is an invalid param: %z"). @@ -447,7 +505,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params are missing required value (e.g., client_id)", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState( happyUpstreamStateParam(). @@ -461,7 +519,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "state's downstream auth params does not contain openid scope", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath(). WithState( @@ -472,18 +530,21 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, - wantDownstreamIDTokenUsername: upstreamUsername, - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"profile", "email"}, - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "state's downstream auth params also included offline_access scope", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath(). WithState( @@ -494,19 +555,22 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access&state=` + happyDownstreamState, - wantDownstreamIDTokenUsername: upstreamUsername, - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"openid", "offline_access"}, wantDownstreamGrantedScopes: []string{"openid", "offline_access"}, - wantDownstreamIDTokenGroups: upstreamGroupMembership, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { name: "the OIDCIdentityProvider CRD has been deleted", - idp: otherUpstreamOIDCIdentityProvider, + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&otherUpstreamOIDCIdentityProvider), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -516,7 +580,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "the CSRF cookie does not exist on request", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), wantStatus: http.StatusForbidden, @@ -525,7 +589,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", @@ -535,7 +599,7 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "cookie csrf value does not match state csrf value", - idp: happyUpstream().Build(), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), csrfCookie: happyCSRFCookie, @@ -546,111 +610,156 @@ func TestCallbackEndpoint(t *testing.T) { // Upstream exchange { - name: "upstream auth code exchange fails", - idp: happyUpstream().WithoutUpstreamAuthcodeExchangeError(errors.New("some error")).Build(), - method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusBadGateway, - wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n", - wantContentType: htmlContentType, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + name: "upstream auth code exchange fails", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithUpstreamAuthcodeExchangeError(errors.New("some error")).Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadGateway, + wantBody: "Bad Gateway: error exchanging and validating upstream tokens\n", + wantContentType: htmlContentType, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token does not contain requested username claim", - idp: happyUpstream().WithoutIDTokenClaim(upstreamUsernameClaim).Build(), - method: http.MethodGet, - path: newRequestPath().WithState(happyState).String(), - csrfCookie: happyCSRFCookie, - wantStatus: http.StatusUnprocessableEntity, - wantBody: "Unprocessable Entity: no username claim in upstream ID token\n", - wantContentType: htmlContentType, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + name: "upstream ID token does not contain requested username claim", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithoutIDTokenClaim(oidcUpstreamUsernameClaim).Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantBody: "Unprocessable Entity: no username claim in upstream ID token\n", + wantContentType: htmlContentType, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token does not contain requested groups claim", - idp: happyUpstream().WithoutIDTokenClaim(upstreamGroupsClaim).Build(), + name: "upstream ID token does not contain requested groups claim", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithoutIDTokenClaim(oidcUpstreamGroupsClaim).Build(), + ), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusFound, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject, - wantDownstreamIDTokenUsername: upstreamUsername, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamIDTokenGroups: []string{}, wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token contains username claim with weird format", - idp: happyUpstream().WithIDTokenClaim(upstreamUsernameClaim, 42).Build(), - method: http.MethodGet, - 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, + name: "upstream ID token contains username claim with weird format", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, 42).Build(), + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token does not contain iss claim when using default username claim config", - idp: happyUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), - method: http.MethodGet, - 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, + name: "upstream ID token does not contain iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: issuer claim in upstream ID token missing\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token has an non-string iss claim when using default username claim config", - idp: happyUpstream().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), - method: http.MethodGet, - 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, + name: "upstream ID token has an non-string iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token contains groups claim with weird format", - idp: happyUpstream().WithIDTokenClaim(upstreamGroupsClaim, 42).Build(), - method: http.MethodGet, - 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, + name: "upstream ID token contains groups claim with weird format", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, 42).Build(), + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token contains groups claim where one element is invalid", - idp: happyUpstream().WithIDTokenClaim(upstreamGroupsClaim, []interface{}{"foo", 7}).Build(), - method: http.MethodGet, - 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, + name: "upstream ID token contains groups claim where one element is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"foo", 7}).Build(), + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, { - name: "upstream ID token contains groups claim with invalid null type", - idp: happyUpstream().WithIDTokenClaim(upstreamGroupsClaim, nil).Build(), - method: http.MethodGet, - 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, + name: "upstream ID token contains groups claim with invalid null type", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamGroupsClaim, nil).Build(), + ), + method: http.MethodGet, + 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", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, }, } for _, test := range tests { @@ -669,9 +778,9 @@ func TestCallbackEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) - idpLister := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&test.idp).Build() - subject := NewHandler(idpLister, oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) - req := httptest.NewRequest(test.method, test.path, nil) + subject := NewHandler(test.idps.Build(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) + reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") + req := httptest.NewRequest(test.method, test.path, nil).WithContext(reqContext) if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) } @@ -682,12 +791,13 @@ func TestCallbackEndpoint(t *testing.T) { testutil.RequireSecurityHeaders(t, rsp) - if test.wantExchangeAndValidateTokensCall != nil { - require.Equal(t, 1, test.idp.ExchangeAuthcodeAndValidateTokensCallCount()) - test.wantExchangeAndValidateTokensCall.Ctx = req.Context() - require.Equal(t, test.wantExchangeAndValidateTokensCall, test.idp.ExchangeAuthcodeAndValidateTokensArgs(0)) + if test.wantAuthcodeExchangeCall != nil { + test.wantAuthcodeExchangeCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens(t, + test.wantAuthcodeExchangeCall.performedByUpstreamName, test.wantAuthcodeExchangeCall.args, + ) } else { - require.Equal(t, 0, test.idp.ExchangeAuthcodeAndValidateTokensCallCount()) + test.idps.RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t) } require.Equal(t, test.wantStatus, rsp.Code) @@ -749,6 +859,11 @@ func TestCallbackEndpoint(t *testing.T) { } } +type expectedAuthcodeExchange struct { + performedByUpstreamName string + args *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs +} + type requestPath struct { code, state *string } @@ -838,70 +953,20 @@ func (b *upstreamStateParamBuilder) WithStateVersion(version string) *upstreamSt return b } -type upstreamOIDCIdentityProviderBuilder struct { - idToken map[string]interface{} - usernameClaim, groupsClaim string - authcodeExchangeErr error -} - -func happyUpstream() *upstreamOIDCIdentityProviderBuilder { - return &upstreamOIDCIdentityProviderBuilder{ - usernameClaim: upstreamUsernameClaim, - groupsClaim: upstreamGroupsClaim, - idToken: map[string]interface{}{ - "iss": upstreamIssuer, - "sub": upstreamSubject, - upstreamUsernameClaim: upstreamUsername, - upstreamGroupsClaim: upstreamGroupMembership, - "other-claim": "should be ignored", - }, - } -} - -func (u *upstreamOIDCIdentityProviderBuilder) WithUsernameClaim(value string) *upstreamOIDCIdentityProviderBuilder { - u.usernameClaim = value - return u -} - -func (u *upstreamOIDCIdentityProviderBuilder) WithoutUsernameClaim() *upstreamOIDCIdentityProviderBuilder { - u.usernameClaim = "" - return u -} - -func (u *upstreamOIDCIdentityProviderBuilder) WithoutGroupsClaim() *upstreamOIDCIdentityProviderBuilder { - u.groupsClaim = "" - return u -} - -func (u *upstreamOIDCIdentityProviderBuilder) WithIDTokenClaim(name string, value interface{}) *upstreamOIDCIdentityProviderBuilder { - u.idToken[name] = value - return u -} - -func (u *upstreamOIDCIdentityProviderBuilder) WithoutIDTokenClaim(claim string) *upstreamOIDCIdentityProviderBuilder { - delete(u.idToken, claim) - return u -} - -func (u *upstreamOIDCIdentityProviderBuilder) WithoutUpstreamAuthcodeExchangeError(err error) *upstreamOIDCIdentityProviderBuilder { - u.authcodeExchangeErr = err - return u -} - -func (u *upstreamOIDCIdentityProviderBuilder) Build() oidctestutil.TestUpstreamOIDCIdentityProvider { - return oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: happyUpstreamIDPName, - ClientID: "some-client-id", - UsernameClaim: u.usernameClaim, - GroupsClaim: u.groupsClaim, - Scopes: []string{"scope1", "scope2"}, - ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier oidcpkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { - if u.authcodeExchangeErr != nil { - return nil, u.authcodeExchangeErr - } - return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}}, nil - }, - } +func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName(happyUpstreamIDPName). + WithClientID("some-client-id"). + WithScopes([]string{"scope1", "scope2"}). + WithUsernameClaim(oidcUpstreamUsernameClaim). + WithGroupsClaim(oidcUpstreamGroupsClaim). + WithIDTokenClaim("iss", oidcUpstreamIssuer). + WithIDTokenClaim("sub", oidcUpstreamSubject). + WithIDTokenClaim(oidcUpstreamUsernameClaim, oidcUpstreamUsername). + WithIDTokenClaim(oidcUpstreamGroupsClaim, oidcUpstreamGroupMembership). + WithIDTokenClaim("other-claim", "should be ignored"). + WithAllowPasswordGrant(false). + WithPasswordGrantError(errors.New("the callback endpoint should not use password grants")) } func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string) url.Values { diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index de5aefeb6..7591bbca2 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -49,6 +49,14 @@ type ExchangeAuthcodeAndValidateTokenArgs struct { RedirectURI string } +// PasswordCredentialsGrantAndValidateTokensArgs is used to spy on calls to +// TestUpstreamOIDCIdentityProvider.PasswordCredentialsGrantAndValidateTokensFunc(). +type PasswordCredentialsGrantAndValidateTokensArgs struct { + Ctx context.Context + Username string + Password string +} + type TestUpstreamLDAPIdentityProvider struct { Name string URL *url.URL @@ -70,13 +78,14 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL { } type TestUpstreamOIDCIdentityProvider struct { - Name string - ClientID string - AuthorizationURL url.URL - UsernameClaim string - GroupsClaim string - Scopes []string - AllowPasswordGrant bool + Name string + ClientID string + AuthorizationURL url.URL + UsernameClaim string + GroupsClaim string + Scopes []string + AllowPasswordGrant bool + ExchangeAuthcodeAndValidateTokensFunc func( ctx context.Context, authcode string, @@ -84,8 +93,16 @@ type TestUpstreamOIDCIdentityProvider struct { expectedIDTokenNonce nonce.Nonce, ) (*oidctypes.Token, error) - exchangeAuthcodeAndValidateTokensCallCount int - exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs + PasswordCredentialsGrantAndValidateTokensFunc func( + ctx context.Context, + username string, + password string, + ) (*oidctypes.Token, error) + + exchangeAuthcodeAndValidateTokensCallCount int + exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs + passwordCredentialsGrantAndValidateTokensCallCount int + passwordCredentialsGrantAndValidateTokensArgs []*PasswordCredentialsGrantAndValidateTokensArgs } func (u *TestUpstreamOIDCIdentityProvider) GetName() string { @@ -117,8 +134,16 @@ func (u *TestUpstreamOIDCIdentityProvider) AllowsPasswordGrant() bool { } func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) { - // TODO implement this unit test helper - return nil, nil + if u.passwordCredentialsGrantAndValidateTokensArgs == nil { + u.passwordCredentialsGrantAndValidateTokensArgs = make([]*PasswordCredentialsGrantAndValidateTokensArgs, 0) + } + u.passwordCredentialsGrantAndValidateTokensCallCount++ + u.passwordCredentialsGrantAndValidateTokensArgs = append(u.passwordCredentialsGrantAndValidateTokensArgs, &PasswordCredentialsGrantAndValidateTokensArgs{ + Ctx: ctx, + Username: username, + Password: password, + }) + return u.PasswordCredentialsGrantAndValidateTokensFunc(ctx, username, password) } func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( @@ -190,10 +215,193 @@ func (b *UpstreamIDPListerBuilder) Build() provider.DynamicUpstreamIDPProvider { return idpProvider } +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPasswordCredentialsGrantAndValidateTokens( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *PasswordCredentialsGrantAndValidateTokensArgs, +) { + t.Helper() + var actualArgs *PasswordCredentialsGrantAndValidateTokensArgs + var actualNameOfUpstreamWhichMadeCall string + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + callCountOnThisUpstream := upstreamOIDC.passwordCredentialsGrantAndValidateTokensCallCount + actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name + actualArgs = upstreamOIDC.passwordCredentialsGrantAndValidateTokensArgs[0] + } + } + require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, + "should have been exactly one call to PasswordCredentialsGrantAndValidateTokens() by all OIDC upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "PasswordCredentialsGrantAndValidateTokens() was called on the wrong OIDC upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPasswordCredentialsGrantAndValidateTokens(t *testing.T) { + t.Helper() + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.passwordCredentialsGrantAndValidateTokensCallCount + } + require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, + "expected exactly zero calls to PasswordCredentialsGrantAndValidateTokens()", + ) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToExchangeAuthcodeAndValidateTokens( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *ExchangeAuthcodeAndValidateTokenArgs, +) { + t.Helper() + var actualArgs *ExchangeAuthcodeAndValidateTokenArgs + var actualNameOfUpstreamWhichMadeCall string + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + callCountOnThisUpstream := upstreamOIDC.exchangeAuthcodeAndValidateTokensCallCount + actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name + actualArgs = upstreamOIDC.exchangeAuthcodeAndValidateTokensArgs[0] + } + } + require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, + "should have been exactly one call to ExchangeAuthcodeAndValidateTokens() by all OIDC upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "ExchangeAuthcodeAndValidateTokens() was called on the wrong OIDC upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndValidateTokens(t *testing.T) { + t.Helper() + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.exchangeAuthcodeAndValidateTokensCallCount + } + require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, + "expected exactly zero calls to ExchangeAuthcodeAndValidateTokens()", + ) +} + func NewUpstreamIDPListerBuilder() *UpstreamIDPListerBuilder { return &UpstreamIDPListerBuilder{} } +type TestUpstreamOIDCIdentityProviderBuilder struct { + name string + clientID string + scopes []string + idToken map[string]interface{} + usernameClaim string + groupsClaim string + authorizationURL url.URL + allowPasswordGrant bool + authcodeExchangeErr error + passwordGrantErr error +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUpstreamOIDCIdentityProviderBuilder { + u.name = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithClientID(value string) *TestUpstreamOIDCIdentityProviderBuilder { + u.clientID = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAuthorizationURL(value url.URL) *TestUpstreamOIDCIdentityProviderBuilder { + u.authorizationURL = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAllowPasswordGrant(value bool) *TestUpstreamOIDCIdentityProviderBuilder { + u.allowPasswordGrant = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithScopes(values []string) *TestUpstreamOIDCIdentityProviderBuilder { + u.scopes = values + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithUsernameClaim(value string) *TestUpstreamOIDCIdentityProviderBuilder { + u.usernameClaim = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutUsernameClaim() *TestUpstreamOIDCIdentityProviderBuilder { + u.usernameClaim = "" + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithGroupsClaim(value string) *TestUpstreamOIDCIdentityProviderBuilder { + u.groupsClaim = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutGroupsClaim() *TestUpstreamOIDCIdentityProviderBuilder { + u.groupsClaim = "" + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithIDTokenClaim(name string, value interface{}) *TestUpstreamOIDCIdentityProviderBuilder { + if u.idToken == nil { + u.idToken = map[string]interface{}{} + } + u.idToken[name] = value + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutIDTokenClaim(claim string) *TestUpstreamOIDCIdentityProviderBuilder { + delete(u.idToken, claim) + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithUpstreamAuthcodeExchangeError(err error) *TestUpstreamOIDCIdentityProviderBuilder { + u.authcodeExchangeErr = err + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithPasswordGrantError(err error) *TestUpstreamOIDCIdentityProviderBuilder { + u.passwordGrantErr = err + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdentityProvider { + return &TestUpstreamOIDCIdentityProvider{ + Name: u.name, + ClientID: u.clientID, + UsernameClaim: u.usernameClaim, + GroupsClaim: u.groupsClaim, + Scopes: u.scopes, + AllowPasswordGrant: u.allowPasswordGrant, + AuthorizationURL: u.authorizationURL, + ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { + if u.authcodeExchangeErr != nil { + return nil, u.authcodeExchangeErr + } + return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}}, nil + }, + PasswordCredentialsGrantAndValidateTokensFunc: func(ctx context.Context, username, password string) (*oidctypes.Token, error) { + if u.passwordGrantErr != nil { + return nil, u.passwordGrantErr + } + return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}}, nil + }, + } +} + +func NewTestUpstreamOIDCIdentityProviderBuilder() *TestUpstreamOIDCIdentityProviderBuilder { + return &TestUpstreamOIDCIdentityProviderBuilder{} +} + // 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 From 52cb0bbc07cfe4a81c69d3f9055e3f4749607434 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 16 Aug 2021 14:27:40 -0700 Subject: [PATCH 04/21] More unit tests and small error handling changes for OIDC password grant --- internal/oidc/auth/auth_handler.go | 52 ++- internal/oidc/auth/auth_handler_test.go | 460 ++++++++++++++++++--- internal/oidc/oidc.go | 12 +- internal/upstreamoidc/upstreamoidc.go | 2 +- internal/upstreamoidc/upstreamoidc_test.go | 2 +- 5 files changed, 438 insertions(+), 90 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 9a2632051..701b6ac94 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -61,7 +61,6 @@ func NewHandler( if oidcUpstream != nil { if len(r.Header.Values(CustomUsernameHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. - // TODO unit test this return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) } return handleAuthRequestForOIDCUpstreamAuthcodeGrant(r, w, @@ -128,19 +127,6 @@ func handleAuthRequestForLDAPUpstream( return nil } -func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { - 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 "", "", false - } - return username, password, true -} - func handleAuthRequestForOIDCUpstreamPasswordGrant( r *http.Request, w http.ResponseWriter, @@ -157,11 +143,27 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } + if !oidcUpstream.AllowsPasswordGrant() { + // Return a user-friendly error for this case which is entirely within our control. + err := errors.WithStack(fosite.ErrAccessDenied. + WithHint("Resource owner password credentials grant is not allowed for this upstream provider according to its configuration."), + ) + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil + } + token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) if err != nil { + // Upstream password grant errors can be generic errors (e.g. a network failure) or can be oauth2.RetrieveError errors + // which represent the http response from the upstream server. These could be a 5XX or some other unexpected error, + // or could be a 400 with a JSON body as described by https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + // which notes that wrong resource owner credentials should result in an "invalid_grant" error. + // However, the exact response is undefined in the sense that there is no such thing as a password grant in + // the OIDC spec, so we don't try too hard to read the upstream errors in this case. (E.g. Dex departs from the + // spec and returns something other than an "invalid_grant" error for bad resource owner credentials.) // Return an error according to OIDC spec 3.1.2.6 (second paragraph). - // TODO do not return the full details of the error to the client, but let them know if it is because password grants are disallowed - err := errors.WithStack(fosite.ErrAccessDenied.WithHintf(err.Error())) + err := errors.WithStack(fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) return nil @@ -181,8 +183,9 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) if err != nil { - plog.WarningErr("error while generating and saving authcode", err, "upstreamName", oidcUpstream.GetName()) - return httperr.Wrap(http.StatusInternalServerError, "error while generating and saving authcode", err) + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil } oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) @@ -286,6 +289,19 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( return nil } +func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { + 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 "", "", false + } + return username, password, true +} + func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider) (fosite.AuthorizeRequester, bool) { authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r) if err != nil { diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 8736c84b3..ce615297a 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -18,6 +18,7 @@ import ( "github.com/gorilla/securecookie" "github.com/ory/fosite" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/kubernetes/fake" @@ -37,7 +38,8 @@ import ( func TestAuthorizationEndpoint(t *testing.T) { const ( - passwordGrantUpstreamName = "some-password-granting-oidc-idp" + oidcUpstreamName = "some-oidc-idp" + oidcPasswordGrantUpstreamName = "some-password-granting-oidc-idp" oidcUpstreamIssuer = "https://my-upstream-issuer.com" oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL @@ -126,6 +128,12 @@ func TestAuthorizationEndpoint(t *testing.T) { "state": happyState, } + fositeAccessDeniedErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Make sure that the request you are making is valid. Maybe the credential or request parameters you are using are limited in scope or otherwise restricted.", + "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.", @@ -137,6 +145,12 @@ func TestAuthorizationEndpoint(t *testing.T) { "error_description": "The resource owner or authorization server denied the request. Missing or blank username or password.", "state": happyState, } + + fositeAccessDeniedWithPasswordGrantDisallowedHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.", + "state": happyState, + } ) hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } @@ -158,18 +172,20 @@ func TestAuthorizationEndpoint(t *testing.T) { upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") require.NoError(t, err) - upstreamOIDCIdentityProvider := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). - WithName("some-oidc-idp"). - WithClientID("some-client-id"). - WithAuthorizationURL(*upstreamAuthURL). - WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow - WithAllowPasswordGrant(false). - WithPasswordGrantError(errors.New("should not have used password grant on this instance")). - Build() + upstreamOIDCIdentityProvider := func() *oidctestutil.TestUpstreamOIDCIdentityProvider { + return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName(oidcUpstreamName). + WithClientID("some-client-id"). + WithAuthorizationURL(*upstreamAuthURL). + WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow + WithAllowPasswordGrant(false). + WithPasswordGrantError(errors.New("should not have used password grant on this instance")). + Build() + } passwordGrantUpstreamOIDCIdentityProviderBuilder := func() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). - WithName(passwordGrantUpstreamName). + WithName(oidcPasswordGrantUpstreamName). WithClientID("some-client-id"). WithAuthorizationURL(*upstreamAuthURL). WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow @@ -309,7 +325,7 @@ func TestAuthorizationEndpoint(t *testing.T) { if csrfValueOverride != "" { csrf = csrfValueOverride } - upstreamName := upstreamOIDCIdentityProvider.Name + upstreamName := oidcUpstreamName if upstreamNameOverride != "" { upstreamName = upstreamNameOverride } @@ -396,7 +412,7 @@ func TestAuthorizationEndpoint(t *testing.T) { tests := []testCase{ { name: "OIDC upstream browser flow happy path using GET without a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -419,7 +435,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: passwordGrantUpstreamName, + performedByUpstreamName: oidcPasswordGrantUpstreamName, args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ Username: oidcUpstreamUsername, Password: oidcUpstreamPassword, @@ -458,8 +474,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, }, { - name: "OIDC upstream happy path using GET with a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "OIDC upstream browser flow happy path using GET with a CSRF cookie", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -475,8 +491,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyStringWithLocationInHref: true, }, { - name: "OIDC upstream happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "OIDC upstream browser flow happy path using POST", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -493,6 +509,34 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "OIDC upstream password grant happy path using POST", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodPost, + path: "/some/path", + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, { name: "LDAP upstream happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -516,8 +560,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, }, { - name: "OIDC upstream happy path with prompt param login passed through to redirect uri", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "OIDC upstream browser flow happy path with prompt param login passed through to redirect uri", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -535,8 +579,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, }, { - name: "OIDC upstream with error while decoding CSRF cookie just generates a new cookie and succeeds as usual", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "OIDC upstream browser flow with error while decoding CSRF cookie just generates a new cookie and succeeds as usual", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -554,8 +598,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyStringWithLocationInHref: true, }, { - name: "OIDC upstream happy path when downstream redirect uri matches what is configured for client except for the port number", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "OIDC upstream browser flow happy path when downstream redirect uri matches what is configured for client except for the port number", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -574,6 +618,34 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "OIDC upstream password grant happy path when downstream redirect uri matches what is configured for client except for the port number", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().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(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURIWithDifferentPort, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, { name: "LDAP upstream happy path when downstream redirect uri matches what is configured for client except for the port number", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -597,8 +669,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, }, { - name: "OIDC upstream happy path when downstream requested scopes include offline_access", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "OIDC upstream browser flow happy path when downstream requested scopes include offline_access", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -626,6 +698,29 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "Bad Gateway: unexpected error during upstream authentication\n", }, + { + name: "wrong upstream credentials for OIDC password grant authentication", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + // This is similar to the error that would be returned by the underlying call to oauth2.PasswordCredentialsToken() + WithPasswordGrantError(&oauth2.RetrieveError{Response: &http.Response{Status: "fake status"}, Body: []byte("fake body")}). + Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr("wrong-password"), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: "wrong-password", + }}, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedErrorQuery), + wantBodyString: "", + }, { name: "wrong upstream password for LDAP authentication", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -675,8 +770,32 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "downstream redirect uri does not match what is configured for client when using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "missing upstream password on request for OIDC password grant authentication", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: nil, // do not send header + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), + wantBodyString: "", + }, + { + name: "using the custom username header on request for OIDC password grant authentication when OIDCIdentityProvider does not allow password grants", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithPasswordGrantDisallowedHintErrorQuery), + wantBodyString: "", + }, + { + name: "downstream redirect uri does not match what is configured for client when using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -690,6 +809,19 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: "application/json; charset=utf-8", wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, + { + name: "downstream redirect uri does not match what is configured for client when using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().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(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusBadRequest, + wantContentType: "application/json; charset=utf-8", + wantBodyJSON: fositeInvalidRedirectURIErrorBody, + }, { name: "downstream redirect uri does not match what is configured for client when using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -704,8 +836,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { - name: "downstream client does not exist when using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "downstream client does not exist when using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -717,6 +849,17 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: "application/json; charset=utf-8", wantBodyJSON: fositeInvalidClientErrorBody, }, + { + name: "downstream client does not exist when using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusUnauthorized, + wantContentType: "application/json; charset=utf-8", + wantBodyJSON: fositeInvalidClientErrorBody, + }, { name: "downstream client does not exist when using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -727,8 +870,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidClientErrorBody, }, { - name: "response type is unsupported when using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "response type is unsupported when using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -741,6 +884,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, + { + name: "response type is unsupported when using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), + wantBodyString: "", + }, { name: "response type is unsupported when using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -752,8 +907,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "downstream scopes do not match what is configured for client using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "downstream scopes do not match what is configured for client using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -766,6 +921,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantBodyString: "", }, + { + name: "downstream scopes do not match what is configured for client using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), + wantBodyString: "", + }, { name: "downstream scopes do not match what is configured for client using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -779,8 +946,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing response type in request using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "missing response type in request using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -793,6 +960,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, + { + name: "missing response type in request using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), + wantBodyString: "", + }, { name: "missing response type in request using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -804,8 +983,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing client id in request using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "missing client id in request using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -817,6 +996,17 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: "application/json; charset=utf-8", wantBodyJSON: fositeInvalidClientErrorBody, }, + { + name: "missing client id in request using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusUnauthorized, + wantContentType: "application/json; charset=utf-8", + wantBodyJSON: fositeInvalidClientErrorBody, + }, { name: "missing client id in request using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -827,8 +1017,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyJSON: fositeInvalidClientErrorBody, }, { - name: "missing PKCE code_challenge in request using OIDC upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "missing PKCE code_challenge in request using OIDC upstream browser flow", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -841,6 +1031,25 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), wantBodyString: "", }, + { + name: "missing PKCE code_challenge in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + 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: "missing PKCE code_challenge in request using LDAP upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -855,8 +1064,8 @@ func TestAuthorizationEndpoint(t *testing.T) { 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 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "invalid value for PKCE code_challenge_method in request using OIDC upstream browser flow", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -869,6 +1078,25 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), wantBodyString: "", }, + { + name: "invalid value for PKCE code_challenge_method in request using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + 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: "invalid value for PKCE code_challenge_method in request using LDAP upstream", // https://tools.ietf.org/html/rfc7636#section-4.3 idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -883,8 +1111,8 @@ func TestAuthorizationEndpoint(t *testing.T) { 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 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream browser flow", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -897,6 +1125,25 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", }, + { + name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + 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: "when PKCE code_challenge_method in request is `plain` using LDAP upstream", // https://tools.ietf.org/html/rfc7636#section-4.3 idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -911,8 +1158,8 @@ func TestAuthorizationEndpoint(t *testing.T) { 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 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "missing PKCE code_challenge_method in request using OIDC upstream browser flow", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -925,6 +1172,25 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", }, + { + name: "missing PKCE code_challenge_method in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + 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 LDAP upstream", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -940,9 +1206,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { // 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 OIDC upstream. - name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + // through that part of the fosite library when using an OIDC upstream browser flow. + name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -955,6 +1221,27 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), wantBodyString: "", }, + { + // 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 OIDC upstream password grant. + name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + 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 + }, { // 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. @@ -971,8 +1258,8 @@ func TestAuthorizationEndpoint(t *testing.T) { 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", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -990,6 +1277,33 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().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(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }}, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: []string{"email"}, // only email was requested + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{}, // no scopes granted + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, { name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -1012,8 +1326,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, }, { - name: "downstream state does not have enough entropy using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "downstream state does not have enough entropy using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1026,6 +1340,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), wantBodyString: "", }, + { + name: "downstream state does not have enough entropy using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), + wantBodyString: "", + }, { name: "downstream state does not have enough entropy using LDAP upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -1039,8 +1365,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "error while encoding upstream state param using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "error while encoding upstream state param using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1053,8 +1379,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 using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "error while encoding CSRF cookie value for new cookie using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1067,8 +1393,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error encoding CSRF cookie\n", }, { - name: "error while generating CSRF token using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "error while generating CSRF token using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: sadCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1081,8 +1407,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error generating CSRF token\n", }, { - name: "error while generating nonce using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "error while generating nonce using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: sadNonceGenerator, @@ -1095,8 +1421,8 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "Internal Server Error: error generating nonce param\n", }, { - name: "error while generating PKCE using OIDC upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + name: "error while generating PKCE using OIDC upstream browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), generateCSRF: happyCSRFGenerator, generatePKCE: sadPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1119,7 +1445,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: multiple OIDC", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider, upstreamOIDCIdentityProvider), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider(), upstreamOIDCIdentityProvider()), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -1137,7 +1463,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: both OIDC and LDAP", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider).WithLDAP(&upstreamLDAPIdentityProvider), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()).WithLDAP(&upstreamLDAPIdentityProvider), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -1146,7 +1472,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PUT is a bad method", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), method: http.MethodPut, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -1155,7 +1481,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PATCH is a bad method", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), method: http.MethodPatch, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -1164,7 +1490,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "DELETE is a bad method", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), method: http.MethodDelete, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index d29979c84..e45564e9f 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -251,11 +251,17 @@ func FositeErrorForLog(err error) []interface{} { rfc6749Error := fosite.ErrorToRFC6749Error(err) keysAndValues := make([]interface{}, 0) keysAndValues = append(keysAndValues, "name") - keysAndValues = append(keysAndValues, rfc6749Error.ErrorField) + keysAndValues = append(keysAndValues, rfc6749Error.Error()) // Error() returns the ErrorField keysAndValues = append(keysAndValues, "status") - keysAndValues = append(keysAndValues, rfc6749Error.Status()) + keysAndValues = append(keysAndValues, rfc6749Error.Status()) // Status() encodes the CodeField as a string keysAndValues = append(keysAndValues, "description") - keysAndValues = append(keysAndValues, rfc6749Error.DescriptionField) + keysAndValues = append(keysAndValues, rfc6749Error.GetDescription()) // GetDescription() returns the DescriptionField and the HintField + keysAndValues = append(keysAndValues, "debug") + keysAndValues = append(keysAndValues, rfc6749Error.Debug()) // Debug() returns the DebugField + if cause := rfc6749Error.Cause(); cause != nil { // Cause() returns the underlying error, or nil + keysAndValues = append(keysAndValues, "cause") + keysAndValues = append(keysAndValues, cause.Error()) + } return keysAndValues } diff --git a/internal/upstreamoidc/upstreamoidc.go b/internal/upstreamoidc/upstreamoidc.go index e7e2e8bb3..7ca73ee80 100644 --- a/internal/upstreamoidc/upstreamoidc.go +++ b/internal/upstreamoidc/upstreamoidc.go @@ -73,7 +73,7 @@ func (p *ProviderConfig) AllowsPasswordGrant() bool { func (p *ProviderConfig) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) { // Disallow this grant when requested. if !p.AllowPasswordGrant { - return nil, fmt.Errorf("resource owner password grant is not allowed for this upstream provider according to its configuration") + return nil, fmt.Errorf("resource owner password credentials grant is not allowed for this upstream provider according to its configuration") } // Note that this implicitly uses the scopes from p.Config.Scopes. diff --git a/internal/upstreamoidc/upstreamoidc_test.go b/internal/upstreamoidc/upstreamoidc_test.go index baed255f8..abba4417b 100644 --- a/internal/upstreamoidc/upstreamoidc_test.go +++ b/internal/upstreamoidc/upstreamoidc_test.go @@ -147,7 +147,7 @@ func TestProviderConfig(t *testing.T) { { name: "password grant not allowed", disallowPasswordGrant: true, // password grant is not allowed in this ProviderConfig - wantErr: "resource owner password grant is not allowed for this upstream provider according to its configuration", + wantErr: "resource owner password credentials grant is not allowed for this upstream provider according to its configuration", }, { name: "token request fails with http error", From 91c8a3ebed134d23069a6c5b9929b1d12c1071d7 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 16 Aug 2021 15:17:30 -0700 Subject: [PATCH 05/21] Extract private helper in auth_handler.go --- internal/oidc/auth/auth_handler.go | 55 +++++++++++++++--------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 701b6ac94..494b4d223 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -109,22 +109,11 @@ func handleAuthRequestForLDAPUpstream( return nil } - openIDSession := downstreamsession.MakeDownstreamSession( - downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse), - authenticateResponse.User.GetName(), - authenticateResponse.User.GetGroups(), - ) + subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) + username = authenticateResponse.User.GetName() + groups := 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 + return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups) } func handleAuthRequestForOIDCUpstreamPasswordGrant( @@ -179,18 +168,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return err } - openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) - - 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 + return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups) } func handleAuthRequestForOIDCUpstreamAuthcodeGrant( @@ -289,6 +267,29 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( return nil } +func makeDownstreamSessionAndReturnAuthcodeRedirect( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + authorizeRequester fosite.AuthorizeRequester, + subject string, + username string, + groups []string, +) error { + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) + + 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 requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { username := r.Header.Get(CustomUsernameHeaderName) password := r.Header.Get(CustomPasswordHeaderName) From 3fb683f64ee2771542d16b9e25c1ed751a288d5b Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 16 Aug 2021 15:40:34 -0700 Subject: [PATCH 06/21] Update expected error message in e2e integration test --- test/integration/e2e_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index d6fa6dbaa..61fcb1181 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -531,7 +531,7 @@ func TestE2EFullIntegration(t *testing.T) { require.Contains(t, kubectlOutput, `Error: could not complete Pinniped login: login failed with code "access_denied": `+ `The resource owner or authorization server denied the request. `+ - `resource owner password grant is not allowed for this upstream provider according to its configuration`, + `Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.`, ) }) From 964d16110ecddb0794e30454cfbcf15d46a30e59 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 17 Aug 2021 13:14:09 -0700 Subject: [PATCH 07/21] Some refactors based on PR feedback from @enj --- .../types_oidcidentityprovider.go.tmpl | 2 +- cmd/pinniped/cmd/kubeconfig_test.go | 2 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 2 +- generated/1.17/README.adoc | 2 +- .../v1alpha1/types_oidcidentityprovider.go | 2 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 2 +- generated/1.18/README.adoc | 2 +- .../v1alpha1/types_oidcidentityprovider.go | 2 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 2 +- generated/1.19/README.adoc | 2 +- .../v1alpha1/types_oidcidentityprovider.go | 2 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 2 +- generated/1.20/README.adoc | 2 +- .../v1alpha1/types_oidcidentityprovider.go | 2 +- ...or.pinniped.dev_oidcidentityproviders.yaml | 2 +- .../v1alpha1/types_oidcidentityprovider.go | 2 +- internal/oidc/auth/auth_handler.go | 7 +- internal/oidc/callback/callback_handler.go | 7 +- .../oidc/callback/callback_handler_test.go | 88 ++++++++++++- .../downstreamsession/downstream_session.go | 119 +++++++++--------- .../testutil/oidctestutil/oidctestutil.go | 3 - internal/upstreamoidc/upstreamoidc.go | 2 +- 22 files changed, 166 insertions(+), 92 deletions(-) diff --git a/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl b/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl index 3211d2e58..5d7277bfa 100644 --- a/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl +++ b/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl @@ -41,7 +41,7 @@ type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // request flow with an OIDC identity provider. // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index 5cf79148c..c3506bb77 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -149,7 +149,7 @@ func TestGetKubeconfig(t *testing.T) { --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-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode') + --upstream-identity-provider-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode') --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') `) diff --git a/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index d9ea45f01..70d3865dc 100644 --- a/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -62,7 +62,7 @@ spec: flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as - part of the token request (see also the AllowPasswordGrant field). + part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. items: type: string diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc index b709175f2..1dc3c5169 100644 --- a/generated/1.17/README.adoc +++ b/generated/1.17/README.adoc @@ -947,7 +947,7 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. | *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 3211d2e58..5d7277bfa 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -41,7 +41,7 @@ type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // request flow with an OIDC identity provider. // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` diff --git a/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index d9ea45f01..70d3865dc 100644 --- a/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -62,7 +62,7 @@ spec: flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as - part of the token request (see also the AllowPasswordGrant field). + part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. items: type: string diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc index 95ed5d611..9c6083ecc 100644 --- a/generated/1.18/README.adoc +++ b/generated/1.18/README.adoc @@ -947,7 +947,7 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. | *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 3211d2e58..5d7277bfa 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -41,7 +41,7 @@ type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // request flow with an OIDC identity provider. // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` diff --git a/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index d9ea45f01..70d3865dc 100644 --- a/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -62,7 +62,7 @@ spec: flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as - part of the token request (see also the AllowPasswordGrant field). + part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. items: type: string diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc index 94587807c..5d5825c6e 100644 --- a/generated/1.19/README.adoc +++ b/generated/1.19/README.adoc @@ -947,7 +947,7 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. | *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 3211d2e58..5d7277bfa 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -41,7 +41,7 @@ type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // request flow with an OIDC identity provider. // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` diff --git a/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index d9ea45f01..70d3865dc 100644 --- a/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -62,7 +62,7 @@ spec: flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as - part of the token request (see also the AllowPasswordGrant field). + part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. items: type: string diff --git a/generated/1.20/README.adoc b/generated/1.20/README.adoc index dba89ffcd..5038afa82 100644 --- a/generated/1.20/README.adoc +++ b/generated/1.20/README.adoc @@ -947,7 +947,7 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). By default, only the "openid" scope will be requested. +| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. | *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. |=== diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 3211d2e58..5d7277bfa 100644 --- a/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -41,7 +41,7 @@ type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // request flow with an OIDC identity provider. // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` diff --git a/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index d9ea45f01..70d3865dc 100644 --- a/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -62,7 +62,7 @@ spec: flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as - part of the token request (see also the AllowPasswordGrant field). + part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. items: type: string diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 3211d2e58..5d7277bfa 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -41,7 +41,7 @@ type OIDCAuthorizationConfig struct { // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization // request flow with an OIDC identity provider. // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the AllowPasswordGrant field). + // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). // By default, only the "openid" scope will be requested. // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 494b4d223..567ff1662 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -158,12 +158,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } - subject, username, err := downstreamsession.GetSubjectAndUsernameFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) - if err != nil { - return err - } - - groups, err := downstreamsession.GetGroupsFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) + subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) if err != nil { return err } diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index e4912da72..73e37b75e 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -68,12 +68,7 @@ func NewHandler( return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens") } - subject, username, err := downstreamsession.GetSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) - if err != nil { - return err - } - - groups, err := downstreamsession.GetGroupsFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) + subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) if err != nil { return err } diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index e2b9a31c7..7252da032 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -634,7 +634,7 @@ func TestCallbackEndpoint(t *testing.T) { path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, - wantBody: "Unprocessable Entity: no username claim in upstream ID token\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", wantContentType: htmlContentType, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, @@ -675,7 +675,23 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: username claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "upstream ID token contains username claim with empty string value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim(oidcUpstreamUsernameClaim, "").Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -683,6 +699,22 @@ func TestCallbackEndpoint(t *testing.T) { }, { name: "upstream ID token does not contain iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithoutIDTokenClaim("iss").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "upstream ID token does has an empty string value for iss claim when using default username claim config", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( happyUpstream().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), ), @@ -691,7 +723,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: issuer claim in upstream ID token missing\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -707,7 +739,55 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: issuer claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "upstream ID token does not contain sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithoutIDTokenClaim("sub").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: required claim in upstream ID token missing\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "upstream ID token does has an empty string value for sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim("sub", "").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: required claim in upstream ID token is empty\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "upstream ID token has an non-string sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + happyUpstream().WithIDTokenClaim("sub", 42).WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index e398908a1..c09ea8a7c 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -56,50 +56,39 @@ func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) { oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience") } -func GetSubjectAndUsernameFromUpstreamIDToken( +// GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order. +func GetDownstreamIdentityFromUpstreamIDToken( + upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + idTokenClaims map[string]interface{}, +) (string, string, []string, error) { + subject, username, err := getSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims) + if err != nil { + return "", "", nil, err + } + + groups, err := getGroupsFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims) + if err != nil { + return "", "", nil, err + } + + return subject, username, groups, err +} + +func getSubjectAndUsernameFromUpstreamIDToken( upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, idTokenClaims map[string]interface{}, ) (string, string, error) { // The spec says the "sub" claim is only unique per issuer, // so we will prepend the issuer string to make it globally unique. - upstreamIssuer := idTokenClaims[oidc.IDTokenIssuerClaim] - if upstreamIssuer == "" { - plog.Warning( - "issuer claim in upstream ID token missing", - "upstreamName", upstreamIDPConfig.GetName(), - "issClaim", upstreamIssuer, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token missing") + upstreamIssuer, err := extractStringClaimValue(oidc.IDTokenIssuerClaim, upstreamIDPConfig.GetName(), idTokenClaims) + if err != nil { + return "", "", err } - upstreamIssuerAsString, ok := upstreamIssuer.(string) - if !ok { - plog.Warning( - "issuer claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - "issClaim", upstreamIssuer, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "issuer claim in upstream ID token has invalid format") + upstreamSubject, err := extractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims) + if err != nil { + return "", "", err } - - subjectAsInterface, ok := idTokenClaims[oidc.IDTokenSubjectClaim] - if !ok { - plog.Warning( - "no subject claim in upstream ID token", - "upstreamName", upstreamIDPConfig.GetName(), - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "no subject claim in upstream ID token") - } - - upstreamSubject, ok := subjectAsInterface.(string) - if !ok { - plog.Warning( - "subject claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "subject claim in upstream ID token has invalid format") - } - - subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString, upstreamSubject) + subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuer, upstreamSubject) usernameClaimName := upstreamIDPConfig.GetUsernameClaim() if usernameClaimName == "" { @@ -130,34 +119,52 @@ func GetSubjectAndUsernameFromUpstreamIDToken( } } - usernameAsInterface, ok := idTokenClaims[usernameClaimName] - if !ok { - plog.Warning( - "no username claim in upstream ID token", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredUsernameClaim", usernameClaimName, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "no username claim in upstream ID token") - } - - username, ok := usernameAsInterface.(string) - if !ok { - plog.Warning( - "username claim in upstream ID token has invalid format", - "upstreamName", upstreamIDPConfig.GetName(), - "configuredUsernameClaim", usernameClaimName, - ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "username claim in upstream ID token has invalid format") + username, err := extractStringClaimValue(usernameClaimName, upstreamIDPConfig.GetName(), idTokenClaims) + if err != nil { + return "", "", err } return subject, username, nil } +func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenClaims map[string]interface{}) (string, error) { + value, ok := idTokenClaims[claimName] + if !ok { + plog.Warning( + "required claim in upstream ID token missing", + "upstreamName", upstreamIDPName, + "claimName", claimName, + ) + return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token missing") + } + + valueAsString, ok := value.(string) + if !ok { + plog.Warning( + "required claim in upstream ID token is not a string value", + "upstreamName", upstreamIDPName, + "claimName", claimName, + ) + return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token has invalid format") + } + + if valueAsString == "" { + plog.Warning( + "required claim in upstream ID token has an empty string value", + "upstreamName", upstreamIDPName, + "claimName", claimName, + ) + return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token is empty") + } + + return valueAsString, nil +} + func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string { return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject)) } -func GetGroupsFromUpstreamIDToken( +func getGroupsFromUpstreamIDToken( upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, idTokenClaims map[string]interface{}, ) ([]string, error) { diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 7591bbca2..d7bccd2a5 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -134,9 +134,6 @@ func (u *TestUpstreamOIDCIdentityProvider) AllowsPasswordGrant() bool { } func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) { - if u.passwordCredentialsGrantAndValidateTokensArgs == nil { - u.passwordCredentialsGrantAndValidateTokensArgs = make([]*PasswordCredentialsGrantAndValidateTokensArgs, 0) - } u.passwordCredentialsGrantAndValidateTokensCallCount++ u.passwordCredentialsGrantAndValidateTokensArgs = append(u.passwordCredentialsGrantAndValidateTokensArgs, &PasswordCredentialsGrantAndValidateTokensArgs{ Ctx: ctx, diff --git a/internal/upstreamoidc/upstreamoidc.go b/internal/upstreamoidc/upstreamoidc.go index 7ca73ee80..c8d3722f5 100644 --- a/internal/upstreamoidc/upstreamoidc.go +++ b/internal/upstreamoidc/upstreamoidc.go @@ -88,7 +88,7 @@ func (p *ProviderConfig) PasswordCredentialsGrantAndValidateTokens(ctx context.C // There is no nonce to validate for a resource owner password credentials grant because it skips using // the authorize endpoint and goes straight to the token endpoint. - skipNonceValidation := nonce.Nonce("") + const skipNonceValidation nonce.Nonce = "" return p.ValidateToken(ctx, tok, skipNonceValidation) } From 96474b3d99a31f41ace841149fcb7e6c331c35df Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 17 Aug 2021 15:23:03 -0700 Subject: [PATCH 08/21] Extract Supervisor IDP discovery endpoint types into apis package --- .../types_supervisor_idp_discovery.go.tmpl | 29 ++++++++++ cmd/pinniped/cmd/kubeconfig.go | 27 ++-------- .../types_supervisor_idp_discovery.go | 29 ++++++++++ .../types_supervisor_idp_discovery.go | 29 ++++++++++ .../types_supervisor_idp_discovery.go | 29 ++++++++++ .../types_supervisor_idp_discovery.go | 29 ++++++++++ .../types_supervisor_idp_discovery.go | 29 ++++++++++ internal/oidc/discovery/discovery_handler.go | 14 ++--- .../oidc/discovery/discovery_handler_test.go | 44 +++++++-------- .../idpdiscovery/idp_discovery_handler.go | 23 +++----- .../idp_discovery_handler_test.go | 54 +++++++++---------- 11 files changed, 236 insertions(+), 100 deletions(-) create mode 100644 apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl create mode 100644 generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go create mode 100644 generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go create mode 100644 generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go create mode 100644 generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go create mode 100644 generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go diff --git a/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl b/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl new file mode 100644 index 000000000..1d37195fa --- /dev/null +++ b/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl @@ -0,0 +1,29 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider +// configuration metadata and only picks out the portion related to Supervisor identity provider discovery. +type SupervisorOIDCDiscoveryResponse struct { + SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type SupervisorIDPDiscoveryResponse struct { + PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +} + +// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// identity provider discovery endpoint. +type SupervisorPinnipedIDP struct { + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` +} diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index c6abc1b6a..272f210d2 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -32,6 +32,7 @@ import ( conciergev1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" "go.pinniped.dev/internal/groupsuffix" ) @@ -98,24 +99,6 @@ 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"` - Flows []string `json:"flows,omitempty"` -} - func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { var ( cmd = &cobra.Command{ @@ -818,7 +801,7 @@ func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpCl return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) } - var body supervisorOIDCDiscoveryResponseWithV1Alpha1 + var body idpdiscoveryv1alpha1.SupervisorOIDCDiscoveryResponse err = discoveredProvider.Claims(&body) if err != nil { return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) @@ -827,7 +810,7 @@ func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpCl return body.SupervisorDiscovery.PinnipedIDPsEndpoint, nil } -func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDPsEndpoint string, httpClient *http.Client) ([]pinnipedIDPResponse, error) { +func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDPsEndpoint string, httpClient *http.Client) ([]idpdiscoveryv1alpha1.SupervisorPinnipedIDP, 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) @@ -849,7 +832,7 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: could not read response body: %w", err) } - var body supervisorIDPsDiscoveryResponseV1Alpha1 + var body idpdiscoveryv1alpha1.SupervisorIDPDiscoveryResponse 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) @@ -858,7 +841,7 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP return body.PinnipedIDPs, nil } -func selectUpstreamIDPNameAndType(pinnipedIDPs []pinnipedIDPResponse, specifiedIDPName, specifiedIDPType string) (string, string, []string, error) { +func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.SupervisorPinnipedIDP, specifiedIDPName, specifiedIDPType string) (string, string, []string, error) { pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) var discoveredFlows []string switch { diff --git a/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go new file mode 100644 index 000000000..1d37195fa --- /dev/null +++ b/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -0,0 +1,29 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider +// configuration metadata and only picks out the portion related to Supervisor identity provider discovery. +type SupervisorOIDCDiscoveryResponse struct { + SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type SupervisorIDPDiscoveryResponse struct { + PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +} + +// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// identity provider discovery endpoint. +type SupervisorPinnipedIDP struct { + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` +} diff --git a/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go new file mode 100644 index 000000000..1d37195fa --- /dev/null +++ b/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -0,0 +1,29 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider +// configuration metadata and only picks out the portion related to Supervisor identity provider discovery. +type SupervisorOIDCDiscoveryResponse struct { + SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type SupervisorIDPDiscoveryResponse struct { + PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +} + +// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// identity provider discovery endpoint. +type SupervisorPinnipedIDP struct { + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` +} diff --git a/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go new file mode 100644 index 000000000..1d37195fa --- /dev/null +++ b/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -0,0 +1,29 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider +// configuration metadata and only picks out the portion related to Supervisor identity provider discovery. +type SupervisorOIDCDiscoveryResponse struct { + SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type SupervisorIDPDiscoveryResponse struct { + PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +} + +// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// identity provider discovery endpoint. +type SupervisorPinnipedIDP struct { + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` +} diff --git a/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go new file mode 100644 index 000000000..1d37195fa --- /dev/null +++ b/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -0,0 +1,29 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider +// configuration metadata and only picks out the portion related to Supervisor identity provider discovery. +type SupervisorOIDCDiscoveryResponse struct { + SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type SupervisorIDPDiscoveryResponse struct { + PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +} + +// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// identity provider discovery endpoint. +type SupervisorPinnipedIDP struct { + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` +} diff --git a/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go new file mode 100644 index 000000000..1d37195fa --- /dev/null +++ b/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -0,0 +1,29 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider +// configuration metadata and only picks out the portion related to Supervisor identity provider discovery. +type SupervisorOIDCDiscoveryResponse struct { + SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +} + +// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { + PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` +} + +// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type SupervisorIDPDiscoveryResponse struct { + PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +} + +// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// identity provider discovery endpoint. +type SupervisorPinnipedIDP struct { + Name string `json:"name"` + Type string `json:"type"` + Flows []string `json:"flows,omitempty"` +} diff --git a/internal/oidc/discovery/discovery_handler.go b/internal/oidc/discovery/discovery_handler.go index 008808b6e..591a6d989 100644 --- a/internal/oidc/discovery/discovery_handler.go +++ b/internal/oidc/discovery/discovery_handler.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" + "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/oidc" ) @@ -41,20 +42,11 @@ type Metadata struct { // vvv Custom vvv - SupervisorDiscovery SupervisorDiscoveryMetadataV1Alpha1 `json:"discovery.supervisor.pinniped.dev/v1alpha1"` + SupervisorDiscovery v1alpha1.SupervisorOIDCDiscoveryResponseIDPEndpoint `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. func NewHandler(issuerURL string) http.Handler { oidcConfig := Metadata{ @@ -62,7 +54,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}, + SupervisorDiscovery: v1alpha1.SupervisorOIDCDiscoveryResponseIDPEndpoint{PinnipedIDPsEndpoint: issuerURL + oidc.PinnipedIDPsPathV1Alpha1}, ResponseTypesSupported: []string{"code"}, ResponseModesSupported: []string{"query", "form_post"}, SubjectTypesSupported: []string{"public"}, diff --git a/internal/oidc/discovery/discovery_handler_test.go b/internal/oidc/discovery/discovery_handler_test.go index b1707f776..855f830dc 100644 --- a/internal/oidc/discovery/discovery_handler_test.go +++ b/internal/oidc/discovery/discovery_handler_test.go @@ -4,13 +4,13 @@ package discovery import ( - "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" ) @@ -24,7 +24,7 @@ func TestDiscovery(t *testing.T) { wantStatus int wantContentType string - wantBodyJSON interface{} + wantBodyJSON string wantBodyString string }{ { @@ -34,22 +34,24 @@ func TestDiscovery(t *testing.T) { path: "/some/path" + oidc.WellKnownEndpointPath, 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", - SupervisorDiscovery: SupervisorDiscoveryMetadataV1Alpha1{ - PinnipedIDPsEndpoint: "https://some-issuer.com/some/path/v1alpha1/pinniped_identity_providers", - }, - ResponseTypesSupported: []string{"code"}, - ResponseModesSupported: []string{"query", "form_post"}, - SubjectTypesSupported: []string{"public"}, - IDTokenSigningAlgValuesSupported: []string{"ES256"}, - TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, - ScopesSupported: []string{"openid", "offline"}, - ClaimsSupported: []string{"groups"}, - }, + wantBodyJSON: here.Doc(` + { + "issuer": "https://some-issuer.com/some/path", + "authorization_endpoint": "https://some-issuer.com/some/path/oauth2/authorize", + "token_endpoint": "https://some-issuer.com/some/path/oauth2/token", + "jwks_uri": "https://some-issuer.com/some/path/jwks.json", + "response_types_supported": ["code"], + "response_modes_supported": ["query", "form_post"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["ES256"], + "token_endpoint_auth_methods_supported": ["client_secret_basic"], + "scopes_supported": ["openid", "offline"], + "claims_supported": ["groups"], + "discovery.supervisor.pinniped.dev/v1alpha1": { + "pinniped_identity_providers_endpoint": "https://some-issuer.com/some/path/v1alpha1/pinniped_identity_providers" + } + } + `), }, { name: "bad method", @@ -73,10 +75,8 @@ func TestDiscovery(t *testing.T) { require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type")) - if test.wantBodyJSON != nil { - wantJSON, err := json.Marshal(test.wantBodyJSON) - require.NoError(t, err) - require.JSONEq(t, string(wantJSON), rsp.Body.String()) + if test.wantBodyJSON != "" { + require.JSONEq(t, test.wantBodyJSON, rsp.Body.String()) } if test.wantBodyString != "" { diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/oidc/idpdiscovery/idp_discovery_handler.go index 8e4535a8a..32ce187d9 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler.go @@ -10,6 +10,7 @@ import ( "net/http" "sort" + "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/oidc" ) @@ -21,16 +22,6 @@ const ( flowCLIPassword = "cli_password" ) -type response struct { - IDPs []identityProviderResponse `json:"pinniped_identity_providers"` -} - -type identityProviderResponse struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows"` -} - // 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) { @@ -54,13 +45,13 @@ func NewHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler } func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, error) { - r := response{ - IDPs: []identityProviderResponse{}, + r := v1alpha1.SupervisorIDPDiscoveryResponse{ + PinnipedIDPs: []v1alpha1.SupervisorPinnipedIDP{}, } // 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{ + r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.SupervisorPinnipedIDP{ Name: provider.GetName(), Type: idpDiscoveryTypeLDAP, Flows: []string{flowCLIPassword}, @@ -71,7 +62,7 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, if provider.AllowsPasswordGrant() { flows = append(flows, flowCLIPassword) } - r.IDPs = append(r.IDPs, identityProviderResponse{ + r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.SupervisorPinnipedIDP{ Name: provider.GetName(), Type: idpDiscoveryTypeOIDC, Flows: flows, @@ -79,8 +70,8 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, } // 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 + sort.SliceStable(r.PinnipedIDPs, func(i, j int) bool { + return r.PinnipedIDPs[i].Name < r.PinnipedIDPs[j].Name }) var b bytes.Buffer diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go index 8f7b270ad..7faf8a35f 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go @@ -4,13 +4,13 @@ package idpdiscovery import ( - "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -25,8 +25,8 @@ func TestIDPDiscovery(t *testing.T) { wantStatus int wantContentType string - wantFirstResponseBodyJSON interface{} - wantSecondResponseBodyJSON interface{} + wantFirstResponseBodyJSON string + wantSecondResponseBodyJSON string wantBodyString string }{ { @@ -35,24 +35,24 @@ func TestIDPDiscovery(t *testing.T) { path: "/some/path" + oidc.WellKnownEndpointPath, wantStatus: http.StatusOK, wantContentType: "application/json", - wantFirstResponseBodyJSON: &response{ - IDPs: []identityProviderResponse{ - {Name: "a-some-ldap-idp", Type: "ldap", Flows: []string{"cli_password"}}, - {Name: "a-some-oidc-idp", Type: "oidc", Flows: []string{"browser_authcode"}}, - {Name: "x-some-idp", Type: "ldap", Flows: []string{"cli_password"}}, - {Name: "x-some-idp", Type: "oidc", Flows: []string{"browser_authcode"}}, - {Name: "z-some-ldap-idp", Type: "ldap", Flows: []string{"cli_password"}}, - {Name: "z-some-oidc-idp", Type: "oidc", Flows: []string{"browser_authcode", "cli_password"}}, - }, - }, - wantSecondResponseBodyJSON: &response{ - IDPs: []identityProviderResponse{ - {Name: "some-other-ldap-idp-1", Type: "ldap", Flows: []string{"cli_password"}}, - {Name: "some-other-ldap-idp-2", Type: "ldap", Flows: []string{"cli_password"}}, - {Name: "some-other-oidc-idp-1", Type: "oidc", Flows: []string{"browser_authcode", "cli_password"}}, - {Name: "some-other-oidc-idp-2", Type: "oidc", Flows: []string{"browser_authcode"}}, - }, - }, + wantFirstResponseBodyJSON: here.Doc(`{ + "pinniped_identity_providers": [ + {"name": "a-some-ldap-idp", "type": "ldap", "flows": ["cli_password"]}, + {"name": "a-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode"]}, + {"name": "x-some-idp", "type": "ldap", "flows": ["cli_password"]}, + {"name": "x-some-idp", "type": "oidc", "flows": ["browser_authcode"]}, + {"name": "z-some-ldap-idp", "type": "ldap", "flows": ["cli_password"]}, + {"name": "z-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode", "cli_password"]} + ] + }`), + wantSecondResponseBodyJSON: here.Doc(`{ + "pinniped_identity_providers": [ + {"name": "some-other-ldap-idp-1", "type": "ldap", "flows": ["cli_password"]}, + {"name": "some-other-ldap-idp-2", "type": "ldap", "flows": ["cli_password"]}, + {"name": "some-other-oidc-idp-1", "type": "oidc", "flows": ["browser_authcode", "cli_password"]}, + {"name": "some-other-oidc-idp-2", "type": "oidc", "flows": ["browser_authcode"]} + ] + }`), }, { name: "bad method", @@ -84,10 +84,8 @@ func TestIDPDiscovery(t *testing.T) { 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.wantFirstResponseBodyJSON != "" { + require.JSONEq(t, test.wantFirstResponseBodyJSON, rsp.Body.String()) } if test.wantBodyString != "" { @@ -112,10 +110,8 @@ func TestIDPDiscovery(t *testing.T) { 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.wantFirstResponseBodyJSON != "" { + require.JSONEq(t, test.wantSecondResponseBodyJSON, rsp.Body.String()) } if test.wantBodyString != "" { From 0089540b078f75bb10fbff878fc3f2c66aa0e764 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 17 Aug 2021 17:50:02 -0700 Subject: [PATCH 09/21] Extract Supervisor IDP discovery endpoint string constants into apis pkg --- .../types_supervisor_idp_discovery.go.tmpl | 42 +++++++++++++++++-- cmd/pinniped/cmd/kubeconfig.go | 32 +++++++------- cmd/pinniped/cmd/login_oidc.go | 37 ++++++++-------- .../types_supervisor_idp_discovery.go | 42 +++++++++++++++++-- .../types_supervisor_idp_discovery.go | 42 +++++++++++++++++-- .../types_supervisor_idp_discovery.go | 42 +++++++++++++++++-- .../types_supervisor_idp_discovery.go | 42 +++++++++++++++++-- .../types_supervisor_idp_discovery.go | 42 +++++++++++++++++-- .../idpdiscovery/idp_discovery_handler.go | 18 +++----- 9 files changed, 274 insertions(+), 65 deletions(-) diff --git a/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl b/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl index 1d37195fa..f67022737 100644 --- a/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl +++ b/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl @@ -3,6 +3,42 @@ package v1alpha1 +// IDPType are the strings that can be returned by the Supervisor identity provider discovery endpoint +// as the "type" of each returned identity provider. +type IDPType string + +// IDPFlow are the strings that can be returned by the Supervisor identity provider discovery endpoint +// in the array of allowed client "flows" for each returned identity provider. +type IDPFlow string + +const ( + IDPTypeOIDC IDPType = "oidc" + IDPTypeLDAP IDPType = "ldap" + + IDPFlowCLIPassword IDPFlow = "cli_password" + IDPFlowBrowserAuthcode IDPFlow = "browser_authcode" +) + +// Equals is a convenience function for comparing an IDPType to a string. +func (r IDPType) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPType to a string. +func (r IDPType) String() string { + return string(r) +} + +// Equals is a convenience function for comparing an IDPFlow to a string. +func (r IDPFlow) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPFlow to a string. +func (r IDPFlow) String() string { + return string(r) +} + // SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. @@ -23,7 +59,7 @@ type SupervisorIDPDiscoveryResponse struct { // SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. type SupervisorPinnipedIDP struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows,omitempty"` + Name string `json:"name"` + Type IDPType `json:"type"` + Flows []IDPFlow `json:"flows,omitempty"` } diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 272f210d2..53429b8c7 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -138,8 +138,8 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { 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.oidc.upstreamIDPFlow, "upstream-identity-provider-flow", "", "The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'cli_password', 'browser_authcode')") + f.StringVar(&flags.oidc.upstreamIDPType, "upstream-identity-provider-type", "", fmt.Sprintf("The type of the upstream identity provider used during login with a Supervisor (e.g. '%s', '%s')", idpdiscoveryv1alpha1.IDPTypeOIDC, idpdiscoveryv1alpha1.IDPTypeLDAP)) + f.StringVar(&flags.oidc.upstreamIDPFlow, "upstream-identity-provider-flow", "", fmt.Sprintf("The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. '%s', '%s')", idpdiscoveryv1alpha1.IDPFlowCLIPassword, idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode)) 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)") @@ -772,8 +772,8 @@ func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigPara } flags.oidc.upstreamIDPName = selectedIDPName - flags.oidc.upstreamIDPType = selectedIDPType - flags.oidc.upstreamIDPFlow = selectedIDPFlow + flags.oidc.upstreamIDPType = selectedIDPType.String() + flags.oidc.upstreamIDPFlow = selectedIDPFlow.String() return nil } @@ -841,15 +841,15 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP return body.PinnipedIDPs, nil } -func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.SupervisorPinnipedIDP, specifiedIDPName, specifiedIDPType string) (string, string, []string, error) { +func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.SupervisorPinnipedIDP, specifiedIDPName, specifiedIDPType string) (string, idpdiscoveryv1alpha1.IDPType, []idpdiscoveryv1alpha1.IDPFlow, error) { pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) - var discoveredFlows []string + var discoveredFlows []idpdiscoveryv1alpha1.IDPFlow switch { case specifiedIDPName != "" && specifiedIDPType != "": // The user specified both name and type, so check to see if there exists an exact match. for _, idp := range pinnipedIDPs { - if idp.Name == specifiedIDPName && idp.Type == specifiedIDPType { - return specifiedIDPName, specifiedIDPType, idp.Flows, nil + if idp.Name == specifiedIDPName && idp.Type.Equals(specifiedIDPType) { + return specifiedIDPName, idp.Type, idp.Flows, nil } } return "", "", nil, fmt.Errorf( @@ -858,8 +858,9 @@ func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.Supervisor case specifiedIDPType != "": // The user specified only a type, so check if there is only one of that type found. discoveredName := "" + var discoveredType idpdiscoveryv1alpha1.IDPType for _, idp := range pinnipedIDPs { - if idp.Type == specifiedIDPType { + if idp.Type.Equals(specifiedIDPType) { if discoveredName != "" { return "", "", nil, fmt.Errorf( "multiple Supervisor upstream identity providers of type %q were found, "+ @@ -868,6 +869,7 @@ func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.Supervisor specifiedIDPType, pinnipedIDPsString) } discoveredName = idp.Name + discoveredType = idp.Type discoveredFlows = idp.Flows } } @@ -876,10 +878,10 @@ func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.Supervisor "no Supervisor upstream identity providers of type %q were found. "+ "Found these upstreams: %s", specifiedIDPType, pinnipedIDPsString) } - return discoveredName, specifiedIDPType, discoveredFlows, nil + return discoveredName, discoveredType, discoveredFlows, nil case specifiedIDPName != "": // The user specified only a name, so check if there is only one of that name found. - discoveredType := "" + var discoveredType idpdiscoveryv1alpha1.IDPType for _, idp := range pinnipedIDPs { if idp.Name == specifiedIDPName { if discoveredType != "" { @@ -911,19 +913,19 @@ func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.Supervisor } } -func selectUpstreamIDPFlow(discoveredIDPFlows []string, selectedIDPName string, selectedIDPType string, specifiedFlow string) (string, error) { +func selectUpstreamIDPFlow(discoveredIDPFlows []idpdiscoveryv1alpha1.IDPFlow, selectedIDPName string, selectedIDPType idpdiscoveryv1alpha1.IDPType, specifiedFlow string) (idpdiscoveryv1alpha1.IDPFlow, error) { switch { case len(discoveredIDPFlows) == 0: // No flows listed by discovery means that we are talking to an old Supervisor from before this feature existed. // If the user specified a flow on the CLI flag then use it without validation, otherwise skip flow selection // and return empty string. - return specifiedFlow, nil + return idpdiscoveryv1alpha1.IDPFlow(specifiedFlow), nil case specifiedFlow != "": // The user specified a flow, so validate that it is available for the selected IDP. for _, flow := range discoveredIDPFlows { - if flow == specifiedFlow { + if flow.Equals(specifiedFlow) { // Found it, so use it as specified by the user. - return specifiedFlow, nil + return flow, nil } } return "", fmt.Errorf( diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 25ebcc6d4..eaef1fdaa 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -24,6 +24,7 @@ import ( "k8s.io/client-go/transport" "k8s.io/klog/v2/klogr" + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/execcredcache" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/plog" @@ -38,13 +39,6 @@ func init() { loginCmd.AddCommand(oidcLoginCommand(oidcLoginCommandRealDeps())) } -const ( - idpTypeOIDC = "oidc" - idpTypeLDAP = "ldap" - idpFlowCLIPassword = "cli_password" - idpFlowBrowserAuthcode = "browser_authcode" -) - type oidcLoginCommandDeps struct { lookupEnv func(string) (string, bool) login func(string, string, ...oidcclient.Option) (*oidctypes.Token, error) @@ -116,8 +110,8 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { 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", idpTypeOIDC, fmt.Sprintf("The type of the upstream identity provider used during login with a Supervisor (e.g. '%s', '%s')", idpTypeOIDC, idpTypeLDAP)) - cmd.Flags().StringVar(&flags.upstreamIdentityProviderFlow, "upstream-identity-provider-flow", "", fmt.Sprintf("The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. '%s', '%s')", idpFlowBrowserAuthcode, idpFlowCLIPassword)) + cmd.Flags().StringVar(&flags.upstreamIdentityProviderType, "upstream-identity-provider-type", idpdiscoveryv1alpha1.IDPTypeOIDC.String(), fmt.Sprintf("The type of the upstream identity provider used during login with a Supervisor (e.g. '%s', '%s')", idpdiscoveryv1alpha1.IDPTypeOIDC, idpdiscoveryv1alpha1.IDPTypeLDAP)) + cmd.Flags().StringVar(&flags.upstreamIdentityProviderFlow, "upstream-identity-provider-flow", "", fmt.Sprintf("The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. '%s', '%s')", idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode, idpdiscoveryv1alpha1.IDPFlowCLIPassword)) // --skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case. mustMarkHidden(cmd, "skip-listen") @@ -170,7 +164,10 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin flags.upstreamIdentityProviderName, flags.upstreamIdentityProviderType)) } - flowOpts, err := flowOptions(flags.upstreamIdentityProviderType, flags.upstreamIdentityProviderFlow) + flowOpts, err := flowOptions( + idpdiscoveryv1alpha1.IDPType(flags.upstreamIdentityProviderType), + idpdiscoveryv1alpha1.IDPFlow(flags.upstreamIdentityProviderFlow), + ) if err != nil { return err } @@ -255,35 +252,37 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin return json.NewEncoder(cmd.OutOrStdout()).Encode(cred) } -func flowOptions(requestedIDPType string, requestedFlow string) ([]oidcclient.Option, error) { +func flowOptions(requestedIDPType idpdiscoveryv1alpha1.IDPType, requestedFlow idpdiscoveryv1alpha1.IDPFlow) ([]oidcclient.Option, error) { useCLIFlow := []oidcclient.Option{oidcclient.WithCLISendingCredentials()} switch requestedIDPType { - case idpTypeOIDC: + case idpdiscoveryv1alpha1.IDPTypeOIDC: switch requestedFlow { - case idpFlowCLIPassword: + case idpdiscoveryv1alpha1.IDPFlowCLIPassword: return useCLIFlow, nil - case idpFlowBrowserAuthcode, "": + case idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode, "": return nil, nil // browser authcode flow is the default Option, so don't need to return an Option here default: return nil, fmt.Errorf( "--upstream-identity-provider-flow value not recognized for identity provider type %q: %s (supported values: %s)", - requestedIDPType, requestedFlow, strings.Join([]string{idpFlowBrowserAuthcode, idpFlowCLIPassword}, ", ")) + requestedIDPType, requestedFlow, strings.Join([]string{idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode.String(), idpdiscoveryv1alpha1.IDPFlowCLIPassword.String()}, ", ")) } - case idpTypeLDAP: + case idpdiscoveryv1alpha1.IDPTypeLDAP: switch requestedFlow { - case idpFlowCLIPassword, "": + case idpdiscoveryv1alpha1.IDPFlowCLIPassword, "": return useCLIFlow, nil + case idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode: + fallthrough // not supported for LDAP providers, so fallthrough to error case default: return nil, fmt.Errorf( "--upstream-identity-provider-flow value not recognized for identity provider type %q: %s (supported values: %s)", - requestedIDPType, requestedFlow, []string{idpFlowCLIPassword}) + requestedIDPType, requestedFlow, []string{idpdiscoveryv1alpha1.IDPFlowCLIPassword.String()}) } default: // Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236 return nil, fmt.Errorf( "--upstream-identity-provider-type value not recognized: %s (supported values: %s)", - requestedIDPType, strings.Join([]string{idpTypeOIDC, idpTypeLDAP}, ", ")) + requestedIDPType, strings.Join([]string{idpdiscoveryv1alpha1.IDPTypeOIDC.String(), idpdiscoveryv1alpha1.IDPTypeLDAP.String()}, ", ")) } } diff --git a/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index 1d37195fa..f67022737 100644 --- a/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -3,6 +3,42 @@ package v1alpha1 +// IDPType are the strings that can be returned by the Supervisor identity provider discovery endpoint +// as the "type" of each returned identity provider. +type IDPType string + +// IDPFlow are the strings that can be returned by the Supervisor identity provider discovery endpoint +// in the array of allowed client "flows" for each returned identity provider. +type IDPFlow string + +const ( + IDPTypeOIDC IDPType = "oidc" + IDPTypeLDAP IDPType = "ldap" + + IDPFlowCLIPassword IDPFlow = "cli_password" + IDPFlowBrowserAuthcode IDPFlow = "browser_authcode" +) + +// Equals is a convenience function for comparing an IDPType to a string. +func (r IDPType) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPType to a string. +func (r IDPType) String() string { + return string(r) +} + +// Equals is a convenience function for comparing an IDPFlow to a string. +func (r IDPFlow) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPFlow to a string. +func (r IDPFlow) String() string { + return string(r) +} + // SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. @@ -23,7 +59,7 @@ type SupervisorIDPDiscoveryResponse struct { // SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. type SupervisorPinnipedIDP struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows,omitempty"` + Name string `json:"name"` + Type IDPType `json:"type"` + Flows []IDPFlow `json:"flows,omitempty"` } diff --git a/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index 1d37195fa..f67022737 100644 --- a/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -3,6 +3,42 @@ package v1alpha1 +// IDPType are the strings that can be returned by the Supervisor identity provider discovery endpoint +// as the "type" of each returned identity provider. +type IDPType string + +// IDPFlow are the strings that can be returned by the Supervisor identity provider discovery endpoint +// in the array of allowed client "flows" for each returned identity provider. +type IDPFlow string + +const ( + IDPTypeOIDC IDPType = "oidc" + IDPTypeLDAP IDPType = "ldap" + + IDPFlowCLIPassword IDPFlow = "cli_password" + IDPFlowBrowserAuthcode IDPFlow = "browser_authcode" +) + +// Equals is a convenience function for comparing an IDPType to a string. +func (r IDPType) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPType to a string. +func (r IDPType) String() string { + return string(r) +} + +// Equals is a convenience function for comparing an IDPFlow to a string. +func (r IDPFlow) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPFlow to a string. +func (r IDPFlow) String() string { + return string(r) +} + // SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. @@ -23,7 +59,7 @@ type SupervisorIDPDiscoveryResponse struct { // SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. type SupervisorPinnipedIDP struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows,omitempty"` + Name string `json:"name"` + Type IDPType `json:"type"` + Flows []IDPFlow `json:"flows,omitempty"` } diff --git a/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index 1d37195fa..f67022737 100644 --- a/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -3,6 +3,42 @@ package v1alpha1 +// IDPType are the strings that can be returned by the Supervisor identity provider discovery endpoint +// as the "type" of each returned identity provider. +type IDPType string + +// IDPFlow are the strings that can be returned by the Supervisor identity provider discovery endpoint +// in the array of allowed client "flows" for each returned identity provider. +type IDPFlow string + +const ( + IDPTypeOIDC IDPType = "oidc" + IDPTypeLDAP IDPType = "ldap" + + IDPFlowCLIPassword IDPFlow = "cli_password" + IDPFlowBrowserAuthcode IDPFlow = "browser_authcode" +) + +// Equals is a convenience function for comparing an IDPType to a string. +func (r IDPType) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPType to a string. +func (r IDPType) String() string { + return string(r) +} + +// Equals is a convenience function for comparing an IDPFlow to a string. +func (r IDPFlow) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPFlow to a string. +func (r IDPFlow) String() string { + return string(r) +} + // SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. @@ -23,7 +59,7 @@ type SupervisorIDPDiscoveryResponse struct { // SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. type SupervisorPinnipedIDP struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows,omitempty"` + Name string `json:"name"` + Type IDPType `json:"type"` + Flows []IDPFlow `json:"flows,omitempty"` } diff --git a/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index 1d37195fa..f67022737 100644 --- a/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -3,6 +3,42 @@ package v1alpha1 +// IDPType are the strings that can be returned by the Supervisor identity provider discovery endpoint +// as the "type" of each returned identity provider. +type IDPType string + +// IDPFlow are the strings that can be returned by the Supervisor identity provider discovery endpoint +// in the array of allowed client "flows" for each returned identity provider. +type IDPFlow string + +const ( + IDPTypeOIDC IDPType = "oidc" + IDPTypeLDAP IDPType = "ldap" + + IDPFlowCLIPassword IDPFlow = "cli_password" + IDPFlowBrowserAuthcode IDPFlow = "browser_authcode" +) + +// Equals is a convenience function for comparing an IDPType to a string. +func (r IDPType) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPType to a string. +func (r IDPType) String() string { + return string(r) +} + +// Equals is a convenience function for comparing an IDPFlow to a string. +func (r IDPFlow) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPFlow to a string. +func (r IDPFlow) String() string { + return string(r) +} + // SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. @@ -23,7 +59,7 @@ type SupervisorIDPDiscoveryResponse struct { // SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. type SupervisorPinnipedIDP struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows,omitempty"` + Name string `json:"name"` + Type IDPType `json:"type"` + Flows []IDPFlow `json:"flows,omitempty"` } diff --git a/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index 1d37195fa..f67022737 100644 --- a/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -3,6 +3,42 @@ package v1alpha1 +// IDPType are the strings that can be returned by the Supervisor identity provider discovery endpoint +// as the "type" of each returned identity provider. +type IDPType string + +// IDPFlow are the strings that can be returned by the Supervisor identity provider discovery endpoint +// in the array of allowed client "flows" for each returned identity provider. +type IDPFlow string + +const ( + IDPTypeOIDC IDPType = "oidc" + IDPTypeLDAP IDPType = "ldap" + + IDPFlowCLIPassword IDPFlow = "cli_password" + IDPFlowBrowserAuthcode IDPFlow = "browser_authcode" +) + +// Equals is a convenience function for comparing an IDPType to a string. +func (r IDPType) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPType to a string. +func (r IDPType) String() string { + return string(r) +} + +// Equals is a convenience function for comparing an IDPFlow to a string. +func (r IDPFlow) Equals(s string) bool { + return string(r) == s +} + +// String is a convenience function to convert an IDPFlow to a string. +func (r IDPFlow) String() string { + return string(r) +} + // SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. @@ -23,7 +59,7 @@ type SupervisorIDPDiscoveryResponse struct { // SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. type SupervisorPinnipedIDP struct { - Name string `json:"name"` - Type string `json:"type"` - Flows []string `json:"flows,omitempty"` + Name string `json:"name"` + Type IDPType `json:"type"` + Flows []IDPFlow `json:"flows,omitempty"` } diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/oidc/idpdiscovery/idp_discovery_handler.go index 32ce187d9..1ffeca2f2 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler.go @@ -14,14 +14,6 @@ import ( "go.pinniped.dev/internal/oidc" ) -const ( - idpDiscoveryTypeLDAP = "ldap" - idpDiscoveryTypeOIDC = "oidc" - - flowOIDCBrowser = "browser_authcode" - flowCLIPassword = "cli_password" -) - // 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) { @@ -53,18 +45,18 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() { r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.SupervisorPinnipedIDP{ Name: provider.GetName(), - Type: idpDiscoveryTypeLDAP, - Flows: []string{flowCLIPassword}, + Type: v1alpha1.IDPTypeLDAP, + Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword}, }) } for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() { - flows := []string{flowOIDCBrowser} + flows := []v1alpha1.IDPFlow{v1alpha1.IDPFlowBrowserAuthcode} if provider.AllowsPasswordGrant() { - flows = append(flows, flowCLIPassword) + flows = append(flows, v1alpha1.IDPFlowCLIPassword) } r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.SupervisorPinnipedIDP{ Name: provider.GetName(), - Type: idpDiscoveryTypeOIDC, + Type: v1alpha1.IDPTypeOIDC, Flows: flows, }) } From 04b8f0b45595ccf4e60b6a78625b2125d81d5a0e Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 18 Aug 2021 10:20:33 -0700 Subject: [PATCH 10/21] Extract Supervisor authorize endpoint string constants into apis pkg --- .../types_supervisor_idp_discovery.go.tmpl | 20 +++++++-------- .../oidc/types_supervisor_oidc.go.tmpl | 25 +++++++++++++++++++ cmd/pinniped/cmd/kubeconfig.go | 8 +++--- .../types_supervisor_idp_discovery.go | 20 +++++++-------- .../supervisor/oidc/types_supervisor_oidc.go | 25 +++++++++++++++++++ .../types_supervisor_idp_discovery.go | 20 +++++++-------- .../supervisor/oidc/types_supervisor_oidc.go | 25 +++++++++++++++++++ .../types_supervisor_idp_discovery.go | 20 +++++++-------- .../supervisor/oidc/types_supervisor_oidc.go | 25 +++++++++++++++++++ .../types_supervisor_idp_discovery.go | 20 +++++++-------- .../supervisor/oidc/types_supervisor_oidc.go | 25 +++++++++++++++++++ .../types_supervisor_idp_discovery.go | 20 +++++++-------- .../supervisor/oidc/types_supervisor_oidc.go | 25 +++++++++++++++++++ internal/oidc/auth/auth_handler.go | 12 +++------ internal/oidc/discovery/discovery_handler.go | 16 +++++++----- .../idpdiscovery/idp_discovery_handler.go | 8 +++--- pkg/oidcclient/login.go | 18 ++++++------- 17 files changed, 240 insertions(+), 92 deletions(-) create mode 100644 apis/supervisor/oidc/types_supervisor_oidc.go.tmpl create mode 100644 generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go create mode 100644 generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go create mode 100644 generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go create mode 100644 generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go create mode 100644 generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go diff --git a/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl b/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl index f67022737..d40dab826 100644 --- a/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl +++ b/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go.tmpl @@ -39,26 +39,26 @@ func (r IDPFlow) String() string { return string(r) } -// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// OIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. -type SupervisorOIDCDiscoveryResponse struct { - SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +type OIDCDiscoveryResponse struct { + SupervisorDiscovery OIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` } -// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. -type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { +// OIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type OIDCDiscoveryResponseIDPEndpoint struct { PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` } -// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. -type SupervisorIDPDiscoveryResponse struct { - PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type IDPDiscoveryResponse struct { + PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"` } -// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. -type SupervisorPinnipedIDP struct { +type PinnipedIDP struct { Name string `json:"name"` Type IDPType `json:"type"` Flows []IDPFlow `json:"flows,omitempty"` diff --git a/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl b/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl new file mode 100644 index 000000000..36d95d189 --- /dev/null +++ b/apis/supervisor/oidc/types_supervisor_oidc.go.tmpl @@ -0,0 +1,25 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +// Constants related to the Supervisor FederationDomain's authorization and token endpoints. +const ( + // AuthorizeUsernameHeaderName is the name of the HTTP header which can be used to transmit a username + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizeUsernameHeaderName = "Pinniped-Username" + + // AuthorizePasswordHeaderName is the name of the HTTP header which can be used to transmit a password + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential + + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the name of the desired identity provider. + AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" + + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the type of the desired identity provider. + AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" +) diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 53429b8c7..1ec214c01 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -801,7 +801,7 @@ func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpCl return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) } - var body idpdiscoveryv1alpha1.SupervisorOIDCDiscoveryResponse + var body idpdiscoveryv1alpha1.OIDCDiscoveryResponse err = discoveredProvider.Claims(&body) if err != nil { return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err) @@ -810,7 +810,7 @@ func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpCl return body.SupervisorDiscovery.PinnipedIDPsEndpoint, nil } -func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDPsEndpoint string, httpClient *http.Client) ([]idpdiscoveryv1alpha1.SupervisorPinnipedIDP, error) { +func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDPsEndpoint string, httpClient *http.Client) ([]idpdiscoveryv1alpha1.PinnipedIDP, 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) @@ -832,7 +832,7 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: could not read response body: %w", err) } - var body idpdiscoveryv1alpha1.SupervisorIDPDiscoveryResponse + var body idpdiscoveryv1alpha1.IDPDiscoveryResponse 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) @@ -841,7 +841,7 @@ func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDP return body.PinnipedIDPs, nil } -func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.SupervisorPinnipedIDP, specifiedIDPName, specifiedIDPType string) (string, idpdiscoveryv1alpha1.IDPType, []idpdiscoveryv1alpha1.IDPFlow, error) { +func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.PinnipedIDP, specifiedIDPName, specifiedIDPType string) (string, idpdiscoveryv1alpha1.IDPType, []idpdiscoveryv1alpha1.IDPFlow, error) { pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) var discoveredFlows []idpdiscoveryv1alpha1.IDPFlow switch { diff --git a/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index f67022737..d40dab826 100644 --- a/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.17/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -39,26 +39,26 @@ func (r IDPFlow) String() string { return string(r) } -// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// OIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. -type SupervisorOIDCDiscoveryResponse struct { - SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +type OIDCDiscoveryResponse struct { + SupervisorDiscovery OIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` } -// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. -type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { +// OIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type OIDCDiscoveryResponseIDPEndpoint struct { PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` } -// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. -type SupervisorIDPDiscoveryResponse struct { - PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type IDPDiscoveryResponse struct { + PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"` } -// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. -type SupervisorPinnipedIDP struct { +type PinnipedIDP struct { Name string `json:"name"` Type IDPType `json:"type"` Flows []IDPFlow `json:"flows,omitempty"` diff --git a/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go new file mode 100644 index 000000000..36d95d189 --- /dev/null +++ b/generated/1.17/apis/supervisor/oidc/types_supervisor_oidc.go @@ -0,0 +1,25 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +// Constants related to the Supervisor FederationDomain's authorization and token endpoints. +const ( + // AuthorizeUsernameHeaderName is the name of the HTTP header which can be used to transmit a username + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizeUsernameHeaderName = "Pinniped-Username" + + // AuthorizePasswordHeaderName is the name of the HTTP header which can be used to transmit a password + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential + + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the name of the desired identity provider. + AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" + + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the type of the desired identity provider. + AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" +) diff --git a/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index f67022737..d40dab826 100644 --- a/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.18/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -39,26 +39,26 @@ func (r IDPFlow) String() string { return string(r) } -// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// OIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. -type SupervisorOIDCDiscoveryResponse struct { - SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +type OIDCDiscoveryResponse struct { + SupervisorDiscovery OIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` } -// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. -type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { +// OIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type OIDCDiscoveryResponseIDPEndpoint struct { PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` } -// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. -type SupervisorIDPDiscoveryResponse struct { - PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type IDPDiscoveryResponse struct { + PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"` } -// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. -type SupervisorPinnipedIDP struct { +type PinnipedIDP struct { Name string `json:"name"` Type IDPType `json:"type"` Flows []IDPFlow `json:"flows,omitempty"` diff --git a/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go new file mode 100644 index 000000000..36d95d189 --- /dev/null +++ b/generated/1.18/apis/supervisor/oidc/types_supervisor_oidc.go @@ -0,0 +1,25 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +// Constants related to the Supervisor FederationDomain's authorization and token endpoints. +const ( + // AuthorizeUsernameHeaderName is the name of the HTTP header which can be used to transmit a username + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizeUsernameHeaderName = "Pinniped-Username" + + // AuthorizePasswordHeaderName is the name of the HTTP header which can be used to transmit a password + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential + + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the name of the desired identity provider. + AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" + + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the type of the desired identity provider. + AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" +) diff --git a/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index f67022737..d40dab826 100644 --- a/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.19/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -39,26 +39,26 @@ func (r IDPFlow) String() string { return string(r) } -// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// OIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. -type SupervisorOIDCDiscoveryResponse struct { - SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +type OIDCDiscoveryResponse struct { + SupervisorDiscovery OIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` } -// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. -type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { +// OIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type OIDCDiscoveryResponseIDPEndpoint struct { PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` } -// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. -type SupervisorIDPDiscoveryResponse struct { - PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type IDPDiscoveryResponse struct { + PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"` } -// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. -type SupervisorPinnipedIDP struct { +type PinnipedIDP struct { Name string `json:"name"` Type IDPType `json:"type"` Flows []IDPFlow `json:"flows,omitempty"` diff --git a/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go new file mode 100644 index 000000000..36d95d189 --- /dev/null +++ b/generated/1.19/apis/supervisor/oidc/types_supervisor_oidc.go @@ -0,0 +1,25 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +// Constants related to the Supervisor FederationDomain's authorization and token endpoints. +const ( + // AuthorizeUsernameHeaderName is the name of the HTTP header which can be used to transmit a username + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizeUsernameHeaderName = "Pinniped-Username" + + // AuthorizePasswordHeaderName is the name of the HTTP header which can be used to transmit a password + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential + + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the name of the desired identity provider. + AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" + + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the type of the desired identity provider. + AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" +) diff --git a/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index f67022737..d40dab826 100644 --- a/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/1.20/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -39,26 +39,26 @@ func (r IDPFlow) String() string { return string(r) } -// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// OIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. -type SupervisorOIDCDiscoveryResponse struct { - SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +type OIDCDiscoveryResponse struct { + SupervisorDiscovery OIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` } -// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. -type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { +// OIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type OIDCDiscoveryResponseIDPEndpoint struct { PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` } -// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. -type SupervisorIDPDiscoveryResponse struct { - PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type IDPDiscoveryResponse struct { + PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"` } -// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. -type SupervisorPinnipedIDP struct { +type PinnipedIDP struct { Name string `json:"name"` Type IDPType `json:"type"` Flows []IDPFlow `json:"flows,omitempty"` diff --git a/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go new file mode 100644 index 000000000..36d95d189 --- /dev/null +++ b/generated/1.20/apis/supervisor/oidc/types_supervisor_oidc.go @@ -0,0 +1,25 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +// Constants related to the Supervisor FederationDomain's authorization and token endpoints. +const ( + // AuthorizeUsernameHeaderName is the name of the HTTP header which can be used to transmit a username + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizeUsernameHeaderName = "Pinniped-Username" + + // AuthorizePasswordHeaderName is the name of the HTTP header which can be used to transmit a password + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential + + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the name of the desired identity provider. + AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" + + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the type of the desired identity provider. + AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" +) diff --git a/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go b/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go index f67022737..d40dab826 100644 --- a/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go +++ b/generated/latest/apis/supervisor/idpdiscovery/v1alpha1/types_supervisor_idp_discovery.go @@ -39,26 +39,26 @@ func (r IDPFlow) String() string { return string(r) } -// SupervisorOIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration +// OIDCDiscoveryResponse is part of the response from a FederationDomain's OpenID Provider Configuration // Document returned by the .well-known/openid-configuration endpoint. It ignores all the standard OpenID Provider // configuration metadata and only picks out the portion related to Supervisor identity provider discovery. -type SupervisorOIDCDiscoveryResponse struct { - SupervisorDiscovery SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` +type OIDCDiscoveryResponse struct { + SupervisorDiscovery OIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` } -// SupervisorOIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. -type SupervisorOIDCDiscoveryResponseIDPEndpoint struct { +// OIDCDiscoveryResponseIDPEndpoint contains the URL for the identity provider discovery endpoint. +type OIDCDiscoveryResponseIDPEndpoint struct { PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"` } -// SupervisorIDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. -type SupervisorIDPDiscoveryResponse struct { - PinnipedIDPs []SupervisorPinnipedIDP `json:"pinniped_identity_providers"` +// IDPDiscoveryResponse is the response of a FederationDomain's identity provider discovery endpoint. +type IDPDiscoveryResponse struct { + PinnipedIDPs []PinnipedIDP `json:"pinniped_identity_providers"` } -// SupervisorPinnipedIDP describes a single identity provider as included in the response of a FederationDomain's +// PinnipedIDP describes a single identity provider as included in the response of a FederationDomain's // identity provider discovery endpoint. -type SupervisorPinnipedIDP struct { +type PinnipedIDP struct { Name string `json:"name"` Type IDPType `json:"type"` Flows []IDPFlow `json:"flows,omitempty"` diff --git a/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go b/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go new file mode 100644 index 000000000..36d95d189 --- /dev/null +++ b/generated/latest/apis/supervisor/oidc/types_supervisor_oidc.go @@ -0,0 +1,25 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +// Constants related to the Supervisor FederationDomain's authorization and token endpoints. +const ( + // AuthorizeUsernameHeaderName is the name of the HTTP header which can be used to transmit a username + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizeUsernameHeaderName = "Pinniped-Username" + + // AuthorizePasswordHeaderName is the name of the HTTP header which can be used to transmit a password + // to the authorize endpoint when using a password flow, for example an OIDCIdentityProvider with a password grant + // or an LDAPIdentityProvider. + AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential + + // AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the name of the desired identity provider. + AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name" + + // AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which + // identity provider should be used for authentication by sending the type of the desired identity provider. + AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type" +) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 567ff1662..b6dd274ac 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -17,6 +17,7 @@ import ( "golang.org/x/oauth2" "k8s.io/apiserver/pkg/authentication/authenticator" + supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" @@ -28,11 +29,6 @@ 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, idpLister oidc.UpstreamIdentityProvidersLister, @@ -59,7 +55,7 @@ func NewHandler( } if oidcUpstream != nil { - if len(r.Header.Values(CustomUsernameHeaderName)) > 0 { + if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) } @@ -286,8 +282,8 @@ func makeDownstreamSessionAndReturnAuthcodeRedirect( } func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { - username := r.Header.Get(CustomUsernameHeaderName) - password := r.Header.Get(CustomPasswordHeaderName) + username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) + password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) 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.")) diff --git a/internal/oidc/discovery/discovery_handler.go b/internal/oidc/discovery/discovery_handler.go index 591a6d989..ca7fdd2b0 100644 --- a/internal/oidc/discovery/discovery_handler.go +++ b/internal/oidc/discovery/discovery_handler.go @@ -42,7 +42,7 @@ type Metadata struct { // vvv Custom vvv - SupervisorDiscovery v1alpha1.SupervisorOIDCDiscoveryResponseIDPEndpoint `json:"discovery.supervisor.pinniped.dev/v1alpha1"` + v1alpha1.OIDCDiscoveryResponse // ^^^ Custom ^^^ } @@ -50,11 +50,15 @@ type Metadata struct { // NewHandler returns an http.Handler that serves an OIDC discovery endpoint. func NewHandler(issuerURL string) http.Handler { oidcConfig := Metadata{ - Issuer: issuerURL, - AuthorizationEndpoint: issuerURL + oidc.AuthorizationEndpointPath, - TokenEndpoint: issuerURL + oidc.TokenEndpointPath, - JWKSURI: issuerURL + oidc.JWKSEndpointPath, - SupervisorDiscovery: v1alpha1.SupervisorOIDCDiscoveryResponseIDPEndpoint{PinnipedIDPsEndpoint: issuerURL + oidc.PinnipedIDPsPathV1Alpha1}, + Issuer: issuerURL, + AuthorizationEndpoint: issuerURL + oidc.AuthorizationEndpointPath, + TokenEndpoint: issuerURL + oidc.TokenEndpointPath, + JWKSURI: issuerURL + oidc.JWKSEndpointPath, + OIDCDiscoveryResponse: v1alpha1.OIDCDiscoveryResponse{ + SupervisorDiscovery: v1alpha1.OIDCDiscoveryResponseIDPEndpoint{ + PinnipedIDPsEndpoint: issuerURL + oidc.PinnipedIDPsPathV1Alpha1, + }, + }, ResponseTypesSupported: []string{"code"}, ResponseModesSupported: []string{"query", "form_post"}, SubjectTypesSupported: []string{"public"}, diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/oidc/idpdiscovery/idp_discovery_handler.go index 1ffeca2f2..a90f65587 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler.go @@ -37,13 +37,11 @@ func NewHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler } func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, error) { - r := v1alpha1.SupervisorIDPDiscoveryResponse{ - PinnipedIDPs: []v1alpha1.SupervisorPinnipedIDP{}, - } + r := v1alpha1.IDPDiscoveryResponse{PinnipedIDPs: []v1alpha1.PinnipedIDP{}} // The cache of IDPs could change at any time, so always recalculate the list. for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() { - r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.SupervisorPinnipedIDP{ + r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ Name: provider.GetName(), Type: v1alpha1.IDPTypeLDAP, Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword}, @@ -54,7 +52,7 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, if provider.AllowsPasswordGrant() { flows = append(flows, v1alpha1.IDPFlowCLIPassword) } - r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.SupervisorPinnipedIDP{ + r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ Name: provider.GetName(), Type: v1alpha1.IDPTypeOIDC, Flows: flows, diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index b0f738223..5dc1c3e1c 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -28,6 +28,7 @@ import ( "golang.org/x/term" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc/provider" @@ -52,11 +53,6 @@ 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: " @@ -389,8 +385,12 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { h.pkce.Method(), } if h.upstreamIdentityProviderName != "" { - authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam(supervisorAuthorizeUpstreamNameParam, h.upstreamIdentityProviderName)) - authorizeOptions = append(authorizeOptions, oauth2.SetAuthURLParam(supervisorAuthorizeUpstreamTypeParam, h.upstreamIdentityProviderType)) + authorizeOptions = append(authorizeOptions, + oauth2.SetAuthURLParam(supervisoroidc.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName), + ) + authorizeOptions = append(authorizeOptions, + oauth2.SetAuthURLParam(supervisoroidc.AuthorizeUpstreamIDPTypeParamName, h.upstreamIdentityProviderType), + ) } // Choose the appropriate authorization and authcode exchange strategy. @@ -445,8 +445,8 @@ func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) ( if err != nil { return nil, fmt.Errorf("could not build authorize request: %w", err) } - authReq.Header.Set(supervisorAuthorizeUpstreamUsernameHeader, username) - authReq.Header.Set(supervisorAuthorizeUpstreamPasswordHeader, password) + authReq.Header.Set(supervisoroidc.AuthorizeUsernameHeaderName, username) + authReq.Header.Set(supervisoroidc.AuthorizePasswordHeaderName, password) authRes, err := h.httpClient.Do(authReq) if err != nil { return nil, fmt.Errorf("authorization response error: %w", err) From 61c21d297707c7f89b6d88ab173087bf8d93d77e Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 18 Aug 2021 12:06:46 -0700 Subject: [PATCH 11/21] Refactor some authorize and callback error handling, and add more tests --- internal/oidc/auth/auth_handler.go | 54 +- internal/oidc/auth/auth_handler_test.go | 610 +++++++++++++++--- internal/oidc/callback/callback_handler.go | 2 +- .../oidc/callback/callback_handler_test.go | 6 +- .../downstreamsession/downstream_session.go | 21 +- 5 files changed, 548 insertions(+), 145 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index b6dd274ac..fb2897191 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -97,12 +97,8 @@ func handleAuthRequestForLDAPUpstream( 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 + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider.")) } subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) @@ -130,12 +126,9 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( if !oidcUpstream.AllowsPasswordGrant() { // Return a user-friendly error for this case which is entirely within our control. - err := errors.WithStack(fosite.ErrAccessDenied. - WithHint("Resource owner password credentials grant is not allowed for this upstream provider according to its configuration."), - ) - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHint( + "Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.")) } token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) @@ -147,16 +140,16 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( // However, the exact response is undefined in the sense that there is no such thing as a password grant in // the OIDC spec, so we don't try too hard to read the upstream errors in this case. (E.g. Dex departs from the // spec and returns something other than an "invalid_grant" error for bad resource owner credentials.) - // Return an error according to OIDC spec 3.1.2.6 (second paragraph). - err := errors.WithStack(fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client } subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) if err != nil { - return err + // Return a user-friendly error for this case which is entirely within our control. + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), + ) } return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups) @@ -189,9 +182,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( }, }) if err != nil { - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, err) } csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) @@ -258,6 +249,14 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( return nil } +func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error) error { + errWithStack := errors.WithStack(err) + plog.Info("authorize response error", oidc.FositeErrorForLog(errWithStack)...) + // Return an error according to OIDC spec 3.1.2.6 (second paragraph). + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil +} + func makeDownstreamSessionAndReturnAuthcodeRedirect( r *http.Request, w http.ResponseWriter, @@ -271,9 +270,7 @@ func makeDownstreamSessionAndReturnAuthcodeRedirect( 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 + return writeAuthorizeError(w, oauthHelper, authorizeRequester, err) } oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) @@ -285,10 +282,8 @@ func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseW username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) 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) + _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Missing or blank username or password.")) return "", "", false } return username, password, true @@ -297,8 +292,7 @@ func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseW 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) + _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err) return nil, false } diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index ce615297a..bb02bd9f0 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -151,6 +151,36 @@ func TestAuthorizationEndpoint(t *testing.T) { "error_description": "The resource owner or authorization server denied the request. Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.", "state": happyState, } + + fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: email_verified claim in upstream ID token has invalid format.", + "state": happyState, + } + + fositeAccessDeniedWithFalseEmailVerifiedHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: email_verified claim in upstream ID token has false value.", + "state": happyState, + } + + fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token missing.", + "state": happyState, + } + + fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token is empty.", + "state": happyState, + } + + fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token has invalid format.", + "state": happyState, + } ) hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } @@ -201,6 +231,14 @@ func TestAuthorizationEndpoint(t *testing.T) { WithUpstreamAuthcodeExchangeError(errors.New("should not have tried to exchange upstream authcode on this instance")) } + happyUpstreamPasswordGrantMockExpectation := &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }, + } + happyLDAPUsername := "some-ldap-user" happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" happyLDAPPassword := "some-ldap-password" //nolint:gosec @@ -428,18 +466,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyStringWithLocationInHref: true, }, { - name: "OIDC upstream password grant happy path using GET", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: happyGetRequestPath, - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "OIDC upstream password grant happy path using GET", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, @@ -510,20 +543,15 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, }, { - name: "OIDC upstream password grant happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodPost, - path: "/some/path", - contentType: "application/x-www-form-urlencoded", - body: encodeQuery(happyGetRequestQueryMap), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "OIDC upstream password grant happy path using POST", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodPost, + path: "/some/path", + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, @@ -625,14 +653,9 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, @@ -1032,18 +1055,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing PKCE code_challenge in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "missing PKCE code_challenge in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), @@ -1079,18 +1097,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "invalid value for PKCE code_challenge_method in request using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "invalid value for PKCE code_challenge_method in request using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), @@ -1126,18 +1139,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), @@ -1173,18 +1181,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing PKCE code_challenge_method in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "missing PKCE code_challenge_method in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), @@ -1224,18 +1227,13 @@ func TestAuthorizationEndpoint(t *testing.T) { { // 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 OIDC upstream password grant. - name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream password grant", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), @@ -1282,15 +1280,10 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().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(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted @@ -1325,6 +1318,417 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, }, + { + name: "OIDC upstream password grant: upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutUsernameClaim().WithoutGroupsClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenGroups: []string{}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "joe@whitehouse.gov", + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", true).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "joe@whitehouse.gov", + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("some-claim"). + WithIDTokenClaim("some-claim", "joe"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", false).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "joe", + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", "supposed to be boolean").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with false value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", false).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithFalseEmailVerifiedHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithUsernameClaim("sub").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamSubject, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token has a non-array value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithIDTokenClaim(oidcUpstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: []string{"notAnArrayGroup1 notAnArrayGroup2"}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token is a slice of interfaces", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: []string{"group1", "group2"}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain requested username claim", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim(oidcUpstreamUsernameClaim).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain requested groups claim", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim(oidcUpstreamGroupsClaim).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: []string{}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream ID token contains username claim with weird format", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamUsernameClaim, 42).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains username claim with empty string value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamUsernameClaim, "").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim("iss").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does has an empty string value for iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token has an non-string iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim("sub").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does has an empty string value for sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("sub", "").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token has an non-string sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("sub", 42).WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains groups claim with weird format", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamGroupsClaim, 42).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains groups claim where one element is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"foo", 7}).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains groups claim with invalid null type", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamGroupsClaim, nil).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, { name: "downstream state does not have enough entropy using OIDC upstream browser flow", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index 73e37b75e..b168e4b9a 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -70,7 +70,7 @@ func NewHandler( subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) if err != nil { - return err + return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 7252da032..9cc5779e6 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -803,7 +803,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -819,7 +819,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -835,7 +835,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index c09ea8a7c..64838d5e0 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -6,7 +6,6 @@ package downstreamsession import ( "fmt" - "net/http" "net/url" "time" @@ -15,7 +14,7 @@ import ( "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" - "go.pinniped.dev/internal/httputil/httperr" + "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" @@ -27,6 +26,12 @@ const ( // The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims emailVerifiedClaimName = "email_verified" + + requiredClaimMissingErr = constable.Error("required claim in upstream ID token missing") + requiredClaimInvalidFormatErr = constable.Error("required claim in upstream ID token has invalid format") + requiredClaimEmptyErr = constable.Error("required claim in upstream ID token is empty") + emailVerifiedClaimInvalidFormatErr = constable.Error("email_verified claim in upstream ID token has invalid format") + emailVerifiedClaimFalseErr = constable.Error("email_verified claim in upstream ID token has false value") ) // MakeDownstreamSession creates a downstream OIDC session. @@ -107,7 +112,7 @@ func getSubjectAndUsernameFromUpstreamIDToken( "configuredUsernameClaim", usernameClaimName, "emailVerifiedClaim", emailVerifiedAsInterface, ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format") + return "", "", emailVerifiedClaimInvalidFormatErr } if !emailVerified { plog.Warning( @@ -115,7 +120,7 @@ func getSubjectAndUsernameFromUpstreamIDToken( "upstreamName", upstreamIDPConfig.GetName(), "configuredUsernameClaim", usernameClaimName, ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value") + return "", "", emailVerifiedClaimFalseErr } } @@ -135,7 +140,7 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl "upstreamName", upstreamIDPName, "claimName", claimName, ) - return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token missing") + return "", requiredClaimMissingErr } valueAsString, ok := value.(string) @@ -145,7 +150,7 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl "upstreamName", upstreamIDPName, "claimName", claimName, ) - return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token has invalid format") + return "", requiredClaimInvalidFormatErr } if valueAsString == "" { @@ -154,7 +159,7 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl "upstreamName", upstreamIDPName, "claimName", claimName, ) - return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token is empty") + return "", requiredClaimEmptyErr } return valueAsString, nil @@ -190,7 +195,7 @@ func getGroupsFromUpstreamIDToken( "upstreamName", upstreamIDPConfig.GetName(), "configuredGroupsClaim", groupsClaimName, ) - return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format") + return nil, requiredClaimInvalidFormatErr } return groupsAsArray, nil From 02b8ed7e0b6aa5bd9f797788295a109f70e49138 Mon Sep 17 00:00:00 2001 From: anjalitelang <49958114+anjaltelang@users.noreply.github.com> Date: Thu, 19 Aug 2021 12:19:31 -0400 Subject: [PATCH 12/21] Update ROADMAP.md Removing features listed for July as they are shipped. --- ROADMAP.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4855c4984..5de5f20a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,8 +36,6 @@ The following table includes the current roadmap for Pinniped. If you have any q Last Updated: July 2021 Theme|Description|Timeline| |--|--|--| -|Remote OIDC login support|Add support for logging in from remote hosts without web browsers in the Pinniped CLI and Supervisor|Jul 2021| -|Non-Interactive Password based LDAP logins |Support for non-interactive LDAP Logins via CLI using Environmental Variables |Jul 2021| |Non-Interactive Password based OIDC logins |Support for non-interactive OIDC Logins via CLI using Password Grant |Aug 2021| |Active Directory Support|Extends upstream IDP protocols|Aug 2021| |Multiple IDP support|Support multiple IDPs configured on a single Supervisor|Sept 2021| From 42d31a70854a4d05bb98c19f42b0f9b1ca1535d4 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 19 Aug 2021 09:59:47 -0700 Subject: [PATCH 13/21] Update login.md doc to mention OIDC CLI-based flow --- site/content/docs/howto/login.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/site/content/docs/howto/login.md b/site/content/docs/howto/login.md index 6420023d0..edc3329a4 100644 --- a/site/content/docs/howto/login.md +++ b/site/content/docs/howto/login.md @@ -93,15 +93,18 @@ to authenticate the user to the cluster. If the Pinniped Supervisor is used for authentication to that cluster, then the user's authentication experience will depend on which type of identity provider was configured. -- For an OIDC identity provider, `kubectl` will open the user's web browser and direct it to the login page of +- For an OIDC identity provider, there are two supported client flows. + + When using the default browser-based flow, `kubectl` will open the user's web browser and direct it to the login page of their OIDC Provider. This login flow is controlled by the provider, so it may include two-factor authentication or - other features provided by the OIDC Provider. - - If the user's browser is not available, then `kubectl` will instead print a URL which can be visited in a - browser (potentially on a different computer) to complete the authentication. + other features provided by the OIDC Provider. If the user's browser is not available, then `kubectl` will instead + print a URL which can be visited in a browser (potentially on a different computer) to complete the authentication. + + When using the optional CLI-based flow, `kubectl` will interactively prompt the user for their username and password at the CLI. + Alternatively, the user can set the environment variables `PINNIPED_USERNAME` and `PINNIPED_PASSWORD` for the + `kubectl` process to avoid the interactive prompts. - For an LDAP identity provider, `kubectl` will interactively prompt the user for their username and password at the CLI. - Alternatively, the user can set the environment variables `PINNIPED_USERNAME` and `PINNIPED_PASSWORD` for the `kubectl` process to avoid the interactive prompts. From b4a39ba3c4a3e7eefe867d34b1d4fb2c46d0b079 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 19 Aug 2021 10:20:24 -0700 Subject: [PATCH 14/21] Remove `unparam` linter We decided that this linter does not provide very useful feedback for our project. --- .golangci.yaml | 1 - internal/groupsuffix/groupsuffix_test.go | 1 - pkg/oidcclient/login_test.go | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 543431cd7..2ffca3819 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -47,7 +47,6 @@ linters: - scopelint - sqlclosecheck - unconvert - - unparam - whitespace issues: diff --git a/internal/groupsuffix/groupsuffix_test.go b/internal/groupsuffix/groupsuffix_test.go index 0e4041d98..d51119d4c 100644 --- a/internal/groupsuffix/groupsuffix_test.go +++ b/internal/groupsuffix/groupsuffix_test.go @@ -644,7 +644,6 @@ func authenticatorAPIGroup(apiGroup string) withFunc { } } -//nolint:unparam // the apiGroupSuffix parameter might always be the same, but this is nice for test readability func replaceGV(t *testing.T, baseGV schema.GroupVersion, apiGroupSuffix string) schema.GroupVersion { t.Helper() groupName, ok := Replace(baseGV.Group, apiGroupSuffix) diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index 94dc24c48..c63173df9 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -238,7 +238,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo formPostProviderMux.HandleFunc("/.well-known/openid-configuration", discoveryHandler(formPostSuccessServer, []string{"query", "form_post"})) formPostProviderMux.HandleFunc("/token", tokenHandler) - defaultDiscoveryResponse := func(req *http.Request) (*http.Response, error) { // nolint:unparam + defaultDiscoveryResponse := func(req *http.Request) (*http.Response, error) { // Call the handler function from the test server to calculate the response. handler, _ := providerMux.Handler(req) recorder := httptest.NewRecorder() @@ -246,7 +246,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo return recorder.Result(), nil } - defaultLDAPTestOpts := func(t *testing.T, h *handlerState, authResponse *http.Response, authError error) error { // nolint:unparam + defaultLDAPTestOpts := func(t *testing.T, h *handlerState, authResponse *http.Response, authError error) 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 } From 6239a567a8ee6a3b088f372473f65263f4810d7c Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 19 Aug 2021 10:57:00 -0700 Subject: [PATCH 15/21] remove one nolint:unparam comment --- internal/upstreamoidc/upstreamoidc_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/upstreamoidc/upstreamoidc_test.go b/internal/upstreamoidc/upstreamoidc_test.go index abba4417b..801207bfa 100644 --- a/internal/upstreamoidc/upstreamoidc_test.go +++ b/internal/upstreamoidc/upstreamoidc_test.go @@ -579,7 +579,7 @@ func (m *mockProvider) UserInfo(_ context.Context, tokenSource oauth2.TokenSourc return m.userInfo, m.userInfoErr } -func forceUserInfoWithClaims(subject string, claims string) *oidc.UserInfo { //nolint:unparam +func forceUserInfoWithClaims(subject string, claims string) *oidc.UserInfo { userInfo := &oidc.UserInfo{Subject: subject} // this is some dark magic to set a private field From 4f5312807b90fd7ff044acb7ac0ddbbc91473863 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 17 Aug 2021 08:53:22 -0500 Subject: [PATCH 16/21] Undo dep hacks to work around gRPC example module. This is essentially reverting 87c7e89b139a8024d8b00bfbd18e500cc25937e4. Signed-off-by: Matt Moyer --- go.mod | 4 ---- go.sum | 1 + hack/dependencyhacks/grpcexamples/go.mod | 3 --- hack/module.sh | 2 +- 4 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 hack/dependencyhacks/grpcexamples/go.mod diff --git a/go.mod b/go.mod index b5f20e9d4..3fc7d215a 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,3 @@ replace github.com/oleiade/reflections v1.0.0 => github.com/oleiade/reflections // We use the SHA of github.com/form3tech-oss/jwt-go@v3.2.2 to get around "used for two different module paths" // https://golang.org/issues/26904 replace github.com/dgrijalva/jwt-go v3.2.0+incompatible => github.com/form3tech-oss/jwt-go v0.0.0-20200915135329-9162a5abdbc0 - -// Pin a gRPC module that's only used in some tests. -// This is required because sometime after v1.29.1, they moved this package into a separate module. -replace google.golang.org/grpc/examples => ./hack/dependencyhacks/grpcexamples/ diff --git a/go.sum b/go.sum index fe9d15bcf..be450e2df 100644 --- a/go.sum +++ b/go.sum @@ -1793,6 +1793,7 @@ google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc/examples v0.0.0-20210304020650-930c79186c99/go.mod h1:Ly7ZA/ARzg8fnPU9TyZIxoz33sEUuWX7txiqs8lPTgE= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/hack/dependencyhacks/grpcexamples/go.mod b/hack/dependencyhacks/grpcexamples/go.mod deleted file mode 100644 index 47491cc52..000000000 --- a/hack/dependencyhacks/grpcexamples/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module google.golang.org/grpc/examples - -go 1.14 diff --git a/hack/module.sh b/hack/module.sh index f7c2d0d22..12f53d6b4 100755 --- a/hack/module.sh +++ b/hack/module.sh @@ -46,7 +46,7 @@ function with_modules() { env_vars="KUBE_CACHE_MUTATION_DETECTOR=${kube_cache_mutation_detector} KUBE_PANIC_WATCH_DECODE_ERROR=${kube_panic_watch_decode_error}" pushd "${ROOT}" >/dev/null - for mod_file in $(find . -maxdepth 4 -not -path "./generated/*" -not -path "./hack/*" -name go.mod | sort); do + for mod_file in $(find . -maxdepth 4 -not -path "./generated/*" -name go.mod | sort); do mod_dir="$(dirname "${mod_file}")" ( echo "=> " From f379eee7a3ee67802f5159d49b4e5cfda6684ce3 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 17 Aug 2021 08:57:47 -0500 Subject: [PATCH 17/21] Drop replace directive for oleiade/reflections. This is reverting 8358c261070a2dc9d75053e5db9c4ec666472295. Signed-off-by: Matt Moyer --- go.mod | 5 ----- go.sum | 1 + 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 3fc7d215a..4f34f0918 100644 --- a/go.mod +++ b/go.mod @@ -43,11 +43,6 @@ require ( sigs.k8s.io/yaml v1.2.0 ) -// Workaround a broken module version (see https://github.com/oleiade/reflections/issues/14). -// We need this until none of our deps tries to pull in v1.0.0, otherwise some tools like -// Dependabot will fail on our module. -replace github.com/oleiade/reflections v1.0.0 => github.com/oleiade/reflections v1.0.1 - // We were never vulnerable to CVE-2020-26160 but this avoids future issues // This fork is not particularly better though: // https://github.com/form3tech-oss/jwt-go/issues/7 diff --git a/go.sum b/go.sum index be450e2df..caac6eaaf 100644 --- a/go.sum +++ b/go.sum @@ -930,6 +930,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oleiade/reflections v1.0.0/go.mod h1:RbATFBbKYkVdqmSFtx13Bb/tVhR0lgOBXunWTZKeL4w= github.com/oleiade/reflections v1.0.1 h1:D1XO3LVEYroYskEsoSiGItp9RUxG6jWnCVvrqH0HHQM= github.com/oleiade/reflections v1.0.1/go.mod h1:rdFxbxq4QXVZWj0F+e9jqjDkc7dbp97vkRixKo2JR60= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= From 03a8160a9108499c5695f81a80f9e88cb8dbea9a Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Wed, 18 Aug 2021 17:03:47 -0500 Subject: [PATCH 18/21] Remove replace directive for dgrijalva/jwt-go. We no longer have a transitive dependency on this older repository, so we don't need the replace directive anymore. There is a new fork of this that we should move to (https://github.com/golang-jwt/jwt), but we can't easily do that until a couple of our direct dependencies upgrade. This is a revert of d162cb9adfa913481a2374145451938122009b8d. Signed-off-by: Matt Moyer --- go.mod | 7 ------- go.sum | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 4f34f0918..1c248a777 100644 --- a/go.mod +++ b/go.mod @@ -42,10 +42,3 @@ require ( k8s.io/utils v0.0.0-20210707171843-4b05e18ac7d9 sigs.k8s.io/yaml v1.2.0 ) - -// We were never vulnerable to CVE-2020-26160 but this avoids future issues -// This fork is not particularly better though: -// https://github.com/form3tech-oss/jwt-go/issues/7 -// We use the SHA of github.com/form3tech-oss/jwt-go@v3.2.2 to get around "used for two different module paths" -// https://golang.org/issues/26904 -replace github.com/dgrijalva/jwt-go v3.2.0+incompatible => github.com/form3tech-oss/jwt-go v0.0.0-20200915135329-9162a5abdbc0 diff --git a/go.sum b/go.sum index caac6eaaf..4969d193c 100644 --- a/go.sum +++ b/go.sum @@ -182,6 +182,7 @@ github.com/dgraph-io/ristretto v0.0.1/go.mod h1:T40EBc7CJke8TkpiYfGGKAeFjSaxuFXh github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= github.com/dgraph-io/ristretto v0.0.3 h1:jh22xisGBjrEVnRZ1DVTpBVQm0Xndu8sMl0CWDzSIBI= github.com/dgraph-io/ristretto v0.0.3/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= @@ -224,7 +225,6 @@ github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8S github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/form3tech-oss/jwt-go v0.0.0-20200915135329-9162a5abdbc0/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/form3tech-oss/jwt-go v3.2.3+incompatible h1:7ZaBxOI7TMoYBfyA3cQHErNNyAWIKUMIwqxEtgHOs5c= github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= From c356710f1fae6c708995d72f91e70290443f3425 Mon Sep 17 00:00:00 2001 From: Monis Khan Date: Wed, 18 Aug 2021 00:14:38 -0400 Subject: [PATCH 19/21] Add leader election middleware Signed-off-by: Monis Khan --- deploy/concierge/rbac.yaml | 3 + deploy/supervisor/rbac.yaml | 3 + go.mod | 1 + .../concierge/impersonator/impersonator.go | 2 +- .../controller/apicerts/certs_expirer_test.go | 7 +- .../impersonator_config_test.go | 10 +- .../kubecertagent/legacypodcleaner.go | 19 +- .../kubecertagent/legacypodcleaner_test.go | 60 +++- .../supervisorstorage/garbage_collector.go | 7 +- .../garbage_collector_test.go | 26 +- .../controllermanager/prepare_controllers.go | 9 +- internal/kubeclient/roundtrip.go | 4 +- internal/leaderelection/leaderelection.go | 150 ++++++++++ internal/supervisor/server/server.go | 20 +- internal/testutil/delete.go | 24 ++ .../concierge_impersonation_proxy_test.go | 35 +-- test/integration/leaderelection_test.go | 278 ++++++++++++++++++ test/testlib/client.go | 40 ++- 18 files changed, 627 insertions(+), 71 deletions(-) create mode 100644 internal/leaderelection/leaderelection.go create mode 100644 test/integration/leaderelection_test.go diff --git a/deploy/concierge/rbac.yaml b/deploy/concierge/rbac.yaml index 82b1c3ed7..f6b14dda2 100644 --- a/deploy/concierge/rbac.yaml +++ b/deploy/concierge/rbac.yaml @@ -153,6 +153,9 @@ rules: - apiGroups: [ "" ] resources: [ configmaps ] verbs: [ list, get, watch ] + - apiGroups: [ coordination.k8s.io ] + resources: [ leases ] + verbs: [ create, get, update ] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/deploy/supervisor/rbac.yaml b/deploy/supervisor/rbac.yaml index 60447f7c5..14a8499ab 100644 --- a/deploy/supervisor/rbac.yaml +++ b/deploy/supervisor/rbac.yaml @@ -48,6 +48,9 @@ rules: - apiGroups: [apps] resources: [replicasets,deployments] verbs: [get] + - apiGroups: [ coordination.k8s.io ] + resources: [ leases ] + verbs: [ create, get, update ] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/go.mod b/go.mod index 1c248a777..7f0baec94 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 github.com/tdewolff/minify/v2 v2.9.21 + go.uber.org/atomic v1.7.0 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 diff --git a/internal/concierge/impersonator/impersonator.go b/internal/concierge/impersonator/impersonator.go index 067fcfac8..c482511eb 100644 --- a/internal/concierge/impersonator/impersonator.go +++ b/internal/concierge/impersonator/impersonator.go @@ -102,7 +102,7 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. // Wire up the impersonation proxy signer CA as another valid authenticator for client cert auth, // along with the Kube API server's CA. - // Note: any changes to the the Authentication stack need to be kept in sync with any assumptions made + // Note: any changes to the Authentication stack need to be kept in sync with any assumptions made // by getTransportForUser, especially if we ever update the TCR API to start returning bearer tokens. kubeClientUnsafeForProxying, err := kubeclient.New(clientOpts...) if err != nil { diff --git a/internal/controller/apicerts/certs_expirer_test.go b/internal/controller/apicerts/certs_expirer_test.go index 010404230..4819e59d5 100644 --- a/internal/controller/apicerts/certs_expirer_test.go +++ b/internal/controller/apicerts/certs_expirer_test.go @@ -297,12 +297,7 @@ func TestExpirerControllerSync(t *testing.T) { if test.wantDelete { require.Len(t, *opts, 1) - require.Equal(t, metav1.DeleteOptions{ - Preconditions: &metav1.Preconditions{ - UID: &testUID, - ResourceVersion: &testRV, - }, - }, (*opts)[0]) + require.Equal(t, testutil.NewPreconditions(testUID, testRV), (*opts)[0]) } else { require.Len(t, *opts, 0) } diff --git a/internal/controller/impersonatorconfig/impersonator_config_test.go b/internal/controller/impersonatorconfig/impersonator_config_test.go index af29711b5..00aeadd1f 100644 --- a/internal/controller/impersonatorconfig/impersonator_config_test.go +++ b/internal/controller/impersonatorconfig/impersonator_config_test.go @@ -29,14 +29,12 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/clock" "k8s.io/apimachinery/pkg/util/intstr" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" kubernetesfake "k8s.io/client-go/kubernetes/fake" coretesting "k8s.io/client-go/testing" - "k8s.io/utils/pointer" "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake" @@ -1032,13 +1030,7 @@ func TestImpersonatorConfigControllerSync(t *testing.T) { // validate that we set delete preconditions correctly r.NotEmpty(*deleteOptions) for _, opt := range *deleteOptions { - uid := types.UID("uid-1234") - r.Equal(metav1.DeleteOptions{ - Preconditions: &metav1.Preconditions{ - UID: &uid, - ResourceVersion: pointer.String("rv-5678"), - }, - }, opt) + r.Equal(testutil.NewPreconditions("uid-1234", "rv-5678"), opt) } } diff --git a/internal/controller/kubecertagent/legacypodcleaner.go b/internal/controller/kubecertagent/legacypodcleaner.go index 1a44477ef..43e4a9f98 100644 --- a/internal/controller/kubecertagent/legacypodcleaner.go +++ b/internal/controller/kubecertagent/legacypodcleaner.go @@ -40,12 +40,29 @@ func NewLegacyPodCleanerController( controllerlib.Config{ Name: "legacy-pod-cleaner-controller", Syncer: controllerlib.SyncFunc(func(ctx controllerlib.Context) error { - if err := client.Kubernetes.CoreV1().Pods(ctx.Key.Namespace).Delete(ctx.Context, ctx.Key.Name, metav1.DeleteOptions{}); err != nil { + podClient := client.Kubernetes.CoreV1().Pods(ctx.Key.Namespace) + + // avoid blind writes to the API + agentPod, err := podClient.Get(ctx.Context, ctx.Key.Name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("could not get legacy agent pod: %w", err) + } + + if err := podClient.Delete(ctx.Context, ctx.Key.Name, metav1.DeleteOptions{ + Preconditions: &metav1.Preconditions{ + UID: &agentPod.UID, + ResourceVersion: &agentPod.ResourceVersion, + }, + }); err != nil { if k8serrors.IsNotFound(err) { return nil } return fmt.Errorf("could not delete legacy agent pod: %w", err) } + log.Info("deleted legacy kube-cert-agent pod", "pod", klog.KRef(ctx.Key.Namespace, ctx.Key.Name)) return nil }), diff --git a/internal/controller/kubecertagent/legacypodcleaner_test.go b/internal/controller/kubecertagent/legacypodcleaner_test.go index b2b1a5e74..7cf89b0bb 100644 --- a/internal/controller/kubecertagent/legacypodcleaner_test.go +++ b/internal/controller/kubecertagent/legacypodcleaner_test.go @@ -20,6 +20,7 @@ import ( "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/testlogger" ) @@ -28,9 +29,11 @@ func TestLegacyPodCleanerController(t *testing.T) { legacyAgentPodWithoutExtraLabel := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "concierge", - Name: "pinniped-concierge-kube-cert-agent-without-extra-label", - Labels: map[string]string{"kube-cert-agent.pinniped.dev": "true"}, + Namespace: "concierge", + Name: "pinniped-concierge-kube-cert-agent-without-extra-label", + Labels: map[string]string{"kube-cert-agent.pinniped.dev": "true"}, + UID: "1", + ResourceVersion: "2", }, Spec: corev1.PodSpec{}, Status: corev1.PodStatus{Phase: corev1.PodRunning}, @@ -40,10 +43,14 @@ func TestLegacyPodCleanerController(t *testing.T) { legacyAgentPodWithExtraLabel.Name = "pinniped-concierge-kube-cert-agent-with-extra-label" legacyAgentPodWithExtraLabel.Labels["extralabel"] = "labelvalue" legacyAgentPodWithExtraLabel.Labels["anotherextralabel"] = "labelvalue" + legacyAgentPodWithExtraLabel.UID = "3" + legacyAgentPodWithExtraLabel.ResourceVersion = "4" nonLegacyAgentPod := legacyAgentPodWithExtraLabel.DeepCopy() nonLegacyAgentPod.Name = "pinniped-concierge-kube-cert-agent-not-legacy" nonLegacyAgentPod.Labels["kube-cert-agent.pinniped.dev"] = "v2" + nonLegacyAgentPod.UID = "5" + nonLegacyAgentPod.ResourceVersion = "6" tests := []struct { name string @@ -52,10 +59,12 @@ func TestLegacyPodCleanerController(t *testing.T) { wantDistinctErrors []string wantDistinctLogs []string wantActions []coretesting.Action + wantDeleteOptions []metav1.DeleteOptions }{ { - name: "no pods", - wantActions: []coretesting.Action{}, + name: "no pods", + wantActions: []coretesting.Action{}, + wantDeleteOptions: []metav1.DeleteOptions{}, }, { name: "mix of pods", @@ -69,8 +78,12 @@ func TestLegacyPodCleanerController(t *testing.T) { `legacy-pod-cleaner-controller "level"=0 "msg"="deleted legacy kube-cert-agent pod" "pod"={"name":"pinniped-concierge-kube-cert-agent-with-extra-label","namespace":"concierge"}`, }, wantActions: []coretesting.Action{ // the first delete triggers the informer again, but the second invocation triggers a Not Found + coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), - coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), + coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), + }, + wantDeleteOptions: []metav1.DeleteOptions{ + testutil.NewPreconditions("3", "4"), }, }, { @@ -89,9 +102,15 @@ func TestLegacyPodCleanerController(t *testing.T) { "could not delete legacy agent pod: some delete error", }, wantActions: []coretesting.Action{ + coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), + coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), }, + wantDeleteOptions: []metav1.DeleteOptions{ + testutil.NewPreconditions("3", "4"), + testutil.NewPreconditions("3", "4"), + }, }, { name: "fail to delete because of not found error", @@ -107,8 +126,30 @@ func TestLegacyPodCleanerController(t *testing.T) { }, wantDistinctErrors: []string{""}, wantActions: []coretesting.Action{ + coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), coretesting.NewDeleteAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), }, + wantDeleteOptions: []metav1.DeleteOptions{ + testutil.NewPreconditions("3", "4"), + }, + }, + { + name: "fail to delete because of not found error on get", + kubeObjects: []runtime.Object{ + legacyAgentPodWithoutExtraLabel, // should not be delete (missing extra label) + legacyAgentPodWithExtraLabel, // should be deleted + nonLegacyAgentPod, // should not be deleted (missing legacy agent label) + }, + addKubeReactions: func(clientset *kubefake.Clientset) { + clientset.PrependReactor("get", "*", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, k8serrors.NewNotFound(action.GetResource().GroupResource(), "") + }) + }, + wantDistinctErrors: []string{""}, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(corev1.Resource("pods").WithVersion("v1"), "concierge", legacyAgentPodWithExtraLabel.Name), + }, + wantDeleteOptions: []metav1.DeleteOptions{}, }, } for _, tt := range tests { @@ -120,6 +161,10 @@ func TestLegacyPodCleanerController(t *testing.T) { if tt.addKubeReactions != nil { tt.addKubeReactions(kubeClientset) } + + opts := &[]metav1.DeleteOptions{} + trackDeleteClient := testutil.NewDeleteOptionsRecorder(kubeClientset, opts) + kubeInformers := informers.NewSharedInformerFactory(kubeClientset, 0) log := testlogger.New(t) controller := NewLegacyPodCleanerController( @@ -127,7 +172,7 @@ func TestLegacyPodCleanerController(t *testing.T) { Namespace: "concierge", Labels: map[string]string{"extralabel": "labelvalue"}, }, - &kubeclient.Client{Kubernetes: kubeClientset}, + &kubeclient.Client{Kubernetes: trackDeleteClient}, kubeInformers.Core().V1().Pods(), log, controllerlib.WithMaxRetries(1), @@ -140,6 +185,7 @@ func TestLegacyPodCleanerController(t *testing.T) { assert.Equal(t, tt.wantDistinctErrors, deduplicate(errorMessages), "unexpected errors") assert.Equal(t, tt.wantDistinctLogs, deduplicate(log.Lines()), "unexpected logs") assert.Equal(t, tt.wantActions, kubeClientset.Actions()[2:], "unexpected actions") + assert.Equal(t, tt.wantDeleteOptions, *opts, "unexpected delete options") }) } } diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index a6d7ebce1..beb5317f6 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -102,7 +102,12 @@ func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { } if garbageCollectAfterTime.Before(frozenClock.Now()) { - err = c.kubeClient.CoreV1().Secrets(secret.Namespace).Delete(ctx.Context, secret.Name, metav1.DeleteOptions{}) + err = c.kubeClient.CoreV1().Secrets(secret.Namespace).Delete(ctx.Context, secret.Name, metav1.DeleteOptions{ + Preconditions: &metav1.Preconditions{ + UID: &secret.UID, + ResourceVersion: &secret.ResourceVersion, + }, + }) if err != nil { plog.WarningErr("failed to garbage collect resource", err, logKV(secret)) continue diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index ac64acf32..889de157c 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -18,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/clock" kubeinformers "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" kubernetesfake "k8s.io/client-go/kubernetes/fake" kubetesting "k8s.io/client-go/testing" @@ -116,6 +117,8 @@ func TestGarbageCollectorControllerSync(t *testing.T) { subject controllerlib.Controller kubeInformerClient *kubernetesfake.Clientset kubeClient *kubernetesfake.Clientset + deleteOptions *[]metav1.DeleteOptions + deleteOptionsRecorder kubernetes.Interface kubeInformers kubeinformers.SharedInformerFactory cancelContext context.Context cancelContextCancelFunc context.CancelFunc @@ -130,7 +133,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { // Set this at the last second to allow for injection of server override. subject = GarbageCollectorController( fakeClock, - kubeClient, + deleteOptionsRecorder, kubeInformers.Core().V1().Secrets(), controllerlib.WithInformer, ) @@ -158,6 +161,8 @@ func TestGarbageCollectorControllerSync(t *testing.T) { kubeInformerClient = kubernetesfake.NewSimpleClientset() kubeClient = kubernetesfake.NewSimpleClientset() + deleteOptions = &[]metav1.DeleteOptions{} + deleteOptionsRecorder = testutil.NewDeleteOptionsRecorder(kubeClient, deleteOptions) kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0) frozenNow = time.Now().UTC() fakeClock = clock.NewFakeClock(frozenNow) @@ -193,8 +198,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) { it.Before(func() { firstExpiredSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: "first expired secret", - Namespace: installedInNamespace, + Name: "first expired secret", + Namespace: installedInNamespace, + UID: "uid-123", + ResourceVersion: "rv-456", Annotations: map[string]string{ "storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-time.Second).Format(time.RFC3339), }, @@ -204,8 +211,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(firstExpiredSecret)) secondExpiredSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: "second expired secret", - Namespace: installedInNamespace, + Name: "second expired secret", + Namespace: installedInNamespace, + UID: "uid-789", + ResourceVersion: "rv-555", Annotations: map[string]string{ "storage.pinniped.dev/garbage-collect-after": frozenNow.Add(-2 * time.Second).Format(time.RFC3339), }, @@ -237,6 +246,13 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + r.ElementsMatch( + []metav1.DeleteOptions{ + testutil.NewPreconditions("uid-123", "rv-456"), + testutil.NewPreconditions("uid-789", "rv-555"), + }, + *deleteOptions, + ) list, err := kubeClient.CoreV1().Secrets(installedInNamespace).List(context.Background(), metav1.ListOptions{}) r.NoError(err) r.Len(list.Items, 2) diff --git a/internal/controllermanager/prepare_controllers.go b/internal/controllermanager/prepare_controllers.go index e6b69ec10..8c9e98082 100644 --- a/internal/controllermanager/prepare_controllers.go +++ b/internal/controllermanager/prepare_controllers.go @@ -33,6 +33,7 @@ import ( "go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/internal/leaderelection" ) const ( @@ -97,7 +98,7 @@ type Config struct { func PrepareControllers(c *Config) (func(ctx context.Context), error) { loginConciergeGroupData, identityConciergeGroupData := groupsuffix.ConciergeAggregatedGroups(c.APIGroupSuffix) - dref, _, err := deploymentref.New(c.ServerInstallationInfo) + dref, deployment, err := deploymentref.New(c.ServerInstallationInfo) if err != nil { return nil, fmt.Errorf("cannot create deployment ref: %w", err) } @@ -107,7 +108,9 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) { return nil, fmt.Errorf("cannot create API service ref: %w", err) } - client, err := kubeclient.New( + client, leaderElector, err := leaderelection.New( + c.ServerInstallationInfo, + deployment, dref, // first try to use the deployment as an owner ref (for namespace scoped resources) apiServiceRef, // fallback to our API service (for everything else we create) kubeclient.WithMiddleware(groupsuffix.New(c.APIGroupSuffix)), @@ -303,7 +306,7 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) { // Return a function which starts the informers and controllers. return func(ctx context.Context) { informers.startAndWaitForSync(ctx) - go controllerManager.Start(ctx) + go leaderElector(ctx, controllerManager.Start) }, nil } diff --git a/internal/kubeclient/roundtrip.go b/internal/kubeclient/roundtrip.go index 3d6029dc5..21b63eaaf 100644 --- a/internal/kubeclient/roundtrip.go +++ b/internal/kubeclient/roundtrip.go @@ -146,7 +146,7 @@ func handleOtherVerbs( result, err := middlewareReq.mutateRequest(obj) if err != nil { - return true, nil, err + return true, nil, fmt.Errorf("middleware request for %#v failed to mutate: %w", middlewareReq, err) } if !result.mutated { @@ -231,7 +231,7 @@ func handleCreateOrUpdate( result, err := middlewareReq.mutateRequest(obj) if err != nil { - return true, nil, err + return true, nil, fmt.Errorf("middleware request for %#v failed to mutate: %w", middlewareReq, err) } if !result.mutated { diff --git a/internal/leaderelection/leaderelection.go b/internal/leaderelection/leaderelection.go new file mode 100644 index 000000000..bb17c3de5 --- /dev/null +++ b/internal/leaderelection/leaderelection.go @@ -0,0 +1,150 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package leaderelection + +import ( + "context" + "fmt" + "time" + + "go.uber.org/atomic" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/leaderelection" + "k8s.io/client-go/tools/leaderelection/resourcelock" + + "go.pinniped.dev/internal/constable" + "go.pinniped.dev/internal/downward" + "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/internal/plog" +) + +const ErrNotLeader constable.Error = "write attempt rejected as client is not leader" + +// New returns a client that has a leader election middleware injected into it. +// This middleware will prevent all non-read requests to the Kubernetes API when +// the current process does not hold the leader election lock. Unlike normal +// leader election where the process blocks until it acquires the lock, this +// middleware approach lets the process run as normal for all read requests. +// Another difference is that if the process acquires the lock and then loses it +// (i.e. a failed renewal), it will not exit (i.e. restart). Instead, it will +// simply attempt to acquire the lock again. +// +// The returned function is blocking and will run the leader election polling +// logic and will coordinate lease release with the input controller starter function. +func New(podInfo *downward.PodInfo, deployment *appsv1.Deployment, opts ...kubeclient.Option) ( + *kubeclient.Client, + func(context.Context, func(context.Context)), + error, +) { + internalClient, err := kubeclient.New(opts...) + if err != nil { + return nil, nil, fmt.Errorf("could not create internal client for leader election: %w", err) + } + + isLeader := atomic.NewBool(false) + + identity := podInfo.Name + leaseName := deployment.Name + + leaderElectionConfig := leaderelection.LeaderElectionConfig{ + Lock: &resourcelock.LeaseLock{ + LeaseMeta: metav1.ObjectMeta{ + Namespace: podInfo.Namespace, + Name: leaseName, + }, + Client: internalClient.Kubernetes.CoordinationV1(), + LockConfig: resourcelock.ResourceLockConfig{ + Identity: identity, + }, + }, + ReleaseOnCancel: true, // semantics for correct release handled by controllersWithLeaderElector below + LeaseDuration: 60 * time.Second, + RenewDeadline: 15 * time.Second, + RetryPeriod: 5 * time.Second, + Callbacks: leaderelection.LeaderCallbacks{ + OnStartedLeading: func(_ context.Context) { + plog.Debug("leader gained", "identity", identity) + isLeader.Store(true) + }, + OnStoppedLeading: func() { + plog.Debug("leader lost", "identity", identity) + isLeader.Store(false) + }, + OnNewLeader: func(newLeader string) { + if newLeader == identity { + return + } + plog.Debug("new leader elected", "newLeader", newLeader) + }, + }, + Name: leaseName, + // this must be set to nil because we do not want to associate /healthz with a failed + // leader election renewal as we do not want to exit the process if the leader changes. + WatchDog: nil, + } + + // validate our config here before we rely on it being functioning below + if _, err := leaderelection.NewLeaderElector(leaderElectionConfig); err != nil { + return nil, nil, fmt.Errorf("invalid config - could not create leader elector: %w", err) + } + + writeOnlyWhenLeader := kubeclient.MiddlewareFunc(func(_ context.Context, rt kubeclient.RoundTrip) { + switch rt.Verb() { + case kubeclient.VerbGet, kubeclient.VerbList, kubeclient.VerbWatch: + // reads are always allowed. + // note that while our pods/exec into the kube cert agent pod is a write request from the + // perspective of the Kube API, it is semantically a read request since no mutation occurs. + // we simply use it to fill a cache, and we need all pods to have a functioning cache. + // however, we do not need to handle it here because remotecommand.NewSPDYExecutor uses a + // kubeclient.Client.JSONConfig as input. since our middleware logic is only injected into + // the generated clientset code, this JSONConfig simply ignores this middleware all together. + return + } + + if isLeader.Load() { // only perform "expensive" test for writes + return // we are currently the leader, all actions are permitted + } + + rt.MutateRequest(func(_ kubeclient.Object) error { + return ErrNotLeader // we are not the leader, fail the write request + }) + }) + + leaderElectionOpts := append( + // all middleware are always executed so this being the first middleware is not relevant + []kubeclient.Option{kubeclient.WithMiddleware(writeOnlyWhenLeader)}, + opts..., // do not mutate input slice + ) + + client, err := kubeclient.New(leaderElectionOpts...) + if err != nil { + return nil, nil, fmt.Errorf("could not create leader election client: %w", err) + } + + controllersWithLeaderElector := func(ctx context.Context, controllers func(context.Context)) { + leaderElectorCtx, leaderElectorCancel := context.WithCancel(context.Background()) // purposefully detached context + + go func() { + controllers(ctx) // run the controllers with the global context, this blocks until the context is canceled + leaderElectorCancel() // once the controllers have all stopped, tell the leader elector to release the lock + }() + + for { // run (and rerun on release) the leader elector with its own context (blocking) + select { + case <-leaderElectorCtx.Done(): + return // keep trying to run until process exit + + default: + // blocks while trying to acquire lease, unblocks on release. + // note that this creates a new leader elector on each loop to + // prevent any bugs from reusing that struct across elections. + // our config was validated above so this should never die. + leaderelection.RunOrDie(leaderElectorCtx, leaderElectionConfig) + } + } + } + + return client, controllersWithLeaderElector, nil +} diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index b4ce75490..a163573ee 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -41,6 +41,7 @@ import ( "go.pinniped.dev/internal/downward" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/internal/leaderelection" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider/manager" @@ -94,6 +95,7 @@ func startControllers( pinnipedClient pinnipedclientset.Interface, kubeInformers kubeinformers.SharedInformerFactory, pinnipedInformers pinnipedinformers.SharedInformerFactory, + leaderElector func(context.Context, func(context.Context)), ) { federationDomainInformer := pinnipedInformers.Config().V1alpha1().FederationDomains() secretInformer := kubeInformers.Core().V1().Secrets() @@ -261,7 +263,7 @@ func startControllers( kubeInformers.WaitForCacheSync(ctx.Done()) pinnipedInformers.WaitForCacheSync(ctx.Done()) - go controllerManager.Start(ctx) + go leaderElector(ctx, controllerManager.Start) } func run(podInfo *downward.PodInfo, cfg *supervisor.Config) error { @@ -275,14 +277,25 @@ func run(podInfo *downward.PodInfo, cfg *supervisor.Config) error { return fmt.Errorf("cannot create deployment ref: %w", err) } - client, err := kubeclient.New( + opts := []kubeclient.Option{ dref, kubeclient.WithMiddleware(groupsuffix.New(*cfg.APIGroupSuffix)), + } + + client, leaderElector, err := leaderelection.New( + podInfo, + supervisorDeployment, + opts..., ) if err != nil { return fmt.Errorf("cannot create k8s client: %w", err) } + clientWithoutLeaderElection, err := kubeclient.New(opts...) + if err != nil { + return fmt.Errorf("cannot create k8s client without leader election: %w", err) + } + kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions( client.Kubernetes, defaultResyncInterval, @@ -312,7 +325,7 @@ func run(podInfo *downward.PodInfo, cfg *supervisor.Config) error { dynamicJWKSProvider, dynamicUpstreamIDPProvider, &secretCache, - client.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), + clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders ) startControllers( @@ -328,6 +341,7 @@ func run(podInfo *downward.PodInfo, cfg *supervisor.Config) error { client.PinnipedSupervisor, kubeInformers, pinnipedInformers, + leaderElector, ) //nolint: gosec // Intentionally binding to all network interfaces. diff --git a/internal/testutil/delete.go b/internal/testutil/delete.go index 7a6a9fe5e..424ae5a39 100644 --- a/internal/testutil/delete.go +++ b/internal/testutil/delete.go @@ -7,6 +7,7 @@ import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" ) @@ -32,10 +33,24 @@ type coreWrapper struct { opts *[]metav1.DeleteOptions } +func (c *coreWrapper) Pods(namespace string) corev1client.PodInterface { + return &podsWrapper{PodInterface: c.CoreV1Interface.Pods(namespace), opts: c.opts} +} + func (c *coreWrapper) Secrets(namespace string) corev1client.SecretInterface { return &secretsWrapper{SecretInterface: c.CoreV1Interface.Secrets(namespace), opts: c.opts} } +type podsWrapper struct { + corev1client.PodInterface + opts *[]metav1.DeleteOptions +} + +func (s *podsWrapper) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + *s.opts = append(*s.opts, opts) + return s.PodInterface.Delete(ctx, name, opts) +} + type secretsWrapper struct { corev1client.SecretInterface opts *[]metav1.DeleteOptions @@ -45,3 +60,12 @@ func (s *secretsWrapper) Delete(ctx context.Context, name string, opts metav1.De *s.opts = append(*s.opts, opts) return s.SecretInterface.Delete(ctx, name, opts) } + +func NewPreconditions(uid types.UID, rv string) metav1.DeleteOptions { + return metav1.DeleteOptions{ + Preconditions: &metav1.Preconditions{ + UID: &uid, + ResourceVersion: &rv, + }, + } +} diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index 7397f4d04..35c092347 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -438,7 +438,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl t.Run("using and watching all the basic verbs", func(t *testing.T) { parallelIfNotEKS(t) // Create a namespace, because it will be easier to exercise "deletecollection" if we have a namespace. - namespaceName := createTestNamespace(t, adminClient) + namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name // Create and start informer to exercise the "watch" verb for us. informerFactory := k8sinformers.NewSharedInformerFactoryWithOptions( @@ -827,7 +827,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl // this works because impersonation cannot set UID and thus the final user info the proxy sees has no UID t.Run("nested impersonation as a service account is allowed if it has enough RBAC permissions", func(t *testing.T) { parallelIfNotEKS(t) - namespaceName := createTestNamespace(t, adminClient) + namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name saName, saToken, saUID := createServiceAccountToken(ctx, t, adminClient, namespaceName) nestedImpersonationClient := newImpersonationProxyClientWithCredentials(t, &loginv1alpha1.ClusterCredential{Token: saToken}, impersonationProxyURL, impersonationProxyCACertPEM, @@ -916,7 +916,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl } // Test using a service account token. - namespaceName := createTestNamespace(t, adminClient) + namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name saName, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName) impersonationProxyServiceAccountPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t, &loginv1alpha1.ClusterCredential{Token: saToken}, @@ -935,7 +935,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl }) t.Run("WhoAmIRequests and SA token request", func(t *testing.T) { - namespaceName := createTestNamespace(t, adminClient) + namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name kubeClient := adminClient.CoreV1() saName, _, saUID := createServiceAccountToken(ctx, t, adminClient, namespaceName) @@ -1145,7 +1145,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl t.Run("websocket client", func(t *testing.T) { parallelIfNotEKS(t) - namespaceName := createTestNamespace(t, adminClient) + namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name impersonationRestConfig := impersonationProxyRestConfig( refreshCredential(t, impersonationProxyURL, impersonationProxyCACertPEM), @@ -1224,7 +1224,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl t.Run("http2 client", func(t *testing.T) { parallelIfNotEKS(t) - namespaceName := createTestNamespace(t, adminClient) + namespaceName := testlib.CreateNamespace(ctx, t, "impersonation").Name wantConfigMapLabelKey, wantConfigMapLabelValue := "some-label-key", "some-label-value" wantConfigMap := &corev1.ConfigMap{ @@ -1783,7 +1783,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl testlib.RequireEventually(t, func(requireEventually *require.Assertions) { _, err := adminClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, impersonationProxyTLSSecretName(env), metav1.GetOptions{}) requireEventually.Truef(k8serrors.IsNotFound(err), "expected NotFound error, got %v", err) - }, 10*time.Second, 250*time.Millisecond) + }, time.Minute, time.Second) // Check that the generated CA cert Secret was not deleted by the controller because it's supposed to keep this // around in case we decide to later re-enable the impersonator. We want to avoid generating new CA certs when @@ -1864,27 +1864,6 @@ func ensureDNSResolves(t *testing.T, urlString string) { }, 5*time.Minute, 1*time.Second) } -func createTestNamespace(t *testing.T, adminClient kubernetes.Interface) string { - t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - namespace, err := adminClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{GenerateName: "impersonation-integration-test-"}, - }, metav1.CreateOptions{}) - require.NoError(t, err) - - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - t.Logf("cleaning up test namespace %s", namespace.Name) - require.NoError(t, adminClient.CoreV1().Namespaces().Delete(ctx, namespace.Name, metav1.DeleteOptions{})) - }) - return namespace.Name -} - func createServiceAccountToken(ctx context.Context, t *testing.T, adminClient kubernetes.Interface, namespaceName string) (name, token string, uid types.UID) { t.Helper() diff --git a/test/integration/leaderelection_test.go b/test/integration/leaderelection_test.go new file mode 100644 index 000000000..7fe80cf75 --- /dev/null +++ b/test/integration/leaderelection_test.go @@ -0,0 +1,278 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + coordinationv1 "k8s.io/api/coordination/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/util/retry" + "k8s.io/utils/pointer" + + "go.pinniped.dev/internal/downward" + "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/internal/leaderelection" + "go.pinniped.dev/test/testlib" +) + +func TestLeaderElection(t *testing.T) { + _ = testlib.IntegrationEnv(t) + + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + + leaseName := "leader-election-" + rand.String(5) + + namespace := testlib.CreateNamespace(ctx, t, leaseName) + + clients := leaderElectionClients(t, namespace, leaseName) + + // the tests below are order dependant to some degree and definitely cannot be run in parallel + + t.Run("sanity check write prevention", func(t *testing.T) { + lease := checkOnlyLeaderCanWrite(ctx, t, namespace, leaseName, clients) + logLease(t, lease) + }) + + t.Run("clients handle leader election transition correctly", func(t *testing.T) { + lease := forceTransition(ctx, t, namespace, leaseName, clients) + logLease(t, lease) + }) + + t.Run("sanity check write prevention after transition", func(t *testing.T) { + lease := checkOnlyLeaderCanWrite(ctx, t, namespace, leaseName, clients) + logLease(t, lease) + }) + + t.Run("clients handle leader election restart correctly", func(t *testing.T) { + lease := forceRestart(ctx, t, namespace, leaseName, clients) + logLease(t, lease) + }) + + t.Run("sanity check write prevention after restart", func(t *testing.T) { + lease := checkOnlyLeaderCanWrite(ctx, t, namespace, leaseName, clients) + logLease(t, lease) + }) +} + +func leaderElectionClient(t *testing.T, namespace *corev1.Namespace, leaseName, identity string) *kubeclient.Client { + t.Helper() + + podInfo := &downward.PodInfo{ + Namespace: namespace.Name, + Name: identity, + } + deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: leaseName}} + + client, leaderElector, err := leaderelection.New(podInfo, deployment, testlib.NewKubeclientOptions(t, testlib.NewClientConfig(t))...) + require.NoError(t, err) + + controllerCtx, controllerCancel := context.WithCancel(context.Background()) + leaderCtx, leaderCancel := context.WithCancel(context.Background()) + + t.Cleanup(func() { + controllerCancel() + + select { + case <-leaderCtx.Done(): + // leader election client stopped correctly + + case <-time.After(time.Minute): + t.Errorf("leader election client in namespace %q with lease %q and identity %q failed to stop", + namespace.Name, leaseName, identity) + } + }) + + go func() { + time.Sleep(time.Duration(rand.Int63nRange(1, 10)) * time.Second) // randomize start of client and controllers + + // this blocks + leaderElector(controllerCtx, func(ctx context.Context) { + <-ctx.Done() + time.Sleep(time.Duration(rand.Int63nRange(1, 10)) * time.Second) // randomize stop of controllers + }) + + select { + case <-controllerCtx.Done(): + // leaderElector correctly stopped but only after controllers stopped + + default: + t.Errorf("leader election client in namespace %q with lease %q and identity %q stopped early", + namespace.Name, leaseName, identity) + } + + leaderCancel() + }() + + return client +} + +func leaderElectionClients(t *testing.T, namespace *corev1.Namespace, leaseName string) map[string]*kubeclient.Client { + t.Helper() + + count := rand.IntnRange(1, 6) + out := make(map[string]*kubeclient.Client, count) + + for i := 0; i < count; i++ { + identity := "leader-election-client-" + rand.String(5) + out[identity] = leaderElectionClient(t, namespace, leaseName, identity) + } + + t.Logf("running leader election client tests with %d clients: %v", len(out), sets.StringKeySet(out).List()) + + return out +} + +func pickRandomLeaderElectionClient(clients map[string]*kubeclient.Client) *kubeclient.Client { + for _, client := range clients { + client := client + return client + } + panic("clients map was empty") +} + +func waitForIdentity(ctx context.Context, t *testing.T, namespace *corev1.Namespace, leaseName string, clients map[string]*kubeclient.Client) *coordinationv1.Lease { + t.Helper() + + identities := sets.StringKeySet(clients) + var out *coordinationv1.Lease + + testlib.RequireEventuallyWithoutError(t, func() (bool, error) { + lease, err := pickRandomLeaderElectionClient(clients).Kubernetes.CoordinationV1().Leases(namespace.Name).Get(ctx, leaseName, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + out = lease + return lease.Spec.HolderIdentity != nil && identities.Has(*lease.Spec.HolderIdentity), nil + }, 3*time.Minute, time.Second) + + return out +} + +func runWriteRequest(ctx context.Context, client *kubeclient.Client) error { + _, err := client.Kubernetes.AuthenticationV1().TokenReviews().Create(ctx, &authenticationv1.TokenReview{ + Spec: authenticationv1.TokenReviewSpec{Token: "any-non-empty-value"}, + }, metav1.CreateOptions{}) + return err +} + +func runWriteRequests(ctx context.Context, clients map[string]*kubeclient.Client) map[string]error { + out := make(map[string]error, len(clients)) + + for identity, client := range clients { + identity, client := identity, client + + out[identity] = runWriteRequest(ctx, client) + } + + return out +} + +func pickCurrentLeaderClient(ctx context.Context, t *testing.T, namespace *corev1.Namespace, leaseName string, clients map[string]*kubeclient.Client) *kubeclient.Client { + t.Helper() + + lease := waitForIdentity(ctx, t, namespace, leaseName, clients) + return clients[*lease.Spec.HolderIdentity] +} + +func checkOnlyLeaderCanWrite(ctx context.Context, t *testing.T, namespace *corev1.Namespace, leaseName string, clients map[string]*kubeclient.Client) *coordinationv1.Lease { + t.Helper() + + lease := waitForIdentity(ctx, t, namespace, leaseName, clients) + + var leaders, nonLeaders int + for identity, err := range runWriteRequests(ctx, clients) { + identity, err := identity, err + + if identity == *lease.Spec.HolderIdentity { + leaders++ + assert.NoError(t, err, "leader client %q should have no error", identity) + } else { + nonLeaders++ + assert.Error(t, err, "non leader client %q should have write error but it was nil", identity) + assert.True(t, errors.Is(err, leaderelection.ErrNotLeader), "non leader client %q should have write error: %v", identity, err) + } + } + assert.Equal(t, 1, leaders, "did not see leader") + assert.Equal(t, len(clients)-1, nonLeaders, "did not see non-leader") + + return lease +} + +func forceTransition(ctx context.Context, t *testing.T, namespace *corev1.Namespace, leaseName string, clients map[string]*kubeclient.Client) *coordinationv1.Lease { + t.Helper() + + var startTransitions int32 + var startTime metav1.MicroTime + + errRetry := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + leaderClient := pickCurrentLeaderClient(ctx, t, namespace, leaseName, clients) + startLease := waitForIdentity(ctx, t, namespace, leaseName, clients) + startTransitions = *startLease.Spec.LeaseTransitions + startTime = *startLease.Spec.AcquireTime + + startLease = startLease.DeepCopy() + startLease.Spec.HolderIdentity = pointer.String("some-other-client" + rand.String(5)) + + _, err := leaderClient.Kubernetes.CoordinationV1().Leases(namespace.Name).Update(ctx, startLease, metav1.UpdateOptions{}) + return err + }) + require.NoError(t, errRetry) + + finalLease := waitForIdentity(ctx, t, namespace, leaseName, clients) + finalTransitions := *finalLease.Spec.LeaseTransitions + finalTime := *finalLease.Spec.AcquireTime + + require.Greater(t, finalTransitions, startTransitions) + require.Greater(t, finalTime.UnixNano(), startTime.UnixNano()) + + time.Sleep(2 * time.Minute) // need to give clients time to notice this change because leader election is polling based + + return finalLease +} + +func forceRestart(ctx context.Context, t *testing.T, namespace *corev1.Namespace, leaseName string, clients map[string]*kubeclient.Client) *coordinationv1.Lease { + t.Helper() + + startLease := waitForIdentity(ctx, t, namespace, leaseName, clients) + + err := pickCurrentLeaderClient(ctx, t, namespace, leaseName, clients). + Kubernetes.CoordinationV1().Leases(namespace.Name).Delete(ctx, leaseName, metav1.DeleteOptions{}) + require.NoError(t, err) + + newLease := waitForIdentity(ctx, t, namespace, leaseName, clients) + require.Zero(t, *newLease.Spec.LeaseTransitions) + require.Greater(t, newLease.Spec.AcquireTime.UnixNano(), startLease.Spec.AcquireTime.UnixNano()) + + time.Sleep(2 * time.Minute) // need to give clients time to notice this change because leader election is polling based + + return newLease +} + +func logLease(t *testing.T, lease *coordinationv1.Lease) { + t.Helper() + + bytes, err := json.MarshalIndent(lease, "", "\t") + require.NoError(t, err) + + t.Logf("current lease:\n%s", string(bytes)) +} diff --git a/test/testlib/client.go b/test/testlib/client.go index 71396858f..46684ff2c 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -137,13 +137,19 @@ func newAnonymousClientRestConfigWithCertAndKeyAdded(t *testing.T, clientCertifi return config } +func NewKubeclientOptions(t *testing.T, config *rest.Config) []kubeclient.Option { + t.Helper() + + return []kubeclient.Option{ + kubeclient.WithConfig(config), + kubeclient.WithMiddleware(groupsuffix.New(IntegrationEnv(t).APIGroupSuffix)), + } +} + func NewKubeclient(t *testing.T, config *rest.Config) *kubeclient.Client { t.Helper() - env := IntegrationEnv(t) - client, err := kubeclient.New( - kubeclient.WithConfig(config), - kubeclient.WithMiddleware(groupsuffix.New(env.APIGroupSuffix)), - ) + + client, err := kubeclient.New(NewKubeclientOptions(t, config)...) require.NoError(t, err) return client } @@ -502,6 +508,30 @@ func CreatePod(ctx context.Context, t *testing.T, name, namespace string, spec c return result } +func CreateNamespace(ctx context.Context, t *testing.T, name string) *corev1.Namespace { + t.Helper() + + adminClient := NewKubernetesClientset(t) + + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + namespace, err := adminClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{GenerateName: name + "-integration-test-"}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + t.Logf("cleaning up test namespace %s", namespace.Name) + require.NoError(t, adminClient.CoreV1().Namespaces().Delete(ctx, namespace.Name, metav1.DeleteOptions{})) + }) + + return namespace +} + func WaitForUserToHaveAccess(t *testing.T, user string, groups []string, shouldHaveAccessTo *authorizationv1.ResourceAttributes) { t.Helper() client := NewKubernetesClientset(t) From 132ec0d2adc3996718fd711a658696457eea5bd4 Mon Sep 17 00:00:00 2001 From: Monis Khan Date: Fri, 20 Aug 2021 17:04:03 -0400 Subject: [PATCH 20/21] leader election test: fix flake related to invalid assumption Even though a client may hold the leader election lock in the Kube lease API, that does not mean it has had a chance to update its internal state to reflect that. Thus we retry the checks in checkOnlyLeaderCanWrite a few times to allow the client to catch up. Signed-off-by: Monis Khan --- test/integration/leaderelection_test.go | 33 +++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/test/integration/leaderelection_test.go b/test/integration/leaderelection_test.go index 7fe80cf75..9f5eaecd0 100644 --- a/test/integration/leaderelection_test.go +++ b/test/integration/leaderelection_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" authenticationv1 "k8s.io/api/authentication/v1" @@ -199,21 +198,23 @@ func checkOnlyLeaderCanWrite(ctx context.Context, t *testing.T, namespace *corev lease := waitForIdentity(ctx, t, namespace, leaseName, clients) - var leaders, nonLeaders int - for identity, err := range runWriteRequests(ctx, clients) { - identity, err := identity, err + testlib.RequireEventually(t, func(requireEventually *require.Assertions) { + var leaders, nonLeaders int + for identity, err := range runWriteRequests(ctx, clients) { + identity, err := identity, err - if identity == *lease.Spec.HolderIdentity { - leaders++ - assert.NoError(t, err, "leader client %q should have no error", identity) - } else { - nonLeaders++ - assert.Error(t, err, "non leader client %q should have write error but it was nil", identity) - assert.True(t, errors.Is(err, leaderelection.ErrNotLeader), "non leader client %q should have write error: %v", identity, err) + if identity == *lease.Spec.HolderIdentity { + leaders++ + requireEventually.NoError(err, "leader client %q should have no error", identity) + } else { + nonLeaders++ + requireEventually.Error(err, "non leader client %q should have write error but it was nil", identity) + requireEventually.True(errors.Is(err, leaderelection.ErrNotLeader), "non leader client %q should have write error: %v", identity, err) + } } - } - assert.Equal(t, 1, leaders, "did not see leader") - assert.Equal(t, len(clients)-1, nonLeaders, "did not see non-leader") + requireEventually.Equal(1, leaders, "did not see leader") + requireEventually.Equal(len(clients)-1, nonLeaders, "did not see non-leader") + }, time.Minute, time.Second) return lease } @@ -225,7 +226,6 @@ func forceTransition(ctx context.Context, t *testing.T, namespace *corev1.Namesp var startTime metav1.MicroTime errRetry := retry.RetryOnConflict(retry.DefaultBackoff, func() error { - leaderClient := pickCurrentLeaderClient(ctx, t, namespace, leaseName, clients) startLease := waitForIdentity(ctx, t, namespace, leaseName, clients) startTransitions = *startLease.Spec.LeaseTransitions startTime = *startLease.Spec.AcquireTime @@ -233,7 +233,8 @@ func forceTransition(ctx context.Context, t *testing.T, namespace *corev1.Namesp startLease = startLease.DeepCopy() startLease.Spec.HolderIdentity = pointer.String("some-other-client" + rand.String(5)) - _, err := leaderClient.Kubernetes.CoordinationV1().Leases(namespace.Name).Update(ctx, startLease, metav1.UpdateOptions{}) + _, err := pickCurrentLeaderClient(ctx, t, namespace, leaseName, clients). + Kubernetes.CoordinationV1().Leases(namespace.Name).Update(ctx, startLease, metav1.UpdateOptions{}) return err }) require.NoError(t, errRetry) From 211f4b23d147cebfd3322ed51fe54e248ed07e5b Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 20 Aug 2021 14:41:02 -0700 Subject: [PATCH 21/21] Log auth endpoint errors with stack traces --- internal/oidc/auth/auth_handler.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index fb2897191..004d6cd95 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -250,8 +250,18 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( } func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error) error { - errWithStack := errors.WithStack(err) - plog.Info("authorize response error", oidc.FositeErrorForLog(errWithStack)...) + if plog.Enabled(plog.LevelTrace) { + // When trace level logging is enabled, include the stack trace in the log message. + keysAndValues := oidc.FositeErrorForLog(err) + errWithStack := errors.WithStack(err) + keysAndValues = append(keysAndValues, "errWithStack") + // klog always prints error values using %s, which does not include stack traces, + // so convert the error to a string which includes the stack trace here. + keysAndValues = append(keysAndValues, fmt.Sprintf("%+v", errWithStack)) + plog.Trace("authorize response error", keysAndValues...) + } else { + plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) + } // Return an error according to OIDC spec 3.1.2.6 (second paragraph). oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) return nil