From 694e4d6df6885106966bac80edbca04d1a0053da Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Wed, 20 Apr 2022 14:58:09 -0700 Subject: [PATCH 01/26] Advertise browser_authcode flow in ldap idp discovery To keep this backwards compatible, this PR changes how the cli deals with ambiguous flows. Previously, if there was more than one flow advertised, the cli would require users to set the flag --upstream-identity-provider-flow. Now it chooses the first one in the list. Signed-off-by: Margo Crawford --- cmd/pinniped/cmd/kubeconfig.go | 15 +++--- cmd/pinniped/cmd/kubeconfig_test.go | 48 +++++++++++++++++-- .../idpdiscovery/idp_discovery_handler.go | 6 +-- .../idp_discovery_handler_test.go | 20 ++++---- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index f10fcd43..74280ec5 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -233,7 +233,7 @@ func runGetKubeconfig(ctx context.Context, out io.Writer, deps kubeconfigDeps, f // 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 { + if err := discoverSupervisorUpstreamIDP(ctx, &flags, deps.log); err != nil { return err } } @@ -726,7 +726,7 @@ func hasPendingStrategy(credentialIssuer *configv1alpha1.CredentialIssuer) bool return false } -func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigParams) error { +func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigParams, log logr.Logger) error { httpClient, err := newDiscoveryHTTPClient(flags.oidc.caBundle) if err != nil { return err @@ -758,7 +758,7 @@ func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigPara return err } - selectedIDPFlow, err := selectUpstreamIDPFlow(discoveredIDPFlows, selectedIDPName, selectedIDPType, flags.oidc.upstreamIDPFlow) + selectedIDPFlow, err := selectUpstreamIDPFlow(discoveredIDPFlows, selectedIDPName, selectedIDPType, flags.oidc.upstreamIDPFlow, log) if err != nil { return err } @@ -898,7 +898,7 @@ func selectUpstreamIDPNameAndType(pinnipedIDPs []idpdiscoveryv1alpha1.PinnipedID } } -func selectUpstreamIDPFlow(discoveredIDPFlows []idpdiscoveryv1alpha1.IDPFlow, selectedIDPName string, selectedIDPType idpdiscoveryv1alpha1.IDPType, specifiedFlow string) (idpdiscoveryv1alpha1.IDPFlow, error) { +func selectUpstreamIDPFlow(discoveredIDPFlows []idpdiscoveryv1alpha1.IDPFlow, selectedIDPName string, selectedIDPType idpdiscoveryv1alpha1.IDPType, specifiedFlow string, log logr.Logger) (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. @@ -922,10 +922,7 @@ func selectUpstreamIDPFlow(discoveredIDPFlows []idpdiscoveryv1alpha1.IDPFlow, se 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) + log.Info("multiple client flows found, selecting first value as default: "+discoveredIDPFlows[0].String(), "idpName", selectedIDPName, "idpType", selectedIDPType) + return discoveredIDPFlows[0], nil } } diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index e5c760aa..e5c27797 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -1261,13 +1261,51 @@ func TestGetKubeconfig(t *testing.T) { oidcDiscoveryResponse: happyOIDCDiscoveryResponse, idpsDiscoveryResponse: here.Docf(`{ "pinniped_identity_providers": [ - {"name": "some-oidc-idp", "type": "oidc", "flows": ["flow1", "flow2"]} + {"name": "some-ldap-idp", "type": "ldap", "flows": ["cli_password", "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" + 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: [] + installHint: The pinniped CLI does not appear to be installed. See https://get.pinniped.dev/cli + for more details + provideClusterInfo: true + `, + issuerURL, + base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) + }, + wantLogs: func(_ string, _ string) []string { + return []string{"\"level\"=0 \"msg\"=\"multiple client flows found, selecting first value as default: cli_password\" \"idpName\"=\"some-ldap-idp\" \"idpType\"=\"ldap\""} }, }, { diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/oidc/idpdiscovery/idp_discovery_handler.go index 8949502c..66a974c9 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler.go @@ -1,4 +1,4 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package idpdiscovery provides a handler for the upstream IDP discovery endpoint. @@ -44,14 +44,14 @@ func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ Name: provider.GetName(), Type: v1alpha1.IDPTypeLDAP, - Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword}, + Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword, v1alpha1.IDPFlowBrowserAuthcode}, }) } for _, provider := range upstreamIDPs.GetActiveDirectoryIdentityProviders() { r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ Name: provider.GetName(), Type: v1alpha1.IDPTypeActiveDirectory, - Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword}, + Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword, v1alpha1.IDPFlowBrowserAuthcode}, }) } for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() { diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go index f5e601bd..b33ab2d8 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go +++ b/internal/oidc/idpdiscovery/idp_discovery_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package idpdiscovery @@ -37,22 +37,22 @@ func TestIDPDiscovery(t *testing.T) { wantContentType: "application/json", wantFirstResponseBodyJSON: here.Doc(`{ "pinniped_identity_providers": [ - {"name": "a-some-ldap-idp", "type": "ldap", "flows": ["cli_password"]}, + {"name": "a-some-ldap-idp", "type": "ldap", "flows": ["cli_password", "browser_authcode"]}, {"name": "a-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode"]}, - {"name": "x-some-idp", "type": "ldap", "flows": ["cli_password"]}, + {"name": "x-some-idp", "type": "ldap", "flows": ["cli_password", "browser_authcode"]}, {"name": "x-some-idp", "type": "oidc", "flows": ["browser_authcode"]}, - {"name": "y-some-ad-idp", "type": "activedirectory", "flows": ["cli_password"]}, - {"name": "z-some-ad-idp", "type": "activedirectory", "flows": ["cli_password"]}, - {"name": "z-some-ldap-idp", "type": "ldap", "flows": ["cli_password"]}, + {"name": "y-some-ad-idp", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]}, + {"name": "z-some-ad-idp", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]}, + {"name": "z-some-ldap-idp", "type": "ldap", "flows": ["cli_password", "browser_authcode"]}, {"name": "z-some-oidc-idp", "type": "oidc", "flows": ["browser_authcode", "cli_password"]} ] }`), wantSecondResponseBodyJSON: here.Doc(`{ "pinniped_identity_providers": [ - {"name": "some-other-ad-idp-1", "type": "activedirectory", "flows": ["cli_password"]}, - {"name": "some-other-ad-idp-2", "type": "activedirectory", "flows": ["cli_password"]}, - {"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-ad-idp-1", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]}, + {"name": "some-other-ad-idp-2", "type": "activedirectory", "flows": ["cli_password", "browser_authcode"]}, + {"name": "some-other-ldap-idp-1", "type": "ldap", "flows": ["cli_password", "browser_authcode"]}, + {"name": "some-other-ldap-idp-2", "type": "ldap", "flows": ["cli_password", "browser_authcode"]}, {"name": "some-other-oidc-idp-1", "type": "oidc", "flows": ["browser_authcode", "cli_password"]}, {"name": "some-other-oidc-idp-2", "type": "oidc", "flows": ["browser_authcode"]} ] From 8832362b94b4cf016c64bd6fbe6c1861726c13d3 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Mon, 25 Apr 2022 16:41:55 -0700 Subject: [PATCH 02/26] WIP: Add login handler for LDAP/AD web login Also change state param to include IDP type --- internal/oidc/login/login_handler.go | 23 +++++++++++++++++++++++ internal/oidc/oidc.go | 6 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 internal/oidc/login/login_handler.go diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go new file mode 100644 index 00000000..10727b3c --- /dev/null +++ b/internal/oidc/login/login_handler.go @@ -0,0 +1,23 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "net/http" +) + +// NewHandler returns an http.Handler that serves the login endpoint for IDPs that +// don't have their own Web UI. +func NewHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed) + return + } + _, err := w.Write([]byte("

hello world

")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 6c3c1918..9467eb22 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package oidc contains common OIDC functionality needed by Pinniped. @@ -26,13 +26,14 @@ const ( CallbackEndpointPath = "/callback" JWKSEndpointPath = "/jwks.json" PinnipedIDPsPathV1Alpha1 = "/v1alpha1/pinniped_identity_providers" + PinnipedLoginPath = "/login" ) const ( // Just in case we need to make a breaking change to the format of the upstream state param, // we are including a format version number. This gives the opportunity for a future version of Pinniped // to have the consumer of this format decide to reject versions that it doesn't understand. - UpstreamStateParamFormatVersion = "1" + UpstreamStateParamFormatVersion = "2" // The `name` passed to the encoder for encoding the upstream state param value. This name is short // because it will be encoded into the upstream state param value and we're trying to keep that small. @@ -93,6 +94,7 @@ type Codec interface { type UpstreamStateParamData struct { AuthParams string `json:"p"` UpstreamName string `json:"u"` + UpstreamType string `json:"t"` Nonce nonce.Nonce `json:"n"` CSRFToken csrftoken.CSRFToken `json:"c"` PKCECode pkce.Code `json:"k"` From eb1d3812ece58591b5a6a5728af7eea40fade5a9 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Tue, 26 Apr 2022 12:51:56 -0700 Subject: [PATCH 03/26] Update authorization endpoint to redirect to new login page Also fix some test failures on the callback handler, register the new login handler in manager.go and add a (half baked) integration test Signed-off-by: Margo Crawford --- internal/oidc/auth/auth_handler.go | 115 ++++++++++++++++- internal/oidc/auth/auth_handler_test.go | 112 +++++++++++++---- .../oidc/callback/callback_handler_test.go | 3 +- internal/oidc/provider/manager/manager.go | 6 +- .../testutil/oidctestutil/oidctestutil.go | 1 + test/integration/e2e_test.go | 116 ++++++++++++++++++ 6 files changed, 323 insertions(+), 30 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 4f281ad8..5b4d0bcb 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -7,6 +7,7 @@ package auth import ( "fmt" "net/http" + "net/url" "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" @@ -75,15 +76,33 @@ func NewHandler( cookieCodec, ) } - return handleAuthRequestForLDAPUpstream(r, w, - oauthHelperWithStorage, + + // we know it's an AD/LDAP upstream. + if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { + // The client set a username header, so they are trying to log in with a username/password. + return handleAuthRequestForLDAPUpstreamCLIFlow(r, w, + oauthHelperWithStorage, + ldapUpstream, + idpType, + ) + } + return handleAuthRequestForLDAPUpstreamBrowserFlow( + r, + w, + oauthHelperWithoutStorage, + generateCSRF, + generateNonce, + generatePKCE, ldapUpstream, idpType, + downstreamIssuer, + upstreamStateEncoder, + cookieCodec, ) })) } -func handleAuthRequestForLDAPUpstream( +func handleAuthRequestForLDAPUpstreamCLIFlow( r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, @@ -138,6 +157,93 @@ func handleAuthRequestForLDAPUpstream( oauthHelper, authorizeRequester, subject, username, groups, customSessionData) } +func handleAuthRequestForLDAPUpstreamBrowserFlow( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + generateCSRF func() (csrftoken.CSRFToken, error), + generateNonce func() (nonce.Nonce, error), + generatePKCE func() (pkce.Code, error), + ldapUpstream provider.UpstreamLDAPIdentityProviderI, + idpType psession.ProviderType, + downstreamIssuer string, + upstreamStateEncoder oidc.Encoder, + cookieCodec oidc.Codec, +) error { + authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false) + if !created { + return nil + } + + now := time.Now() + _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. + Subject: "none", + AuthTime: now, + RequestedAt: now, + }, + }, + }) + if err != nil { + return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false) + } + + csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) + if err != nil { + plog.Error("authorize generate error", err) + return err + } + csrfFromCookie := readCSRFCookie(r, cookieCodec) + if csrfFromCookie != "" { + csrfValue = csrfFromCookie + } + + encodedStateParamValue, err := upstreamStateParam( + authorizeRequester, + ldapUpstream.GetName(), + string(idpType), + nonceValue, + csrfValue, + pkceValue, + upstreamStateEncoder, + ) + if err != nil { + plog.Error("authorize upstream state param error", err) + return err + } + + promptParam := r.Form.Get(promptParamName) + if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { + return writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) + } + + if csrfFromCookie == "" { + // We did not receive an incoming CSRF cookie, so write a new one. + err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec) + if err != nil { + plog.Error("error setting CSRF cookie", err) + return err + } + } + + loginURL, err := url.Parse(downstreamIssuer + "/login") + if err != nil { + return err + } + q := loginURL.Query() + q.Set("state", encodedStateParamValue) + loginURL.RawQuery = q.Encode() + + http.Redirect(w, r, + loginURL.String(), + http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 + ) + + return nil +} + func handleAuthRequestForOIDCUpstreamPasswordGrant( r *http.Request, w http.ResponseWriter, @@ -246,6 +352,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( encodedStateParamValue, err := upstreamStateParam( authorizeRequester, oidcUpstream.GetName(), + string(psession.ProviderTypeOIDC), nonceValue, csrfValue, pkceValue, @@ -463,6 +570,7 @@ func generateValues( func upstreamStateParam( authorizeRequester fosite.AuthorizeRequester, upstreamName string, + upstreamType string, nonceValue nonce.Nonce, csrfValue csrftoken.CSRFToken, pkceValue pkce.Code, @@ -471,6 +579,7 @@ func upstreamStateParam( stateParamData := oidc.UpstreamStateParamData{ AuthParams: authorizeRequester.GetRequestForm().Encode(), UpstreamName: upstreamName, + UpstreamType: upstreamType, Nonce: nonceValue, CSRFToken: csrfValue, PKCECode: pkceValue, diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 51a810de..34e1f158 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -409,23 +409,20 @@ func TestAuthorizationEndpoint(t *testing.T) { return pathWithQuery("/some/path", modifiedHappyGetRequestQueryMap(queryOverrides)) } - expectedUpstreamStateParam := func(queryOverrides map[string]string, csrfValueOverride, upstreamNameOverride string) string { + expectedUpstreamStateParam := func(queryOverrides map[string]string, csrfValueOverride, upstreamName, upstreamType string) string { csrf := happyCSRF if csrfValueOverride != "" { csrf = csrfValueOverride } - upstreamName := oidcUpstreamName - if upstreamNameOverride != "" { - upstreamName = upstreamNameOverride - } encoded, err := happyStateEncoder.Encode("s", oidctestutil.ExpectedUpstreamStateParamFormat{ P: encodeQuery(modifiedHappyGetRequestQueryMap(queryOverrides)), U: upstreamName, + T: upstreamType, N: happyNonce, C: csrf, K: happyPKCE, - V: "1", + V: "2", }, ) require.NoError(t, err) @@ -558,7 +555,24 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, + { + name: "LDAP upstream browser flow happy path using GET without a CSRF cookie", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPath, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", ldapUpstreamName, "ldap")}), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -639,7 +653,7 @@ func TestAuthorizationEndpoint(t *testing.T) { csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -659,7 +673,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: "", wantBodyString: "", wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, }, { @@ -748,7 +762,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, }, { @@ -767,7 +781,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), map[string]string{"prompt": "consent", "abc": "123", "def": "456"}), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", oidcUpstreamName, "oidc"), map[string]string{"prompt": "consent", "abc": "123", "def": "456"}), wantUpstreamStateParamInLocationHeader: true, }, { @@ -802,7 +816,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, // Generated a new CSRF cookie and set it in the response. wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -823,7 +837,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantCSRFValueInCookieHeader: happyCSRF, wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client - }, "", ""), nil), + }, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -889,7 +903,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantCSRFValueInCookieHeader: happyCSRF, wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "scope": "openid offline_access", - }, "", ""), nil), + }, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -1063,7 +1077,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing upstream username on request for LDAP authentication", + name: "missing upstream username but has password on request for LDAP authentication", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, @@ -1338,21 +1352,45 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "response type is unsupported when using LDAP upstream", + name: "response type is unsupported when using LDAP cli upstream", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), + wantBodyString: "", + }, + { + name: "response type is unsupported when using LDAP browser upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), - wantStatus: http.StatusFound, + wantStatus: http.StatusSeeOther, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, { - name: "response type is unsupported when using active directory upstream", + name: "response type is unsupported when using active directory cli upstream", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + 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 active directory browser upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), - wantStatus: http.StatusFound, + wantStatus: http.StatusSeeOther, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", @@ -1436,21 +1474,45 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing response type in request using LDAP upstream", + name: "missing response type in request using LDAP cli upstream", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + 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 browser upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), - wantStatus: http.StatusFound, + wantStatus: http.StatusSeeOther, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, { - name: "missing response type in request using Active Directory upstream", + name: "missing response type in request using Active Directory cli upstream", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + 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 Active Directory browser upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), - wantStatus: http.StatusFound, + wantStatus: http.StatusSeeOther, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", @@ -1720,7 +1782,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam( - map[string]string{"prompt": "none login", "scope": "email"}, "", "", + map[string]string{"prompt": "none login", "scope": "email"}, "", oidcUpstreamName, "oidc", ), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, @@ -2543,7 +2605,7 @@ func TestAuthorizationEndpoint(t *testing.T) { "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", + nil, "", "some-other-new-idp-name", "oidc", ), // updated expectation "nonce": happyNonce, "code_challenge": expectedUpstreamCodeChallenge, diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 83e2af60..21380979 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -48,7 +48,7 @@ const ( happyDownstreamCSRF = "test-csrf" happyDownstreamPKCE = "test-pkce" happyDownstreamNonce = "test-nonce" - happyDownstreamStateVersion = "1" + happyDownstreamStateVersion = "2" downstreamIssuer = "https://my-downstream-issuer.com/path" downstreamRedirectURI = "http://127.0.0.1/callback" @@ -1162,6 +1162,7 @@ func happyUpstreamStateParam() *upstreamStateParamBuilder { return &upstreamStateParamBuilder{ U: happyUpstreamIDPName, P: happyDownstreamRequestParams, + T: "oidc", N: happyDownstreamNonce, C: happyDownstreamCSRF, K: happyDownstreamPKCE, diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 708d4855..dda7fa86 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package manager @@ -8,6 +8,8 @@ import ( "strings" "sync" + "go.pinniped.dev/internal/oidc/login" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/internal/oidc" @@ -134,6 +136,8 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs oauthHelperWithKubeStorage, ) + m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler() + plog.Debug("oidc provider manager added or updated issuer", "issuer", issuer) } } diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 769a1d57..1936c406 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -830,6 +830,7 @@ func NewTestUpstreamOIDCIdentityProviderBuilder() *TestUpstreamOIDCIdentityProvi type ExpectedUpstreamStateParamFormat struct { P string `json:"p"` U string `json:"u"` + T string `json:"t"` N string `json:"n"` C string `json:"c"` K string `json:"k"` diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index b2cfd68d..d0cb858b 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -964,6 +964,122 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo expectedGroups, ) }) + + // Add an OIDC upstream IDP and try using it to authenticate during kubectl commands. + t.Run("with Supervisor LDAP upstream IDP and browser flow", func(t *testing.T) { + testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. + page := browsertest.Open(t) + + expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue + + setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/ldap-test-sessions.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-ca-bundle", testCABundlePath, + "--upstream-identity-provider-flow", "browser_authcode", + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6") + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + + // Wrap the stdout and stderr pipes with TeeReaders which will copy each incremental read to an + // in-memory buffer, so we can have the full output available to us at the end. + originalStderrPipe, err := kubectlCmd.StderrPipe() + require.NoError(t, err) + originalStdoutPipe, err := kubectlCmd.StdoutPipe() + require.NoError(t, err) + var stderrPipeBuf, stdoutPipeBuf bytes.Buffer + stderrPipe := io.TeeReader(originalStderrPipe, &stderrPipeBuf) + stdoutPipe := io.TeeReader(originalStdoutPipe, &stdoutPipeBuf) + + t.Logf("starting kubectl subprocess") + require.NoError(t, kubectlCmd.Start()) + t.Cleanup(func() { + // Consume readers so that the tee buffers will contain all the output so far. + _, stdoutReadAllErr := readAllCtx(testCtx, stdoutPipe) + _, stderrReadAllErr := readAllCtx(testCtx, stderrPipe) + + // Note that Wait closes the stdout/stderr pipes, so we don't need to close them ourselves. + waitErr := kubectlCmd.Wait() + t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) + + // Upon failure, print the full output so far of the kubectl command. + var testAlreadyFailedErr error + if t.Failed() { + testAlreadyFailedErr = errors.New("test failed prior to clean up function") + } + cleanupErrs := utilerrors.NewAggregate([]error{waitErr, stdoutReadAllErr, stderrReadAllErr, testAlreadyFailedErr}) + + if cleanupErrs != nil { + t.Logf("kubectl stdout was:\n----start of stdout\n%s\n----end of stdout", stdoutPipeBuf.String()) + t.Logf("kubectl stderr was:\n----start of stderr\n%s\n----end of stderr", stderrPipeBuf.String()) + } + require.NoErrorf(t, cleanupErrs, "kubectl process did not exit cleanly and/or the test failed. "+ + "Note: if kubectl's first call to the Pinniped CLI results in the Pinniped CLI returning an error, "+ + "then kubectl may call the Pinniped CLI again, which may hang because it will wait for the user "+ + "to finish the login. This test will kill the kubectl process after a timeout. In this case, the "+ + " kubectl output printed above will include multiple prompts for the user to enter their authcode.", + ) + }) + + // Start a background goroutine to read stderr from the CLI and parse out the login URL. + loginURLChan := make(chan string, 1) + spawnTestGoroutine(testCtx, t, func() error { + reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderrPipe)) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + loginURL, err := url.Parse(strings.TrimSpace(scanner.Text())) + if err == nil && loginURL.Scheme == "https" { + loginURLChan <- loginURL.String() // this channel is buffered so this will not block + return nil + } + } + return fmt.Errorf("expected stderr to contain login URL") + }) + + // Start a background goroutine to read stdout from kubectl and return the result as a string. + kubectlOutputChan := make(chan string, 1) + spawnTestGoroutine(testCtx, t, func() error { + output, err := readAllCtx(testCtx, stdoutPipe) + if err != nil { + return err + } + t.Logf("kubectl output:\n%s\n", output) + kubectlOutputChan <- string(output) // this channel is buffered so this will not block + return nil + }) + + // Wait for the CLI to print out the login URL and open the browser to it. + t.Logf("waiting for CLI to output login URL") + var loginURL string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for login URL") + case loginURL = <-loginURLChan: + } + t.Logf("navigating to login page: %q", loginURL) + require.NoError(t, page.Navigate(loginURL)) + + // Expect to be redirected to the supervisor's ldap login page. + t.Logf("waiting for redirect to supervisor ldap login page") + regex := regexp.MustCompile(`\A` + downstream.Spec.Issuer + `/login.+`) + browsertest.WaitForURL(t, page, regex) + + // TODO actually log in :P + }) } func setupClusterForEndToEndLDAPTest(t *testing.T, username string, env *testlib.TestEnv) { From 65eed7e74282b08d257b30697ba0f5e301e24f5f Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 26 Apr 2022 15:30:39 -0700 Subject: [PATCH 04/26] Implement login_handler.go to defer to other handlers The other handlers for GET and POST requests are not yet implemented in this commit. The shared handler code in login_handler.go takes care of things checking the method, checking the CSRF cookie, decoding the state param, and adding security headers on behalf of both the GET and POST handlers. Some code has been extracted from callback_handler.go to be shared. --- internal/oidc/callback/callback_handler.go | 57 +-- .../oidc/callback/callback_handler_test.go | 37 +- internal/oidc/login/get_login_handler.go | 17 + internal/oidc/login/login_handler.go | 63 ++- internal/oidc/login/login_handler_test.go | 448 ++++++++++++++++++ internal/oidc/login/post_login_handler.go | 19 + internal/oidc/oidc.go | 68 +++ internal/oidc/provider/manager/manager.go | 7 +- .../testutil/oidctestutil/oidctestutil.go | 39 ++ 9 files changed, 656 insertions(+), 99 deletions(-) create mode 100644 internal/oidc/login/get_login_handler.go create mode 100644 internal/oidc/login/login_handler_test.go create mode 100644 internal/oidc/login/post_login_handler.go diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index fbf13728..bcb8bf1b 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -5,7 +5,6 @@ package callback import ( - "crypto/subtle" "net/http" "net/url" @@ -14,7 +13,6 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/downstreamsession" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider/formposthtml" @@ -102,9 +100,9 @@ func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) return nil, httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET)", r.Method) } - csrfValue, err := readCSRFCookie(r, cookieDecoder) + _, decodedState, err := oidc.ReadStateParamAndValidateCSRFCookie(r, cookieDecoder, stateDecoder) if err != nil { - plog.InfoErr("error reading CSRF cookie", err) + plog.InfoErr("state or CSRF error", err) return nil, err } @@ -113,23 +111,7 @@ func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) return nil, httperr.New(http.StatusBadRequest, "code param not found") } - if r.FormValue("state") == "" { - plog.Info("state param not found") - return nil, httperr.New(http.StatusBadRequest, "state param not found") - } - - state, err := readState(r, stateDecoder) - if err != nil { - plog.InfoErr("error reading state", err) - return nil, err - } - - if subtle.ConstantTimeCompare([]byte(state.CSRFToken), []byte(csrfValue)) != 1 { - plog.InfoErr("CSRF value does not match", err) - return nil, httperr.Wrap(http.StatusForbidden, "CSRF value does not match", err) - } - - return state, nil + return decodedState, nil } func findUpstreamIDPConfig(upstreamName string, upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister) provider.UpstreamOIDCIdentityProviderI { @@ -140,36 +122,3 @@ func findUpstreamIDPConfig(upstreamName string, upstreamIDPs oidc.UpstreamOIDCId } return nil } - -func readCSRFCookie(r *http.Request, cookieDecoder oidc.Decoder) (csrftoken.CSRFToken, error) { - receivedCSRFCookie, err := r.Cookie(oidc.CSRFCookieName) - if err != nil { - // Error means that the cookie was not found - return "", httperr.Wrap(http.StatusForbidden, "CSRF cookie is missing", err) - } - - var csrfFromCookie csrftoken.CSRFToken - err = cookieDecoder.Decode(oidc.CSRFCookieEncodingName, receivedCSRFCookie.Value, &csrfFromCookie) - if err != nil { - return "", httperr.Wrap(http.StatusForbidden, "error reading CSRF cookie", err) - } - - return csrfFromCookie, nil -} - -func readState(r *http.Request, stateDecoder oidc.Decoder) (*oidc.UpstreamStateParamData, error) { - var state oidc.UpstreamStateParamData - if err := stateDecoder.Decode( - oidc.UpstreamStateParamEncodingName, - r.FormValue("state"), - &state, - ); err != nil { - return nil, httperr.New(http.StatusBadRequest, "error reading state") - } - - if state.FormatVersion != oidc.UpstreamStateParamFormatVersion { - return nil, httperr.New(http.StatusUnprocessableEntity, "state format version is invalid") - } - - return &state, nil -} diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 21380979..6fc47773 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -1156,10 +1156,8 @@ func (r *requestPath) String() string { return path + params.Encode() } -type upstreamStateParamBuilder oidctestutil.ExpectedUpstreamStateParamFormat - -func happyUpstreamStateParam() *upstreamStateParamBuilder { - return &upstreamStateParamBuilder{ +func happyUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { + return &oidctestutil.UpstreamStateParamBuilder{ U: happyUpstreamIDPName, P: happyDownstreamRequestParams, T: "oidc", @@ -1170,37 +1168,6 @@ func happyUpstreamStateParam() *upstreamStateParamBuilder { } } -func (b upstreamStateParamBuilder) Build(t *testing.T, stateEncoder *securecookie.SecureCookie) string { - state, err := stateEncoder.Encode("s", b) - require.NoError(t, err) - return state -} - -func (b *upstreamStateParamBuilder) WithAuthorizeRequestParams(params string) *upstreamStateParamBuilder { - b.P = params - return b -} - -func (b *upstreamStateParamBuilder) WithNonce(nonce string) *upstreamStateParamBuilder { - b.N = nonce - return b -} - -func (b *upstreamStateParamBuilder) WithCSRF(csrf string) *upstreamStateParamBuilder { - b.C = csrf - return b -} - -func (b *upstreamStateParamBuilder) WithPKCVE(pkce string) *upstreamStateParamBuilder { - b.K = pkce - return b -} - -func (b *upstreamStateParamBuilder) WithStateVersion(version string) *upstreamStateParamBuilder { - b.V = version - return b -} - func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(happyUpstreamIDPName). diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go new file mode 100644 index 00000000..e1d6ffb6 --- /dev/null +++ b/internal/oidc/login/get_login_handler.go @@ -0,0 +1,17 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "net/http" + + "go.pinniped.dev/internal/oidc" +) + +func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc { + return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { + // TODO + return nil + } +} diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go index 10727b3c..a8e65e0e 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/oidc/login/login_handler.go @@ -5,19 +5,64 @@ package login import ( "net/http" + + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + "go.pinniped.dev/internal/httputil/httperr" + "go.pinniped.dev/internal/httputil/securityheader" + "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/plog" ) -// NewHandler returns an http.Handler that serves the login endpoint for IDPs that -// don't have their own Web UI. -func NewHandler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed) - return +// HandlerFunc is a function that can handle either a GET or POST request for the login endpoint. +type HandlerFunc func( + w http.ResponseWriter, + r *http.Request, + encodedState string, + decodedState *oidc.UpstreamStateParamData, +) error + +// NewHandler returns a http.Handler that serves the login endpoint for IDPs that don't have their own web UI for login. +// +// This handler takes care of the shared concerns between the GET and POST methods of the login endpoint: +// checking the method, checking the CSRF cookie, decoding the state param, and adding security headers. +// Then it defers the rest of the handling to the passed in handler functions for GET and POST requests. +// Note that CSRF protection isn't needed on GET requests, but it doesn't hurt. Putting it here +// keeps the implementations and tests of HandlerFunc simpler since they won't need to deal with any decoders. +// Users should always initially get redirected to this page from the authorization endpoint, and never need +// to navigate directly to this page in their browser without going through the authorization endpoint first. +// Once their browser has landed on this page, it should be okay for the user to refresh the browser. +func NewHandler( + stateDecoder oidc.Decoder, + cookieDecoder oidc.Decoder, + getHandler HandlerFunc, // use NewGetHandler() for production + postHandler HandlerFunc, // use NewPostHandler() for production +) http.Handler { + loginHandler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + var handler HandlerFunc + switch r.Method { + case http.MethodGet: + handler = getHandler + case http.MethodPost: + handler = postHandler + default: + return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) } - _, err := w.Write([]byte("

hello world

")) + + encodedState, decodedState, err := oidc.ReadStateParamAndValidateCSRFCookie(r, cookieDecoder, stateDecoder) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + plog.InfoErr("state or CSRF error", err) + return err } + + switch decodedState.UpstreamType { + case string(idpdiscoveryv1alpha1.IDPTypeLDAP), string(idpdiscoveryv1alpha1.IDPTypeActiveDirectory): + // these are the types supported by this endpoint, so no error here + default: + return httperr.Newf(http.StatusBadRequest, "not a supported upstream IDP type for this endpoint: %q", decodedState.UpstreamType) + } + + return handler(w, r, encodedState, decodedState) }) + + return securityheader.Wrap(loginHandler) } diff --git a/internal/oidc/login/login_handler_test.go b/internal/oidc/login/login_handler_test.go new file mode 100644 index 00000000..c77758da --- /dev/null +++ b/internal/oidc/login/login_handler_test.go @@ -0,0 +1,448 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gorilla/securecookie" + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/httputil/httperr" + "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" +) + +func TestLoginEndpoint(t *testing.T) { + const ( + htmlContentType = "text/html; charset=utf-8" + happyGetResult = "

get handler result

" + happyPostResult = "

post handler result

" + + happyUpstreamIDPName = "upstream-idp-name" + happyUpstreamIDPType = "ldap" + happyDownstreamCSRF = "test-csrf" + happyDownstreamPKCE = "test-pkce" + happyDownstreamNonce = "test-nonce" + happyDownstreamStateVersion = "2" + + downstreamClientID = "pinniped-cli" + happyDownstreamState = "8b-state" + downstreamNonce = "some-nonce-value" + downstreamPKCEChallenge = "some-challenge" + downstreamPKCEChallengeMethod = "S256" + downstreamRedirectURI = "http://127.0.0.1/callback" + ) + + happyDownstreamScopesRequested := []string{"openid"} + happyDownstreamRequestParamsQuery := url.Values{ + "response_type": []string{"code"}, + "scope": []string{strings.Join(happyDownstreamScopesRequested, " ")}, + "client_id": []string{downstreamClientID}, + "state": []string{happyDownstreamState}, + "nonce": []string{downstreamNonce}, + "code_challenge": []string{downstreamPKCEChallenge}, + "code_challenge_method": []string{downstreamPKCEChallengeMethod}, + "redirect_uri": []string{downstreamRedirectURI}, + } + happyDownstreamRequestParams := happyDownstreamRequestParamsQuery.Encode() + + expectedHappyDecodedUpstreamStateParam := func() *oidc.UpstreamStateParamData { + return &oidc.UpstreamStateParamData{ + UpstreamName: happyUpstreamIDPName, + UpstreamType: happyUpstreamIDPType, + AuthParams: happyDownstreamRequestParams, + Nonce: happyDownstreamNonce, + CSRFToken: happyDownstreamCSRF, + PKCECode: happyDownstreamPKCE, + FormatVersion: happyDownstreamStateVersion, + } + } + + expectedHappyDecodedUpstreamStateParamForActiveDirectory := func() *oidc.UpstreamStateParamData { + s := expectedHappyDecodedUpstreamStateParam() + s.UpstreamType = "activedirectory" + return s + } + + happyUpstreamStateParam := func() *oidctestutil.UpstreamStateParamBuilder { + return &oidctestutil.UpstreamStateParamBuilder{ + U: happyUpstreamIDPName, + T: happyUpstreamIDPType, + P: happyDownstreamRequestParams, + N: happyDownstreamNonce, + C: happyDownstreamCSRF, + K: happyDownstreamPKCE, + V: happyDownstreamStateVersion, + } + } + + stateEncoderHashKey := []byte("fake-hash-secret") + stateEncoderBlockKey := []byte("0123456789ABCDEF") // block encryption requires 16/24/32 bytes for AES + cookieEncoderHashKey := []byte("fake-hash-secret2") + cookieEncoderBlockKey := []byte("0123456789ABCDE2") // block encryption requires 16/24/32 bytes for AES + require.NotEqual(t, stateEncoderHashKey, cookieEncoderHashKey) + require.NotEqual(t, stateEncoderBlockKey, cookieEncoderBlockKey) + + happyStateCodec := securecookie.New(stateEncoderHashKey, stateEncoderBlockKey) + happyStateCodec.SetSerializer(securecookie.JSONEncoder{}) + happyCookieCodec := securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey) + happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) + + happyState := happyUpstreamStateParam().Build(t, happyStateCodec) + happyPathWithState := newRequestPath().WithState(happyState).String() + + happyActiveDirectoryState := happyUpstreamStateParam().WithUpstreamIDPType("activedirectory").Build(t, happyStateCodec) + + encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) + require.NoError(t, err) + happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + + tests := []struct { + name string + method string + path string + csrfCookie string + getHandlerErr error + postHandlerErr error + + wantStatus int + wantContentType string + wantBody string + wantEncodedState string + wantDecodedState *oidc.UpstreamStateParamData + }{ + { + name: "PUT method is invalid", + method: http.MethodPut, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: PUT (try GET or POST)\n", + }, + { + name: "PATCH method is invalid", + method: http.MethodPatch, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: PATCH (try GET or POST)\n", + }, + { + name: "DELETE method is invalid", + method: http.MethodDelete, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: DELETE (try GET or POST)\n", + }, + { + name: "HEAD method is invalid", + method: http.MethodHead, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: HEAD (try GET or POST)\n", + }, + { + name: "CONNECT method is invalid", + method: http.MethodConnect, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: CONNECT (try GET or POST)\n", + }, + { + name: "OPTIONS method is invalid", + method: http.MethodOptions, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: OPTIONS (try GET or POST)\n", + }, + { + name: "TRACE method is invalid", + method: http.MethodTrace, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusMethodNotAllowed, + wantContentType: htmlContentType, + wantBody: "Method Not Allowed: TRACE (try GET or POST)\n", + }, + { + name: "state param was not included on GET request", + method: http.MethodGet, + path: newRequestPath().WithoutState().String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: state param not found\n", + }, + { + name: "state param was not included on POST request", + method: http.MethodPost, + path: newRequestPath().WithoutState().String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: state param not found\n", + }, + { + name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason on GET request", + method: http.MethodGet, + path: newRequestPath().WithState("this-will-not-decode").String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error reading state\n", + }, + { + name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason on POST request", + method: http.MethodPost, + path: newRequestPath().WithState("this-will-not-decode").String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error reading state\n", + }, + { + name: "the CSRF cookie does not exist on GET request", + method: http.MethodGet, + path: happyPathWithState, + csrfCookie: "", + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: CSRF cookie is missing\n", + }, + { + name: "the CSRF cookie does not exist on POST request", + method: http.MethodPost, + path: happyPathWithState, + csrfCookie: "", + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: CSRF cookie is missing\n", + }, + { + name: "the CSRF cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason on GET request", + method: http.MethodGet, + path: happyPathWithState, + csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: error reading CSRF cookie\n", + }, + { + name: "the CSRF cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason on POST request", + method: http.MethodPost, + path: happyPathWithState, + csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: error reading CSRF cookie\n", + }, + { + name: "cookie csrf value does not match state csrf value on GET request", + method: http.MethodGet, + path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: CSRF value does not match\n", + }, + { + name: "cookie csrf value does not match state csrf value on POST request", + method: http.MethodPost, + path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusForbidden, + wantContentType: htmlContentType, + wantBody: "Forbidden: CSRF value does not match\n", + }, + { + name: "GET request when upstream IDP type in state param is not supported by this endpoint", + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithUpstreamIDPType("oidc").Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: not a supported upstream IDP type for this endpoint: \"oidc\"\n", + }, + { + name: "POST request when upstream IDP type in state param is not supported by this endpoint", + method: http.MethodPost, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithUpstreamIDPType("oidc").Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: not a supported upstream IDP type for this endpoint: \"oidc\"\n", + }, + { + name: "valid GET request when GET endpoint handler returns an error", + method: http.MethodGet, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + getHandlerErr: httperr.Newf(http.StatusInternalServerError, "some get error"), + wantStatus: http.StatusInternalServerError, + wantContentType: htmlContentType, + wantBody: "Internal Server Error: some get error\n", + wantEncodedState: happyState, + wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + }, + { + name: "valid POST request when POST endpoint handler returns an error", + method: http.MethodPost, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + postHandlerErr: httperr.Newf(http.StatusInternalServerError, "some post error"), + wantStatus: http.StatusInternalServerError, + wantContentType: htmlContentType, + wantBody: "Internal Server Error: some post error\n", + wantEncodedState: happyState, + wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + }, + { + name: "happy GET request for LDAP upstream", + method: http.MethodGet, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: happyGetResult, + wantEncodedState: happyState, + wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + }, + { + name: "happy POST request for LDAP upstream", + method: http.MethodPost, + path: happyPathWithState, + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: happyPostResult, + wantEncodedState: happyState, + wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + }, + { + name: "happy GET request for ActiveDirectory upstream", + method: http.MethodGet, + path: newRequestPath().WithState(happyActiveDirectoryState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: happyGetResult, + wantEncodedState: happyActiveDirectoryState, + wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(), + }, + { + name: "happy POST request for ActiveDirectory upstream", + method: http.MethodPost, + path: newRequestPath().WithState(happyActiveDirectoryState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: happyPostResult, + wantEncodedState: happyActiveDirectoryState, + wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(), + }, + } + + for _, test := range tests { + tt := test + + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + if test.csrfCookie != "" { + req.Header.Set("Cookie", test.csrfCookie) + } + rsp := httptest.NewRecorder() + + testGetHandler := func( + w http.ResponseWriter, + r *http.Request, + encodedState string, + decodedState *oidc.UpstreamStateParamData, + ) error { + require.Equal(t, req, r) + require.Equal(t, rsp, w) + require.Equal(t, tt.wantEncodedState, encodedState) + require.Equal(t, tt.wantDecodedState, decodedState) + if tt.getHandlerErr == nil { + _, err := w.Write([]byte(happyGetResult)) + require.NoError(t, err) + } + return tt.getHandlerErr + } + + testPostHandler := func( + w http.ResponseWriter, + r *http.Request, + encodedState string, + decodedState *oidc.UpstreamStateParamData, + ) error { + require.Equal(t, req, r) + require.Equal(t, rsp, w) + require.Equal(t, tt.wantEncodedState, encodedState) + require.Equal(t, tt.wantDecodedState, decodedState) + if tt.postHandlerErr == nil { + _, err := w.Write([]byte(happyPostResult)) + require.NoError(t, err) + } + return tt.postHandlerErr + } + + subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler) + + subject.ServeHTTP(rsp, req) + + testutil.RequireSecurityHeaders(t, rsp) + + require.Equal(t, tt.wantStatus, rsp.Code) + testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) + require.Equal(t, tt.wantBody, rsp.Body.String()) + }) + } +} + +type requestPath struct { + state *string +} + +func newRequestPath() *requestPath { + return &requestPath{} +} + +func (r *requestPath) WithState(state string) *requestPath { + r.state = &state + return r +} + +func (r *requestPath) WithoutState() *requestPath { + r.state = nil + return r +} + +func (r *requestPath) String() string { + path := "/login?" + params := url.Values{} + if r.state != nil { + params.Add("state", *r.state) + } + return path + params.Encode() +} diff --git a/internal/oidc/login/post_login_handler.go b/internal/oidc/login/post_login_handler.go new file mode 100644 index 00000000..33819c69 --- /dev/null +++ b/internal/oidc/login/post_login_handler.go @@ -0,0 +1,19 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "net/http" + + "github.com/ory/fosite" + + "go.pinniped.dev/internal/oidc" +) + +func NewPostHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister, oauthHelper fosite.OAuth2Provider) HandlerFunc { + return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { + // TODO + return nil + } +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 9467eb22..90c47655 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -5,12 +5,15 @@ package oidc import ( + "crypto/subtle" + "net/http" "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" "github.com/ory/fosite/compose" + "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" @@ -297,3 +300,68 @@ func ScopeWasRequested(authorizeRequester fosite.AuthorizeRequester, scopeName s } return false } + +func ReadStateParamAndValidateCSRFCookie(r *http.Request, cookieDecoder Decoder, stateDecoder Decoder) (string, *UpstreamStateParamData, error) { + csrfValue, err := readCSRFCookie(r, cookieDecoder) + if err != nil { + return "", nil, err + } + + encodedState, decodedState, err := readStateParam(r, stateDecoder) + if err != nil { + return "", nil, err + } + + err = validateCSRFValue(decodedState, csrfValue) + if err != nil { + return "", nil, err + } + + return encodedState, decodedState, nil +} + +func readCSRFCookie(r *http.Request, cookieDecoder Decoder) (csrftoken.CSRFToken, error) { + receivedCSRFCookie, err := r.Cookie(CSRFCookieName) + if err != nil { + // Error means that the cookie was not found + return "", httperr.Wrap(http.StatusForbidden, "CSRF cookie is missing", err) + } + + var csrfFromCookie csrftoken.CSRFToken + err = cookieDecoder.Decode(CSRFCookieEncodingName, receivedCSRFCookie.Value, &csrfFromCookie) + if err != nil { + return "", httperr.Wrap(http.StatusForbidden, "error reading CSRF cookie", err) + } + + return csrfFromCookie, nil +} + +func readStateParam(r *http.Request, stateDecoder Decoder) (string, *UpstreamStateParamData, error) { + encodedState := r.FormValue("state") + + if encodedState == "" { + return "", nil, httperr.New(http.StatusBadRequest, "state param not found") + } + + var state UpstreamStateParamData + if err := stateDecoder.Decode( + UpstreamStateParamEncodingName, + r.FormValue("state"), + &state, + ); err != nil { + return "", nil, httperr.New(http.StatusBadRequest, "error reading state") + } + + if state.FormatVersion != UpstreamStateParamFormatVersion { + return "", nil, httperr.New(http.StatusUnprocessableEntity, "state format version is invalid") + } + + return encodedState, &state, nil +} + +func validateCSRFValue(state *UpstreamStateParamData, csrfCookieValue csrftoken.CSRFToken) error { + if subtle.ConstantTimeCompare([]byte(state.CSRFToken), []byte(csrfCookieValue)) != 1 { + return httperr.New(http.StatusForbidden, "CSRF value does not match") + } + return nil +} diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index dda7fa86..283b1808 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -136,7 +136,12 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs oauthHelperWithKubeStorage, ) - m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler() + m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler( + upstreamStateEncoder, + csrfCookieEncoder, + login.NewGetHandler(m.upstreamIDPs), + login.NewPostHandler(m.upstreamIDPs, oauthHelperWithKubeStorage), + ) plog.Debug("oidc provider manager added or updated issuer", "issuer", issuer) } diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 1936c406..c408ada9 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -15,6 +15,7 @@ import ( "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/securecookie" "github.com/ory/fosite" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -837,6 +838,44 @@ type ExpectedUpstreamStateParamFormat struct { V string `json:"v"` } +type UpstreamStateParamBuilder ExpectedUpstreamStateParamFormat + +func (b UpstreamStateParamBuilder) Build(t *testing.T, stateEncoder *securecookie.SecureCookie) string { + state, err := stateEncoder.Encode("s", b) + require.NoError(t, err) + return state +} + +func (b *UpstreamStateParamBuilder) WithAuthorizeRequestParams(params string) *UpstreamStateParamBuilder { + b.P = params + return b +} + +func (b *UpstreamStateParamBuilder) WithNonce(nonce string) *UpstreamStateParamBuilder { + b.N = nonce + return b +} + +func (b *UpstreamStateParamBuilder) WithCSRF(csrf string) *UpstreamStateParamBuilder { + b.C = csrf + return b +} + +func (b *UpstreamStateParamBuilder) WithPKCE(pkce string) *UpstreamStateParamBuilder { + b.K = pkce + return b +} + +func (b *UpstreamStateParamBuilder) WithUpstreamIDPType(upstreamIDPType string) *UpstreamStateParamBuilder { + b.T = upstreamIDPType + return b +} + +func (b *UpstreamStateParamBuilder) WithStateVersion(version string) *UpstreamStateParamBuilder { + b.V = version + return b +} + type staticKeySet struct { publicKey crypto.PublicKey } From 379a80350976c31763b786aeb4cbf914d998197f Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Tue, 26 Apr 2022 16:46:58 -0700 Subject: [PATCH 05/26] when password header but not username is sent to password grant, error also add more unit tests Signed-off-by: Margo Crawford --- internal/oidc/auth/auth_handler.go | 6 +- internal/oidc/auth/auth_handler_test.go | 109 +++++++++++++++++++++++- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 5b4d0bcb..d658100c 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -63,7 +63,8 @@ func NewHandler( } if idpType == psession.ProviderTypeOIDC { - if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 { + if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || + len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) } @@ -78,7 +79,8 @@ func NewHandler( } // we know it's an AD/LDAP upstream. - if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { + if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || + len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForLDAPUpstreamCLIFlow(r, w, oauthHelperWithStorage, diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 34e1f158..128d5d4f 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -576,6 +576,23 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPath, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", activeDirectoryUpstreamName, "activedirectory")}), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, { name: "OIDC upstream password grant happy path using GET", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -599,7 +616,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { - name: "LDAP upstream happy path using GET", + name: "LDAP cli upstream happy path using GET", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, @@ -620,7 +637,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { - name: "ActiveDirectory upstream happy path using GET", + name: "ActiveDirectory cli upstream happy path using GET", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, @@ -657,6 +674,40 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "LDAP upstream browser flow happy path using GET with a CSRF cookie", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPath, + csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ldapUpstreamName, "ldap")}), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, + { + name: "Active Directory upstream browser flow happy path using GET with a CSRF cookie", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPath, + csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, incomingCookieCSRFValue, activeDirectoryUpstreamName, "activedirectory")}), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, { name: "OIDC upstream browser flow happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), @@ -676,6 +727,44 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "LDAP upstream browser flow happy path using POST", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodPost, + path: "/some/path", + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + wantStatus: http.StatusSeeOther, + wantContentType: "", + wantBodyString: "", + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", ldapUpstreamName, "ldap")}), + wantUpstreamStateParamInLocationHeader: true, + }, + { + name: "Active Directory upstream browser flow happy path using POST", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodPost, + path: "/some/path", + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + wantStatus: http.StatusSeeOther, + wantContentType: "", + wantBodyString: "", + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", activeDirectoryUpstreamName, "activedirectory")}), + wantUpstreamStateParamInLocationHeader: true, + }, { name: "OIDC upstream password grant happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -701,7 +790,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { - name: "LDAP upstream happy path using POST", + name: "LDAP cli upstream happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodPost, path: "/some/path", @@ -724,7 +813,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { - name: "Active Directory upstream happy path using POST", + name: "Active Directory cli upstream happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodPost, path: "/some/path", @@ -1076,6 +1165,18 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), wantBodyString: "", }, + { + name: "missing upstream username but has password on request for OIDC password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: nil, // do not send header + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), + wantBodyString: "", + }, { name: "missing upstream username but has password on request for LDAP authentication", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), From ae60d4356b28e78a7d275776321405342f9f9b83 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Wed, 27 Apr 2022 08:51:37 -0700 Subject: [PATCH 06/26] Some refactoring of shared code between OIDC and LDAP browser flows Signed-off-by: Margo Crawford --- internal/oidc/auth/auth_handler.go | 203 ++++++++++++++--------------- 1 file changed, 100 insertions(+), 103 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index d658100c..5f74b8bd 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -172,62 +172,23 @@ func handleAuthRequestForLDAPUpstreamBrowserFlow( upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, ) error { - authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false) - if !created { - return nil - } - - now := time.Now() - _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{ - Fosite: &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. - Subject: "none", - AuthTime: now, - RequestedAt: now, - }, - }, - }) - if err != nil { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false) - } - - csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) - if err != nil { - plog.Error("authorize generate error", err) - return err - } - csrfFromCookie := readCSRFCookie(r, cookieCodec) - if csrfFromCookie != "" { - csrfValue = csrfFromCookie - } - - encodedStateParamValue, err := upstreamStateParam( - authorizeRequester, + encodedStateParamValue, _, _, err := handleBrowserAuthRequest( + r, + w, + oauthHelper, + generateCSRF, + generateNonce, + generatePKCE, ldapUpstream.GetName(), - string(idpType), - nonceValue, - csrfValue, - pkceValue, + idpType, + cookieCodec, upstreamStateEncoder, ) if err != nil { - plog.Error("authorize upstream state param error", err) return err } - - promptParam := r.Form.Get(promptParamName) - if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) - } - - if csrfFromCookie == "" { - // We did not receive an incoming CSRF cookie, so write a new one. - err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec) - if err != nil { - plog.Error("error setting CSRF cookie", err) - return err - } + if encodedStateParamValue == "" { + return nil } loginURL, err := url.Parse(downstreamIssuer + "/login") @@ -312,34 +273,23 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, ) error { - authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false) - if !created { - return nil - } - - now := time.Now() - _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{ - Fosite: &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. - Subject: "none", - AuthTime: now, - RequestedAt: now, - }, - }, - }) + encodedStateParamValue, pkceValue, nonceValue, err := handleBrowserAuthRequest( + r, + w, + oauthHelper, + generateCSRF, + generateNonce, + generatePKCE, + oidcUpstream.GetName(), + psession.ProviderTypeOIDC, + cookieCodec, + upstreamStateEncoder, + ) if err != nil { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false) - } - - csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) - if err != nil { - plog.Error("authorize generate error", err) return err } - csrfFromCookie := readCSRFCookie(r, cookieCodec) - if csrfFromCookie != "" { - csrfValue = csrfFromCookie + if encodedStateParamValue == "" { + return nil } upstreamOAuthConfig := oauth2.Config{ @@ -351,44 +301,16 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( Scopes: oidcUpstream.GetScopes(), } - encodedStateParamValue, err := upstreamStateParam( - authorizeRequester, - oidcUpstream.GetName(), - string(psession.ProviderTypeOIDC), - nonceValue, - csrfValue, - pkceValue, - upstreamStateEncoder, - ) - if err != nil { - plog.Error("authorize upstream state param error", err) - return err - } - authCodeOptions := []oauth2.AuthCodeOption{ nonceValue.Param(), pkceValue.Challenge(), pkceValue.Method(), } - promptParam := r.Form.Get(promptParamName) - if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) - } - for key, val := range oidcUpstream.GetAdditionalAuthcodeParams() { authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam(key, val)) } - if csrfFromCookie == "" { - // We did not receive an incoming CSRF cookie, so write a new one. - err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec) - if err != nil { - plog.Error("error setting CSRF cookie", err) - return err - } - } - http.Redirect(w, r, upstreamOAuthConfig.AuthCodeURL( encodedStateParamValue, @@ -549,6 +471,81 @@ func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider } } +// handleBrowserAuthRequest performs the shared validations and setup between browser based auth requests +// regardless of IDP type-- LDAP, Active Directory and OIDC. +// It generates the state param, sets the CSRF cookie, and validates the prompt param. +func handleBrowserAuthRequest( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + generateCSRF func() (csrftoken.CSRFToken, error), + generateNonce func() (nonce.Nonce, error), + generatePKCE func() (pkce.Code, error), + upstreamName string, + idpType psession.ProviderType, + cookieCodec oidc.Codec, + upstreamStateEncoder oidc.Encoder, +) (string, pkce.Code, nonce.Nonce, error) { + authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false) + if !created { + return "", "", "", nil + } + + now := time.Now() + _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. + Subject: "none", + AuthTime: now, + RequestedAt: now, + }, + }, + }) + if err != nil { + return "", "", "", writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false) + } + + csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) + if err != nil { + plog.Error("authorize generate error", err) + return "", "", "", err + } + csrfFromCookie := readCSRFCookie(r, cookieCodec) + if csrfFromCookie != "" { + csrfValue = csrfFromCookie + } + + encodedStateParamValue, err := upstreamStateParam( + authorizeRequester, + upstreamName, + string(idpType), + nonceValue, + csrfValue, + pkceValue, + upstreamStateEncoder, + ) + if err != nil { + plog.Error("authorize upstream state param error", err) + return "", "", "", err + } + + promptParam := r.Form.Get(promptParamName) + if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { + return "", "", "", writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) + } + + if csrfFromCookie == "" { + // We did not receive an incoming CSRF cookie, so write a new one. + err = addCSRFSetCookieHeader(w, csrfValue, cookieCodec) + if err != nil { + plog.Error("error setting CSRF cookie", err) + return "", "", "", err + } + } + return encodedStateParamValue, pkceValue, nonceValue, nil +} + func generateValues( generateCSRF func() (csrftoken.CSRFToken, error), generateNonce func() (nonce.Nonce, error), From 77f016fb64d91446aafcae57d9e6bd3db3d40ff5 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Wed, 27 Apr 2022 08:53:53 -0700 Subject: [PATCH 07/26] Allow browser_authcode flow for pinniped login command Signed-off-by: Margo Crawford --- cmd/pinniped/cmd/login_oidc.go | 6 +++--- cmd/pinniped/cmd/login_oidc_test.go | 32 +++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index c8b2b0cc..bf35a6ba 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -271,11 +271,11 @@ func flowOptions(requestedIDPType idpdiscoveryv1alpha1.IDPType, requestedFlow id case idpdiscoveryv1alpha1.IDPFlowCLIPassword, "": return useCLIFlow, nil case idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode: - fallthrough // not supported for LDAP providers, so fallthrough to error case + return nil, 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{idpdiscoveryv1alpha1.IDPFlowCLIPassword.String()}) + requestedIDPType, requestedFlow, strings.Join([]string{idpdiscoveryv1alpha1.IDPFlowCLIPassword.String(), idpdiscoveryv1alpha1.IDPFlowBrowserAuthcode.String()}, ", ")) } default: // Surprisingly cobra does not support this kind of flag validation. See https://github.com/spf13/pflag/issues/236 diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 4a384c76..da0cfcb7 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -235,18 +235,30 @@ func TestLoginOIDCCommand(t *testing.T) { wantOptionsCount: 5, 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 browser_authcode flow is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "ldap", + "--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":{"interactive":false},"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 + "--upstream-identity-provider-flow", "foo", "--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]) + Error: --upstream-identity-provider-flow value not recognized for identity provider type "ldap": foo (supported values: cli_password, browser_authcode) `), }, { @@ -261,18 +273,30 @@ func TestLoginOIDCCommand(t *testing.T) { wantOptionsCount: 5, 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: "active directory upstream type with browser_authcode is allowed", + args: []string{ + "--issuer", "test-issuer", + "--client-id", "test-client-id", + "--upstream-identity-provider-type", "activedirectory", + "--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":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", + }, { name: "active directory upstream type with unsupported flow is an error", args: []string{ "--issuer", "test-issuer", "--client-id", "test-client-id", "--upstream-identity-provider-type", "activedirectory", - "--upstream-identity-provider-flow", "browser_authcode", // "browser_authcode" is only supported for OIDC upstreams + "--upstream-identity-provider-flow", "foo", "--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 "activedirectory": browser_authcode (supported values: [cli_password]) + Error: --upstream-identity-provider-flow value not recognized for identity provider type "activedirectory": foo (supported values: cli_password, browser_authcode) `), }, { From 07b2306254fef878f0cb566a340a134f674fb397 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Thu, 28 Apr 2022 09:11:51 -0700 Subject: [PATCH 08/26] Add basic outline of login get handler Signed-off-by: Margo Crawford --- internal/oidc/login/get_login_handler.go | 21 ++++- internal/oidc/login/get_login_handler_test.go | 81 +++++++++++++++++++ internal/oidc/login/login_form.gohtml | 32 ++++++++ internal/oidc/login/login_handler_test.go | 5 +- test/integration/e2e_test.go | 1 + 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 internal/oidc/login/get_login_handler_test.go create mode 100644 internal/oidc/login/login_form.gohtml diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go index e1d6ffb6..c7bc335e 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/oidc/login/get_login_handler.go @@ -4,14 +4,33 @@ package login import ( + _ "embed" + "html/template" "net/http" "go.pinniped.dev/internal/oidc" ) +var ( + //go:embed login_form.gohtml + rawHTMLTemplate string +) + +var parsedHTMLTemplate = template.Must(template.New("login_post.gohtml").Parse(rawHTMLTemplate)) + +type PageData struct { + State string + IDPName string +} + func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc { return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { - // TODO + + err := parsedHTMLTemplate.Execute(w, &PageData{State: encodedState, IDPName: decodedState.UpstreamName}) + if err != nil { + return err + } + return nil } } diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go new file mode 100644 index 00000000..cbb48103 --- /dev/null +++ b/internal/oidc/login/get_login_handler_test.go @@ -0,0 +1,81 @@ +package login + +import ( + "net/http" + "net/http/httptest" + "testing" + + "go.pinniped.dev/internal/testutil" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/oidc" +) + +func TestGetLogin(t *testing.T) { + const ( + happyLdapIDPName = "some-ldap-idp" + happyGetResult = ` + + + +

Pinniped

+

some-ldap-idp

+ +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +` + ) + tests := []struct { + name string + decodedState *oidc.UpstreamStateParamData + encodedState string + idps oidc.UpstreamIdentityProvidersLister + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Happy path ldap", + decodedState: &oidc.UpstreamStateParamData{ + UpstreamName: happyLdapIDPName, + UpstreamType: "ldap", + }, + encodedState: "foo", // the encoded and decoded state don't match, but that verification is handled one level up. + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: happyGetResult, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + handler := NewGetHandler(tt.idps) + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rsp := httptest.NewRecorder() + err := handler(rsp, req, tt.encodedState, tt.decodedState) + require.NoError(t, err) + + require.Equal(t, test.wantStatus, rsp.Code) + testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) + body := rsp.Body.String() + require.Equal(t, tt.wantBody, body) + }) + } +} diff --git a/internal/oidc/login/login_form.gohtml b/internal/oidc/login/login_form.gohtml new file mode 100644 index 00000000..adb5c2a9 --- /dev/null +++ b/internal/oidc/login/login_form.gohtml @@ -0,0 +1,32 @@ + + + + +

Pinniped

+

{{ .IDPName }}

+ +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+ + + diff --git a/internal/oidc/login/login_handler_test.go b/internal/oidc/login/login_handler_test.go index c77758da..347f0760 100644 --- a/internal/oidc/login/login_handler_test.go +++ b/internal/oidc/login/login_handler_test.go @@ -19,9 +19,12 @@ import ( "go.pinniped.dev/internal/testutil/oidctestutil" ) +const ( + htmlContentType = "text/html; charset=utf-8" +) + func TestLoginEndpoint(t *testing.T) { const ( - htmlContentType = "text/html; charset=utf-8" happyGetResult = "

get handler result

" happyPostResult = "

post handler result

" diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index d0cb858b..13a70757 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -1078,6 +1078,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo regex := regexp.MustCompile(`\A` + downstream.Spec.Issuer + `/login.+`) browsertest.WaitForURL(t, page, regex) + browsertest.WaitForVisibleElements(t, page, "input#username", "input#password", "button#submit") // TODO actually log in :P }) } From 453c69af7d6b321f72cc739750142eac6fbae06b Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Thu, 28 Apr 2022 12:07:04 -0700 Subject: [PATCH 09/26] Fix some errors and pass state as form element Signed-off-by: Margo Crawford --- internal/oidc/login/get_login_handler.go | 4 +--- internal/oidc/login/get_login_handler_test.go | 19 +++++++++++++------ internal/oidc/login/login_form.gohtml | 8 ++++---- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go index c7bc335e..18b3ba8c 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/oidc/login/get_login_handler.go @@ -16,16 +16,14 @@ var ( rawHTMLTemplate string ) -var parsedHTMLTemplate = template.Must(template.New("login_post.gohtml").Parse(rawHTMLTemplate)) - type PageData struct { State string IDPName string } func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc { + var parsedHTMLTemplate = template.Must(template.New("login_post.gohtml").Parse(rawHTMLTemplate)) return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { - err := parsedHTMLTemplate.Execute(w, &PageData{State: encodedState, IDPName: decodedState.UpstreamName}) if err != nil { return err diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go index cbb48103..97ebf990 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/oidc/login/get_login_handler_test.go @@ -1,3 +1,6 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + package login import ( @@ -22,19 +25,23 @@ func TestGetLogin(t *testing.T) {

Pinniped

some-ldap-idp

-
+
- - + +
- - + +
- +
+ +
+ +
diff --git a/internal/oidc/login/login_form.gohtml b/internal/oidc/login/login_form.gohtml index adb5c2a9..7bc61878 100644 --- a/internal/oidc/login/login_form.gohtml +++ b/internal/oidc/login/login_form.gohtml @@ -8,15 +8,15 @@ SPDX-License-Identifier: Apache-2.0

Pinniped

{{ .IDPName }}

-
+
- +
- +
@@ -24,7 +24,7 @@ SPDX-License-Identifier: Apache-2.0 - +
From 646c6ec9ed18e5543f31219b3879779444ca98dc Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Fri, 29 Apr 2022 10:36:13 -0700 Subject: [PATCH 10/26] Show error message on login page Also add autocomplete attribute and title element Signed-off-by: Margo Crawford --- internal/oidc/login/get_login_handler.go | 26 +++- internal/oidc/login/get_login_handler_test.go | 127 +++++++++++++----- internal/oidc/login/login_form.gohtml | 15 ++- 3 files changed, 130 insertions(+), 38 deletions(-) diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go index 18b3ba8c..a8f90216 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/oidc/login/get_login_handler.go @@ -11,20 +11,40 @@ import ( "go.pinniped.dev/internal/oidc" ) +const defaultErrorMessage = "An internal error occurred. Please contact your administrator for help." + var ( //go:embed login_form.gohtml rawHTMLTemplate string + + errorMappings = map[string]string{ + "login_error": "Incorrect username or password.", + } ) type PageData struct { - State string - IDPName string + State string + IDPName string + HasAlertError bool + AlertMessage string + Title string } func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc { var parsedHTMLTemplate = template.Must(template.New("login_post.gohtml").Parse(rawHTMLTemplate)) return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { - err := parsedHTMLTemplate.Execute(w, &PageData{State: encodedState, IDPName: decodedState.UpstreamName}) + alertError := r.URL.Query().Get("err") + message := errorMappings[alertError] + if message == "" { + message = defaultErrorMessage + } + err := parsedHTMLTemplate.Execute(w, &PageData{ + State: encodedState, + IDPName: decodedState.UpstreamName, + HasAlertError: alertError != "", + AlertMessage: message, + Title: "Pinniped", + }) if err != nil { return err } diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go index 97ebf990..7e4d8c56 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/oidc/login/get_login_handler_test.go @@ -4,6 +4,7 @@ package login import ( + "fmt" "net/http" "net/http/httptest" "testing" @@ -18,40 +19,13 @@ import ( func TestGetLogin(t *testing.T) { const ( happyLdapIDPName = "some-ldap-idp" - happyGetResult = ` - - - -

Pinniped

-

some-ldap-idp

- -
- -
- - -
- -
- - -
- -
- -
- - - -
- - -` ) + tests := []struct { name string decodedState *oidc.UpstreamStateParamData encodedState string + errParam string idps oidc.UpstreamIdentityProvidersLister wantStatus int wantContentType string @@ -66,7 +40,57 @@ func TestGetLogin(t *testing.T) { encodedState: "foo", // the encoded and decoded state don't match, but that verification is handled one level up. wantStatus: http.StatusOK, wantContentType: htmlContentType, - wantBody: happyGetResult, + wantBody: getHTMLResult(""), + }, + { + name: "displays error banner when err=login_error param is sent", + decodedState: &oidc.UpstreamStateParamData{ + UpstreamName: happyLdapIDPName, + UpstreamType: "ldap", + }, + encodedState: "foo", + errParam: "login_error", + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: getHTMLResult(` +
+ Incorrect username or password. +
+`), + }, + { + name: "displays error banner when err=internal_error param is sent", + decodedState: &oidc.UpstreamStateParamData{ + UpstreamName: happyLdapIDPName, + UpstreamType: "ldap", + }, + encodedState: "foo", + errParam: "internal_error", + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: getHTMLResult(` +
+ An internal error occurred. Please contact your administrator for help. +
+`), + }, + // If we get an error that we don't recognize, that's also an error, so we + // should probably just tell you to contact your administrator... + { + name: "displays generic error banner when unrecognized err param is sent", + decodedState: &oidc.UpstreamStateParamData{ + UpstreamName: happyLdapIDPName, + UpstreamType: "ldap", + }, + encodedState: "foo", + errParam: "some_other_error", + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: getHTMLResult(` +
+ An internal error occurred. Please contact your administrator for help. +
+`), }, } @@ -74,7 +98,11 @@ func TestGetLogin(t *testing.T) { tt := test t.Run(tt.name, func(t *testing.T) { handler := NewGetHandler(tt.idps) - req := httptest.NewRequest(http.MethodGet, "/login", nil) + target := "/login?state=" + tt.encodedState + if tt.errParam != "" { + target += "&err=" + tt.errParam + } + req := httptest.NewRequest(http.MethodGet, target, nil) rsp := httptest.NewRecorder() err := handler(rsp, req, tt.encodedState, tt.decodedState) require.NoError(t, err) @@ -86,3 +114,40 @@ func TestGetLogin(t *testing.T) { }) } } + +func getHTMLResult(errorBanner string) string { + happyGetResult := ` + + + Pinniped + + + +

Pinniped

+

some-ldap-idp

+%s +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+ + + +` + return fmt.Sprintf(happyGetResult, errorBanner) +} diff --git a/internal/oidc/login/login_form.gohtml b/internal/oidc/login/login_form.gohtml index 7bc61878..5376b5e4 100644 --- a/internal/oidc/login/login_form.gohtml +++ b/internal/oidc/login/login_form.gohtml @@ -3,28 +3,35 @@ Copyright 2022 the Pinniped contributors. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 --> + + {{.Title}} +

Pinniped

{{ .IDPName }}

- +{{if .HasAlertError}} +
+ {{.AlertMessage}} +
+{{end}}
- +
- +
- +
From 69e5169fc52fd3cd394834d3f52371607bfce34d Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 29 Apr 2022 16:01:51 -0700 Subject: [PATCH 11/26] Implement post_login_handler.go to accept form post and auth to LDAP/AD Also extract some helpers from auth_handler.go so they can be shared with the new handler. --- internal/oidc/auth/auth_handler.go | 154 +--- internal/oidc/auth/auth_handler_test.go | 176 ++--- .../downstreamsession/downstream_session.go | 34 + internal/oidc/login/login_handler.go | 41 ++ internal/oidc/login/post_login_handler.go | 73 +- .../oidc/login/post_login_handler_test.go | 693 ++++++++++++++++++ internal/oidc/oidc.go | 110 +++ internal/oidc/provider/manager/manager.go | 2 +- 8 files changed, 1069 insertions(+), 214 deletions(-) create mode 100644 internal/oidc/login/post_login_handler_test.go diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 5f74b8bd..0c3df1e8 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -7,24 +7,21 @@ package auth import ( "fmt" "net/http" - "net/url" "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" - "github.com/felixge/httpsnoop" "github.com/ory/fosite" "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" - "github.com/pkg/errors" "golang.org/x/oauth2" supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc" - "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/downstreamsession" + "go.pinniped.dev/internal/oidc/login" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" @@ -127,36 +124,19 @@ func handleAuthRequestForLDAPUpstreamCLIFlow( return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication") } if !authenticated { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider."), true) + return nil } - subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) + subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) username = authenticateResponse.User.GetName() groups := authenticateResponse.User.GetGroups() - dn := authenticateResponse.DN + customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse) + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) + oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true) - customSessionData := &psession.CustomSessionData{ - ProviderUID: ldapUpstream.GetResourceUID(), - ProviderName: ldapUpstream.GetName(), - ProviderType: idpType, - } - - if idpType == psession.ProviderTypeLDAP { - customSessionData.LDAP = &psession.LDAPSessionData{ - UserDN: dn, - ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes, - } - } - if idpType == psession.ProviderTypeActiveDirectory { - customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{ - UserDN: dn, - ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes, - } - } - - return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, - oauthHelper, authorizeRequester, subject, username, groups, customSessionData) + return nil } func handleAuthRequestForLDAPUpstreamBrowserFlow( @@ -191,20 +171,7 @@ func handleAuthRequestForLDAPUpstreamBrowserFlow( return nil } - loginURL, err := url.Parse(downstreamIssuer + "/login") - if err != nil { - return err - } - q := loginURL.Query() - q.Set("state", encodedStateParamValue) - loginURL.RawQuery = q.Encode() - - http.Redirect(w, r, - loginURL.String(), - http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 - ) - - return nil + return login.RedirectToLoginPage(r, w, downstreamIssuer, encodedStateParamValue, login.ShowNoError) } func handleAuthRequestForOIDCUpstreamPasswordGrant( @@ -225,9 +192,10 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( if !oidcUpstream.AllowsPasswordGrant() { // Return a user-friendly error for this case which is entirely within our control. - return writeAuthorizeError(w, oauthHelper, authorizeRequester, + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHint( "Resource owner password credentials grant is not allowed for this upstream provider according to its configuration."), true) + return nil } token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) @@ -239,26 +207,33 @@ 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 writeAuthorizeError(w, oauthHelper, authorizeRequester, + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithDebug(err.Error()), true) // WithDebug hides the error from the client + return nil } subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) if err != nil { // Return a user-friendly error for this case which is entirely within our control. - return writeAuthorizeError(w, oauthHelper, authorizeRequester, + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true, ) + return nil } customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token) if err != nil { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true, ) + return nil } - return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups, customSessionData) + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) + + oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true) + + return nil } func handleAuthRequestForOIDCUpstreamAuthcodeGrant( @@ -322,78 +297,11 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( return nil } -func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error, isBrowserless bool) error { - 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)...) - } - if isBrowserless { - w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w) - } - // 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, - oauthHelper fosite.OAuth2Provider, - authorizeRequester fosite.AuthorizeRequester, - subject string, - username string, - groups []string, - customSessionData *psession.CustomSessionData, -) error { - openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) - - authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) - if err != nil { - return writeAuthorizeError(w, oauthHelper, authorizeRequester, err, true) - } - - w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w) - oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) - - return nil -} - -func rewriteStatusSeeOtherToStatusFoundForBrowserless(w http.ResponseWriter) http.ResponseWriter { - // rewrite http.StatusSeeOther to http.StatusFound for backwards compatibility with old pinniped CLIs. - // we can drop this in a few releases once we feel enough time has passed for users to update. - // - // WriteAuthorizeResponse/WriteAuthorizeError calls used to result in http.StatusFound until - // https://github.com/ory/fosite/pull/636 changed it to http.StatusSeeOther to address - // https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 - // Safari has the bad behavior in the case of http.StatusFound and not just http.StatusTemporaryRedirect. - // - // in the browserless flows, the OAuth client is the pinniped CLI and it already has access to the user's - // password. Thus there is no security issue with using http.StatusFound vs. http.StatusSeeOther. - return httpsnoop.Wrap(w, httpsnoop.Hooks{ - WriteHeader: func(delegate httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc { - return func(code int) { - if code == http.StatusSeeOther { - code = http.StatusFound - } - delegate(code) - } - }, - }) -} - func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) if username == "" || password == "" { - _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."), true) return "", "", false } @@ -403,7 +311,7 @@ func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseW func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, isBrowserless bool) (fosite.AuthorizeRequester, bool) { authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r) if err != nil { - _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err, isBrowserless) + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, err, isBrowserless) return nil, false } @@ -435,7 +343,8 @@ func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken { return csrfFromCookie } -// Select either an OIDC, an LDAP or an AD IDP, or return an error. +// chooseUpstreamIDP selects either an OIDC, an LDAP, or an AD IDP, or returns an error. +// Note that AD and LDAP IDPs both return the same interface type, but different ProviderTypes values. func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, provider.UpstreamLDAPIdentityProviderI, psession.ProviderType, error) { oidcUpstreams := idpLister.GetOIDCIdentityProviders() ldapUpstreams := idpLister.GetLDAPIdentityProviders() @@ -503,7 +412,8 @@ func handleBrowserAuthRequest( }, }) if err != nil { - return "", "", "", writeAuthorizeError(w, oauthHelper, authorizeRequester, err, false) + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, err, false) + return "", "", "", nil } csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) @@ -532,7 +442,8 @@ func handleBrowserAuthRequest( promptParam := r.Form.Get(promptParamName) if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { - return "", "", "", writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) + return "", "", "", nil } if csrfFromCookie == "" { @@ -608,8 +519,3 @@ func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken return nil } - -func downstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticators.Response) string { - ldapURL := *ldapUpstream.GetURL() - return downstreamsession.DownstreamLDAPSubject(authenticateResponse.User.GetUID(), ldapURL) -} diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 128d5d4f..fc0cbc53 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -70,6 +70,8 @@ func TestAuthorizationEndpoint(t *testing.T) { downstreamClientID = "pinniped-cli" upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev" htmlContentType = "text/html; charset=utf-8" + jsonContentType = "application/json; charset=utf-8" + formContentType = "application/x-www-form-urlencoded" ) require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -718,7 +720,7 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodPost, path: "/some/path", - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: "", @@ -737,7 +739,7 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodPost, path: "/some/path", - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: "", @@ -756,7 +758,7 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodPost, path: "/some/path", - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: "", @@ -770,7 +772,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodPost, path: "/some/path", - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), @@ -794,7 +796,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), method: http.MethodPost, path: "/some/path", - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), @@ -817,7 +819,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodPost, path: "/some/path", - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), @@ -845,7 +847,7 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -864,7 +866,7 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -883,10 +885,10 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}), - contentType: "application/x-www-form-urlencoded", + contentType: formContentType, body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), wantBodyString: "", }, @@ -1113,7 +1115,7 @@ func TestAuthorizationEndpoint(t *testing.T) { Password: "wrong-password", }}, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedErrorQuery), wantBodyString: "", }, @@ -1125,7 +1127,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr("wrong-password"), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1137,7 +1139,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr("wrong-password"), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1149,7 +1151,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr("wrong-username"), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1161,7 +1163,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr("wrong-username"), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1173,7 +1175,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: nil, // do not send header customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1185,7 +1187,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: nil, // do not send header customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1197,7 +1199,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: nil, // do not send header customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1209,7 +1211,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: nil, // do not send header wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1221,7 +1223,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: nil, // do not send header wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1234,7 +1236,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUserInfoEndpointErrorQuery), wantBodyString: "", }, @@ -1247,7 +1249,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUserInfoEndpointErrorQuery), wantBodyString: "", }, @@ -1260,7 +1262,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery), wantBodyString: "", }, @@ -1273,7 +1275,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery), wantBodyString: "", }, @@ -1286,7 +1288,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery), wantBodyString: "", }, @@ -1299,7 +1301,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingAccessTokenErrorQuery), wantBodyString: "", }, @@ -1311,7 +1313,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: nil, // do not send header wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, @@ -1323,7 +1325,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithPasswordGrantDisallowedHintErrorQuery), wantBodyString: "", }, @@ -1340,7 +1342,7 @@ func TestAuthorizationEndpoint(t *testing.T) { "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", }), wantStatus: http.StatusBadRequest, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { @@ -1353,7 +1355,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusBadRequest, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { @@ -1366,7 +1368,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusBadRequest, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { @@ -1379,7 +1381,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusBadRequest, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, { @@ -1393,7 +1395,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1404,7 +1406,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1413,7 +1415,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1422,7 +1424,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1436,7 +1438,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, @@ -1448,7 +1450,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, @@ -1460,7 +1462,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, @@ -1470,7 +1472,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, @@ -1482,7 +1484,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, @@ -1492,7 +1494,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, @@ -1507,7 +1509,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantBodyString: "", }, @@ -1519,7 +1521,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantBodyString: "", }, @@ -1531,7 +1533,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantBodyString: "", }, @@ -1543,7 +1545,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantBodyString: "", }, @@ -1558,7 +1560,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, @@ -1570,7 +1572,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, @@ -1582,7 +1584,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, @@ -1592,7 +1594,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, @@ -1604,7 +1606,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, @@ -1614,7 +1616,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingResponseTypeErrorQuery), wantBodyString: "", }, @@ -1629,7 +1631,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": ""}), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1640,7 +1642,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1649,7 +1651,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": ""}), wantStatus: http.StatusUnauthorized, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { @@ -1663,7 +1665,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), wantBodyString: "", }, @@ -1676,7 +1678,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1689,7 +1691,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1705,7 +1707,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), wantBodyString: "", }, @@ -1718,7 +1720,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1731,7 +1733,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1747,7 +1749,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", }, @@ -1760,7 +1762,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1773,7 +1775,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1789,7 +1791,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", }, @@ -1802,7 +1804,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1815,7 +1817,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error @@ -1833,7 +1835,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), wantBodyString: "", }, @@ -1848,7 +1850,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 1, // fosite already stored the authcode before it noticed the error @@ -1863,7 +1865,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), wantBodyString: "", wantUnnecessaryStoredRecords: 1, // fosite already stored the authcode before it noticed the error @@ -2052,7 +2054,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery), wantBodyString: "", }, @@ -2070,7 +2072,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithFalseEmailVerifiedHintErrorQuery), wantBodyString: "", }, @@ -2159,7 +2161,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), wantBodyString: "", }, @@ -2198,7 +2200,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), wantBodyString: "", }, @@ -2213,7 +2215,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), wantBodyString: "", }, @@ -2228,7 +2230,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), wantBodyString: "", }, @@ -2243,7 +2245,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), wantBodyString: "", }, @@ -2258,7 +2260,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), wantBodyString: "", }, @@ -2273,7 +2275,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), wantBodyString: "", }, @@ -2288,7 +2290,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), wantBodyString: "", }, @@ -2303,7 +2305,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), wantBodyString: "", }, @@ -2318,7 +2320,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), wantBodyString: "", }, @@ -2333,7 +2335,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), wantBodyString: "", }, @@ -2348,7 +2350,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), wantBodyString: "", }, @@ -2363,7 +2365,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"state": "short"}), wantStatus: http.StatusSeeOther, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), wantBodyString: "", }, @@ -2375,7 +2377,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), wantBodyString: "", }, @@ -2387,7 +2389,7 @@ func TestAuthorizationEndpoint(t *testing.T) { customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, - wantContentType: "application/json; charset=utf-8", + wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidStateErrorQuery), wantBodyString: "", }, diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index 195aae00..2343c833 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -16,6 +16,7 @@ import ( "github.com/ory/fosite/token/jwt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/provider" @@ -61,6 +62,34 @@ func MakeDownstreamSession(subject string, username string, groups []string, cus return openIDSession } +func MakeDownstreamLDAPOrADCustomSessionData( + ldapUpstream provider.UpstreamLDAPIdentityProviderI, + idpType psession.ProviderType, + authenticateResponse *authenticators.Response, +) *psession.CustomSessionData { + customSessionData := &psession.CustomSessionData{ + ProviderUID: ldapUpstream.GetResourceUID(), + ProviderName: ldapUpstream.GetName(), + ProviderType: idpType, + } + + if idpType == psession.ProviderTypeLDAP { + customSessionData.LDAP = &psession.LDAPSessionData{ + UserDN: authenticateResponse.DN, + ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes, + } + } + + if idpType == psession.ProviderTypeActiveDirectory { + customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{ + UserDN: authenticateResponse.DN, + ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes, + } + } + + return customSessionData +} + func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdentityProviderI, token *oidctypes.Token) (*psession.CustomSessionData, error) { upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, oidcUpstream.GetName(), token.IDToken.Claims) if err != nil { @@ -228,6 +257,11 @@ func ExtractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl return valueAsString, nil } +func DownstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticators.Response) string { + ldapURL := *ldapUpstream.GetURL() + return DownstreamLDAPSubject(authenticateResponse.User.GetUID(), ldapURL) +} + func DownstreamLDAPSubject(uid string, ldapURL url.URL) string { q := ldapURL.Query() q.Set(oidc.IDTokenSubjectClaim, uid) diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go index a8e65e0e..751dc9c4 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/oidc/login/login_handler.go @@ -5,6 +5,7 @@ package login import ( "net/http" + "net/url" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/httputil/httperr" @@ -13,6 +14,19 @@ import ( "go.pinniped.dev/internal/plog" ) +type ErrorParamValue string + +const ( + usernameParamName = "username" + passwordParamName = "password" + stateParamName = "state" + errParamName = "err" + + ShowNoError ErrorParamValue = "" + ShowInternalError ErrorParamValue = "internal_error" + ShowBadUserPassErr ErrorParamValue = "login_error" +) + // HandlerFunc is a function that can handle either a GET or POST request for the login endpoint. type HandlerFunc func( w http.ResponseWriter, @@ -66,3 +80,30 @@ func NewHandler( return securityheader.Wrap(loginHandler) } + +func RedirectToLoginPage( + r *http.Request, + w http.ResponseWriter, + downstreamIssuer string, + encodedStateParamValue string, + errToDisplay ErrorParamValue, +) error { + loginURL, err := url.Parse(downstreamIssuer + oidc.PinnipedLoginPath) + if err != nil { + return err + } + + q := loginURL.Query() + q.Set(stateParamName, encodedStateParamValue) + if errToDisplay != ShowNoError { + q.Set(errParamName, string(errToDisplay)) + } + loginURL.RawQuery = q.Encode() + + http.Redirect(w, r, + loginURL.String(), + http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 + ) + + return nil +} diff --git a/internal/oidc/login/post_login_handler.go b/internal/oidc/login/post_login_handler.go index 33819c69..5eb3a2e0 100644 --- a/internal/oidc/login/post_login_handler.go +++ b/internal/oidc/login/post_login_handler.go @@ -5,15 +5,84 @@ package login import ( "net/http" + "net/url" "github.com/ory/fosite" + "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/downstreamsession" + "go.pinniped.dev/internal/plog" ) -func NewPostHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister, oauthHelper fosite.OAuth2Provider) HandlerFunc { +func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvidersLister, oauthHelper fosite.OAuth2Provider) HandlerFunc { return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { - // TODO + // Note that the login handler prevents this handler from being called with OIDC upstreams. + _, ldapUpstream, idpType, err := oidc.FindUpstreamIDPByNameAndType(upstreamIDPs, decodedState.UpstreamName, decodedState.UpstreamType) + if err != nil { + // This shouldn't normally happen because the authorization endpoint ensured that this provider existed + // at that time. It would be possible in the unlikely event that the provider was deleted during the login. + plog.Error("error finding upstream provider", err) + return httperr.Wrap(http.StatusUnprocessableEntity, "error finding upstream provider", err) + } + + // Get the original params that were used at the authorization endpoint. + downstreamAuthParams, err := url.ParseQuery(decodedState.AuthParams) + if err != nil { + // This shouldn't really happen because the authorization endpoint encoded these query params correctly. + plog.Error("error reading state downstream auth params", err) + return httperr.New(http.StatusBadRequest, "error reading state downstream auth params") + } + + // Recreate enough of the original authorize request so we can pass it to NewAuthorizeRequest(). + reconstitutedAuthRequest := &http.Request{Form: downstreamAuthParams} + authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), reconstitutedAuthRequest) + if err != nil { + // This shouldn't really happen because the authorization endpoint has already validated these params + // by calling NewAuthorizeRequest() itself. + plog.Error("error using state downstream auth params", err) + return httperr.New(http.StatusBadRequest, "error using state downstream auth params") + } + + // Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested. + downstreamsession.GrantScopesIfRequested(authorizeRequester) + + // Get the username and password form params from the POST body. + username := r.PostFormValue(usernameParamName) + password := r.PostFormValue(passwordParamName) + + // Treat blank username or password as a bad username/password combination, as opposed to an internal error. + if username == "" || password == "" { + // User forgot to enter one of the required fields. + // The user may try to log in again if they'd like, so redirect back to the login page with an error. + return RedirectToLoginPage(r, w, issuerURL, encodedState, ShowBadUserPassErr) + } + + // Attempt to authenticate the user with the upstream IDP. + authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password) + if err != nil { + plog.WarningErr("unexpected error during upstream LDAP authentication", err, "upstreamName", ldapUpstream.GetName()) + // There was some problem during authentication with the upstream, aside from bad username/password. + // The user may try to log in again if they'd like, so redirect back to the login page with an error. + return RedirectToLoginPage(r, w, issuerURL, encodedState, ShowInternalError) + } + if !authenticated { + // The upstream did not accept the username/password combination. + // The user may try to log in again if they'd like, so redirect back to the login page with an error. + return RedirectToLoginPage(r, w, issuerURL, encodedState, ShowBadUserPassErr) + } + + // We had previously interrupted the regular steps of the OIDC authcode flow to show the login page UI. + // Now the upstream IDP has authenticated the user, so now we're back into the regular OIDC authcode flow steps. + // Both success and error responses from this point onwards should look like the usual fosite redirect + // responses, and a happy redirect response will include a downstream authcode. + subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) + username = authenticateResponse.User.GetName() + groups := authenticateResponse.User.GetGroups() + customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse) + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) + oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, false) + return nil } } diff --git a/internal/oidc/login/post_login_handler_test.go b/internal/oidc/login/post_login_handler_test.go new file mode 100644 index 00000000..1e4fa437 --- /dev/null +++ b/internal/oidc/login/post_login_handler_test.go @@ -0,0 +1,693 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/kubernetes/fake" + + "go.pinniped.dev/internal/authenticators" + "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/oidctestutil" +) + +func TestPostLoginEndpoint(t *testing.T) { + const ( + htmlContentType = "text/html; charset=utf-8" + + happyDownstreamCSRF = "test-csrf" + happyDownstreamPKCE = "test-pkce" + happyDownstreamNonce = "test-nonce" + happyDownstreamStateVersion = "2" + happyEncodedUpstreamState = "fake-encoded-state-param-value" + + downstreamIssuer = "https://my-downstream-issuer.com/path" + downstreamRedirectURI = "http://127.0.0.1/callback" + downstreamClientID = "pinniped-cli" + happyDownstreamState = "8b-state" + downstreamNonce = "some-nonce-value" + downstreamPKCEChallenge = "some-challenge" + downstreamPKCEChallengeMethod = "S256" + + ldapUpstreamName = "some-ldap-idp" + ldapUpstreamType = "ldap" + ldapUpstreamResourceUID = "ldap-resource-uid" + activeDirectoryUpstreamName = "some-active-directory-idp" + activeDirectoryUpstreamType = "activedirectory" + activeDirectoryUpstreamResourceUID = "active-directory-resource-uid" + upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev" + + userParam = "username" + passParam = "password" + badUserPassErrParamValue = "login_error" + internalErrParamValue = "internal_error" + ) + + var ( + fositeMissingCodeChallengeErrorQuery = map[string]string{ + "error": "invalid_request", + "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Clients must include a code_challenge when performing the authorize code flow, but it is missing.", + "state": happyDownstreamState, + } + + fositeInvalidCodeChallengeErrorQuery = map[string]string{ + "error": "invalid_request", + "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. The code_challenge_method is not supported, use S256 instead.", + "state": happyDownstreamState, + } + + fositeMissingCodeChallengeMethodErrorQuery = map[string]string{ + "error": "invalid_request", + "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Clients must use code_challenge_method=S256, plain is not allowed.", + "state": happyDownstreamState, + } + + fositePromptHasNoneAndOtherValueErrorQuery = map[string]string{ + "error": "invalid_request", + "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Parameter 'prompt' was set to 'none', but contains other values as well which is not allowed.", + "state": happyDownstreamState, + } + ) + + happyDownstreamScopesRequested := []string{"openid"} + happyDownstreamScopesGranted := []string{"openid"} + + happyDownstreamRequestParamsQuery := url.Values{ + "response_type": []string{"code"}, + "scope": []string{strings.Join(happyDownstreamScopesRequested, " ")}, + "client_id": []string{downstreamClientID}, + "state": []string{happyDownstreamState}, + "nonce": []string{downstreamNonce}, + "code_challenge": []string{downstreamPKCEChallenge}, + "code_challenge_method": []string{downstreamPKCEChallengeMethod}, + "redirect_uri": []string{downstreamRedirectURI}, + } + happyDownstreamRequestParams := happyDownstreamRequestParamsQuery.Encode() + + copyOfHappyDownstreamRequestParamsQuery := func() url.Values { + params := url.Values{} + for k, v := range happyDownstreamRequestParamsQuery { + params[k] = make([]string, len(v)) + copy(params[k], v) + } + return params + } + + happyLDAPDecodedState := &oidc.UpstreamStateParamData{ + AuthParams: happyDownstreamRequestParams, + UpstreamName: ldapUpstreamName, + UpstreamType: ldapUpstreamType, + Nonce: happyDownstreamNonce, + CSRFToken: happyDownstreamCSRF, + PKCECode: happyDownstreamPKCE, + FormatVersion: happyDownstreamStateVersion, + } + + modifyHappyLDAPDecodedState := func(edit func(*oidc.UpstreamStateParamData)) *oidc.UpstreamStateParamData { + copyOfHappyLDAPDecodedState := *happyLDAPDecodedState + edit(©OfHappyLDAPDecodedState) + return ©OfHappyLDAPDecodedState + } + + happyActiveDirectoryDecodedState := &oidc.UpstreamStateParamData{ + AuthParams: happyDownstreamRequestParams, + UpstreamName: activeDirectoryUpstreamName, + UpstreamType: activeDirectoryUpstreamType, + Nonce: happyDownstreamNonce, + CSRFToken: happyDownstreamCSRF, + PKCECode: happyDownstreamPKCE, + FormatVersion: happyDownstreamStateVersion, + } + + happyLDAPUsername := "some-ldap-user" + happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" + happyLDAPPassword := "some-ldap-password" //nolint:gosec + happyLDAPUID := "some-ldap-uid" + happyLDAPUserDN := "cn=foo,dn=bar" + happyLDAPGroups := []string{"group1", "group2", "group3"} + happyLDAPExtraRefreshAttribute := "some-refresh-attribute" + happyLDAPExtraRefreshValue := "some-refresh-attribute-value" + + parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL) + require.NoError(t, err) + + ldapAuthenticateFunc := func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) { + if username == "" || password == "" { + return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator") + } + if username == happyLDAPUsername && password == happyLDAPPassword { + return &authenticators.Response{ + User: &user.DefaultInfo{ + Name: happyLDAPUsernameFromAuthenticator, + UID: happyLDAPUID, + Groups: happyLDAPGroups, + }, + DN: happyLDAPUserDN, + ExtraRefreshAttributes: map[string]string{ + happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue, + }, + }, true, nil + } + return nil, false, nil + } + + upstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: parsedUpstreamLDAPURL, + AuthenticateFunc: ldapAuthenticateFunc, + } + + upstreamActiveDirectoryIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: activeDirectoryUpstreamName, + ResourceUID: activeDirectoryUpstreamResourceUID, + URL: parsedUpstreamLDAPURL, + AuthenticateFunc: ldapAuthenticateFunc, + } + + erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) { + return nil, false, fmt.Errorf("some ldap upstream auth error") + }, + } + + expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{ + ProviderUID: activeDirectoryUpstreamResourceUID, + ProviderName: activeDirectoryUpstreamName, + ProviderType: psession.ProviderTypeActiveDirectory, + OIDC: nil, + LDAP: nil, + ActiveDirectory: &psession.ActiveDirectorySessionData{ + UserDN: happyLDAPUserDN, + ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue}, + }, + } + + expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{ + ProviderUID: ldapUpstreamResourceUID, + ProviderName: ldapUpstreamName, + ProviderType: psession.ProviderTypeLDAP, + OIDC: nil, + LDAP: &psession.LDAPSessionData{ + UserDN: happyLDAPUserDN, + ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue}, + }, + ActiveDirectory: nil, + } + + // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it + happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState + + happyUsernamePasswordFormParams := url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{happyLDAPPassword}} + + encodeQuery := func(query map[string]string) string { + values := url.Values{} + for k, v := range query { + values[k] = []string{v} + } + return values.Encode() + } + + urlWithQuery := func(baseURL string, query map[string]string) string { + urlToReturn := fmt.Sprintf("%s?%s", baseURL, encodeQuery(query)) + _, err := url.Parse(urlToReturn) + require.NoError(t, err, "urlWithQuery helper was used to create an illegal URL") + return urlToReturn + } + + tests := []struct { + name string + idps *oidctestutil.UpstreamIDPListerBuilder + decodedState *oidc.UpstreamStateParamData + formParams url.Values + reqURIQuery url.Values + + wantStatus int + wantContentType string + wantBodyString string + wantErr string + + // Assertion that the response should be a redirect to the login page with an error param. + wantRedirectToLoginPageError string + + // Assertions for when an authcode should be returned, i.e. the request was authenticated by an + // upstream LDAP or AD provider. + wantRedirectLocationRegexp string // for loose matching + wantRedirectLocationString string // for exact matching instead + wantDownstreamRedirectURI string + wantDownstreamGrantedScopes []string + wantDownstreamIDTokenSubject string + wantDownstreamIDTokenUsername string + wantDownstreamIDTokenGroups []string + wantDownstreamRequestedScopes []string + wantDownstreamPKCEChallenge string + wantDownstreamPKCEChallengeMethod string + wantDownstreamNonce string + wantDownstreamCustomSessionData *psession.CustomSessionData + + // Authorization requests for either a successful OIDC upstream or for an error with any upstream + // should never use Kube storage. There is only one exception to this rule, which is that certain + // OIDC validations are checked in fosite after the OAuth authcode (and sometimes the OIDC session) + // is stored, so it is possible with an LDAP upstream to store objects and then return an error to + // the client anyway (which makes the stored objects useless, but oh well). + wantUnnecessaryStoredRecords int + }{ + { + name: "happy LDAP login", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one + WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy AD login", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithLDAP(&erroringUpstreamLDAPIdentityProvider). + WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), // should pick this one + decodedState: happyActiveDirectoryDecodedState, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, + }, + { + name: "happy LDAP login when downstream redirect uri matches what is configured for client except for the port number", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["redirect_uri"] = []string{"http://127.0.0.1:4242/callback"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: "http://127.0.0.1:4242/callback", + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy LDAP login when there are additional allowed downstream requested scopes", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["scope"] = []string{"openid offline_access pinniped:request-audience"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy LDAP when downstream OIDC validations are skipped because the openid scope was not requested", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["scope"] = []string{"email"} + // The following prompt value is illegal when openid is requested, but note that openid is not requested. + query["prompt"] = []string{"none login"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, // no scopes granted + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: []string{"email"}, // only email was requested + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{}, // no scopes granted + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "bad username LDAP login", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + formParams: url.Values{userParam: []string{"wrong!"}, passParam: []string{happyLDAPPassword}}, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectToLoginPageError: badUserPassErrParamValue, + }, + { + name: "bad password LDAP login", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + formParams: url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{"wrong!"}}, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectToLoginPageError: badUserPassErrParamValue, + }, + { + name: "blank username LDAP login", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + formParams: url.Values{userParam: []string{""}, passParam: []string{happyLDAPPassword}}, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectToLoginPageError: badUserPassErrParamValue, + }, + { + name: "blank password LDAP login", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + formParams: url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{""}}, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectToLoginPageError: badUserPassErrParamValue, + }, + { + name: "username and password sent as URI query params should be ignored since they are expected in form post body", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + reqURIQuery: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectToLoginPageError: badUserPassErrParamValue, + }, + { + name: "error during upstream LDAP authentication", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider), + decodedState: happyLDAPDecodedState, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectToLoginPageError: internalErrParamValue, + }, + { + name: "downstream redirect uri does not match what is configured for client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["redirect_uri"] = []string{"http://127.0.0.1/wrong_callback"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "downstream client does not exist", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["client_id"] = []string{"wrong_client_id"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "downstream client is missing", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + delete(query, "client_id") + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "response type is unsupported", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["response_type"] = []string{"unsupported"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "response type is missing", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + delete(query, "response_type") + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "PKCE code_challenge is missing", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + delete(query, "code_challenge") + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "PKCE code_challenge_method is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["code_challenge_method"] = []string{"this-is-not-a-valid-pkce-alg"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "PKCE code_challenge_method is `plain`", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["code_challenge_method"] = []string{"plain"} // plain is not allowed + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "PKCE code_challenge_method is missing", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + delete(query, "code_challenge_method") + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "prompt param is not allowed to have none and another legal value at the same time", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["prompt"] = []string{"none login"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), + wantUnnecessaryStoredRecords: 1, // fosite already stored the authcode before it noticed the error + }, + { + name: "downstream state does not have enough entropy", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["state"] = []string{"short"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "downstream scopes do not match what is configured for client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["scope"] = []string{"openid offline_access pinniped:request-audience scope_not_allowed"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "no upstream providers are configured or provider cannot be found by name", + idps: oidctestutil.NewUpstreamIDPListerBuilder(), // empty + decodedState: happyLDAPDecodedState, + formParams: happyUsernamePasswordFormParams, + wantErr: "error finding upstream provider: provider not found", + }, + { + name: "upstream provider cannot be found by name and type", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: happyActiveDirectoryDecodedState, // correct upstream IDP name, but wrong upstream IDP type + formParams: happyUsernamePasswordFormParams, + wantErr: "error finding upstream provider: provider not found", + }, + } + + for _, test := range tests { + tt := test + + t.Run(tt.name, func(t *testing.T) { + kubeClient := fake.NewSimpleClientset() + secretsClient := kubeClient.CoreV1().Secrets("some-namespace") + + // Configure fosite the same way that the production code would. + // Inject this into our test subject at the last second so we get a fresh storage for every test. + timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() + kubeOauthStore := oidc.NewKubeStorage(secretsClient, timeoutsConfiguration) + hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } + require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") + jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() + oauthHelper := oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + + req := httptest.NewRequest(http.MethodPost, "/ignored", strings.NewReader(tt.formParams.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if tt.reqURIQuery != nil { + req.URL.RawQuery = tt.reqURIQuery.Encode() + } + + rsp := httptest.NewRecorder() + + subject := NewPostHandler(downstreamIssuer, tt.idps.Build(), oauthHelper) + + err := subject(rsp, req, happyEncodedUpstreamState, tt.decodedState) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Empty(t, kubeClient.Actions()) + return // the http response doesn't matter when the function returns an error, because the caller should handle the error + } + // Otherwise, expect no error. + require.NoError(t, err) + + require.Equal(t, tt.wantStatus, rsp.Code) + testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) + require.Equal(t, test.wantBodyString, rsp.Body.String()) + + actualLocation := rsp.Header().Get("Location") + + switch { + case tt.wantRedirectLocationRegexp != "": + require.Len(t, rsp.Header().Values("Location"), 1) + oidctestutil.RequireAuthCodeRegexpMatch( + t, + actualLocation, + test.wantRedirectLocationRegexp, + kubeClient, + secretsClient, + kubeOauthStore, + test.wantDownstreamGrantedScopes, + test.wantDownstreamIDTokenSubject, + test.wantDownstreamIDTokenUsername, + test.wantDownstreamIDTokenGroups, + test.wantDownstreamRequestedScopes, + test.wantDownstreamPKCEChallenge, + test.wantDownstreamPKCEChallengeMethod, + test.wantDownstreamNonce, + downstreamClientID, + test.wantDownstreamRedirectURI, + test.wantDownstreamCustomSessionData, + ) + case tt.wantRedirectToLoginPageError != "": + expectedLocation := downstreamIssuer + oidc.PinnipedLoginPath + + "?err=" + tt.wantRedirectToLoginPageError + "&state=" + happyEncodedUpstreamState + require.Equal(t, expectedLocation, actualLocation) + require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords) + case tt.wantRedirectLocationString != "": + require.Equal(t, tt.wantRedirectLocationString, actualLocation) + require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords) + default: + require.Failf(t, "test should have expected a redirect", + "actual location was %q", actualLocation) + } + }) + } +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 90c47655..b45e757a 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -6,18 +6,25 @@ package oidc import ( "crypto/subtle" + "errors" + "fmt" "net/http" "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/felixge/httpsnoop" "github.com/ory/fosite" "github.com/ory/fosite/compose" + errorsx "github.com/pkg/errors" + "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider/formposthtml" + "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) @@ -365,3 +372,106 @@ func validateCSRFValue(state *UpstreamStateParamData, csrfCookieValue csrftoken. } return nil } + +// FindUpstreamIDPByNameAndType finds the requested IDP by name and type, or returns an error. +// Note that AD and LDAP IDPs both return the same interface type, but different ProviderTypes values. +func FindUpstreamIDPByNameAndType( + idpLister UpstreamIdentityProvidersLister, + upstreamName string, + upstreamType string, +) ( + provider.UpstreamOIDCIdentityProviderI, + provider.UpstreamLDAPIdentityProviderI, + psession.ProviderType, + error, +) { + switch upstreamType { + case string(v1alpha1.IDPTypeOIDC): + for _, p := range idpLister.GetOIDCIdentityProviders() { + if p.GetName() == upstreamName { + return p, nil, psession.ProviderTypeOIDC, nil + } + } + case string(v1alpha1.IDPTypeLDAP): + for _, p := range idpLister.GetLDAPIdentityProviders() { + if p.GetName() == upstreamName { + return nil, p, psession.ProviderTypeLDAP, nil + } + } + case string(v1alpha1.IDPTypeActiveDirectory): + for _, p := range idpLister.GetActiveDirectoryIdentityProviders() { + if p.GetName() == upstreamName { + return nil, p, psession.ProviderTypeActiveDirectory, nil + } + } + } + return nil, nil, "", errors.New("provider not found") +} + +// WriteAuthorizeError writes an authorization error as it should be returned by the authorization endpoint and other +// similar endpoints that are the end of the downstream authcode flow. Errors responses are written in the usual fosite style. +func WriteAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error, isBrowserless bool) { + if plog.Enabled(plog.LevelTrace) { + // When trace level logging is enabled, include the stack trace in the log message. + keysAndValues := FositeErrorForLog(err) + errWithStack := errorsx.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", FositeErrorForLog(err)...) + } + if isBrowserless { + w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w) + } + // Return an error according to OIDC spec 3.1.2.6 (second paragraph). + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) +} + +// PerformAuthcodeRedirect successfully completes a downstream login by creating a session and +// writing the authcode redirect response as it should be returned by the authorization endpoint and other +// similar endpoints that are the end of the downstream authcode flow. +func PerformAuthcodeRedirect( + r *http.Request, + w http.ResponseWriter, + oauthHelper fosite.OAuth2Provider, + authorizeRequester fosite.AuthorizeRequester, + openIDSession *psession.PinnipedSession, + isBrowserless bool, +) { + authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) + if err != nil { + plog.WarningErr("error while generating and saving authcode", err) + WriteAuthorizeError(w, oauthHelper, authorizeRequester, err, isBrowserless) + return + } + if isBrowserless { + w = rewriteStatusSeeOtherToStatusFoundForBrowserless(w) + } + oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) +} + +func rewriteStatusSeeOtherToStatusFoundForBrowserless(w http.ResponseWriter) http.ResponseWriter { + // rewrite http.StatusSeeOther to http.StatusFound for backwards compatibility with old pinniped CLIs. + // we can drop this in a few releases once we feel enough time has passed for users to update. + // + // WriteAuthorizeResponse/WriteAuthorizeError calls used to result in http.StatusFound until + // https://github.com/ory/fosite/pull/636 changed it to http.StatusSeeOther to address + // https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 + // Safari has the bad behavior in the case of http.StatusFound and not just http.StatusTemporaryRedirect. + // + // in the browserless flows, the OAuth client is the pinniped CLI and it already has access to the user's + // password. Thus there is no security issue with using http.StatusFound vs. http.StatusSeeOther. + return httpsnoop.Wrap(w, httpsnoop.Hooks{ + WriteHeader: func(delegate httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc { + return func(code int) { + if code == http.StatusSeeOther { + code = http.StatusFound + } + delegate(code) + } + }, + }) +} diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 283b1808..3da0c2c3 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -140,7 +140,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs upstreamStateEncoder, csrfCookieEncoder, login.NewGetHandler(m.upstreamIDPs), - login.NewPostHandler(m.upstreamIDPs, oauthHelperWithKubeStorage), + login.NewPostHandler(issuer, m.upstreamIDPs, oauthHelperWithKubeStorage), ) plog.Debug("oidc provider manager added or updated issuer", "issuer", issuer) From 388cdb6ddd89faa28a88117b8e9cc82647cb5c02 Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Tue, 3 May 2022 15:18:38 -0700 Subject: [PATCH 12/26] Fix bug where form was posting to the wrong path Signed-off-by: Margo Crawford --- internal/oidc/login/get_login_handler.go | 2 ++ internal/oidc/login/get_login_handler_test.go | 4 ++-- internal/oidc/login/login_form.gohtml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go index a8f90216..a34f487c 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/oidc/login/get_login_handler.go @@ -28,6 +28,7 @@ type PageData struct { HasAlertError bool AlertMessage string Title string + PostPath string } func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc { @@ -44,6 +45,7 @@ func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFun HasAlertError: alertError != "", AlertMessage: message, Title: "Pinniped", + PostPath: r.URL.Path, // the path for POST is the same as for GET }) if err != nil { return err diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go index 7e4d8c56..3235fcc5 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/oidc/login/get_login_handler_test.go @@ -98,7 +98,7 @@ func TestGetLogin(t *testing.T) { tt := test t.Run(tt.name, func(t *testing.T) { handler := NewGetHandler(tt.idps) - target := "/login?state=" + tt.encodedState + target := "/some/path/login?state=" + tt.encodedState if tt.errParam != "" { target += "&err=" + tt.errParam } @@ -126,7 +126,7 @@ func getHTMLResult(errorBanner string) string {

Pinniped

some-ldap-idp

%s -
+
diff --git a/internal/oidc/login/login_form.gohtml b/internal/oidc/login/login_form.gohtml index 5376b5e4..6dd4819d 100644 --- a/internal/oidc/login/login_form.gohtml +++ b/internal/oidc/login/login_form.gohtml @@ -15,7 +15,7 @@ SPDX-License-Identifier: Apache-2.0 {{.AlertMessage}}
{{end}} - +
From 2e031f727b2fc9390a6f4f65f4fe608d4983725b Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 3 May 2022 16:46:09 -0700 Subject: [PATCH 13/26] Use security headers for the form_post page in the POST /login endpoint Also use more specific test assertions where security headers are expected. And run the unit tests for the login package in parallel. --- internal/oidc/auth/auth_handler_test.go | 2 +- .../oidc/callback/callback_handler_test.go | 2 +- internal/oidc/login/get_login_handler_test.go | 5 ++- internal/oidc/login/login_handler.go | 18 +++++++++- internal/oidc/login/login_handler_test.go | 12 +++++-- .../oidc/login/post_login_handler_test.go | 30 ++++++++-------- internal/testutil/assertions.go | 34 ++++++++++++++++--- 7 files changed, 78 insertions(+), 25 deletions(-) diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index fc0cbc53..058cb70c 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -2566,7 +2566,7 @@ func TestAuthorizationEndpoint(t *testing.T) { require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) - testutil.RequireSecurityHeaders(t, rsp) + testutil.RequireSecurityHeadersWithoutFormPostCSPs(t, rsp) if test.wantPasswordGrantCall != nil { test.wantPasswordGrantCall.args.Ctx = reqContext diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 6fc47773..e92974d9 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -1034,7 +1034,7 @@ func TestCallbackEndpoint(t *testing.T) { t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) - testutil.RequireSecurityHeaders(t, rsp) + testutil.RequireSecurityHeadersWithFormPostCSPs(t, rsp) if test.wantAuthcodeExchangeCall != nil { test.wantAuthcodeExchangeCall.args.Ctx = reqContext diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go index 3235fcc5..484ee450 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/oidc/login/get_login_handler_test.go @@ -96,7 +96,10 @@ func TestGetLogin(t *testing.T) { for _, test := range tests { tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + handler := NewGetHandler(tt.idps) target := "/some/path/login?state=" + tt.encodedState if tt.errParam != "" { @@ -107,7 +110,7 @@ func TestGetLogin(t *testing.T) { err := handler(rsp, req, tt.encodedState, tt.decodedState) require.NoError(t, err) - require.Equal(t, test.wantStatus, rsp.Code) + require.Equal(t, tt.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) body := rsp.Body.String() require.Equal(t, tt.wantBody, body) diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go index 751dc9c4..ce1b3810 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/oidc/login/login_handler.go @@ -11,6 +11,7 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" ) @@ -78,7 +79,22 @@ func NewHandler( return handler(w, r, encodedState, decodedState) }) - return securityheader.Wrap(loginHandler) + return wrapSecurityHeaders(loginHandler) +} + +func wrapSecurityHeaders(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var wrapped http.Handler + switch r.Method { + case http.MethodPost: + // POST requests can result in the form_post html page, so allow it with CSP headers. + wrapped = securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy()) + default: + wrapped = securityheader.Wrap(handler) + } + + wrapped.ServeHTTP(w, r) + }) } func RedirectToLoginPage( diff --git a/internal/oidc/login/login_handler_test.go b/internal/oidc/login/login_handler_test.go index 347f0760..79a0ee65 100644 --- a/internal/oidc/login/login_handler_test.go +++ b/internal/oidc/login/login_handler_test.go @@ -370,9 +370,11 @@ func TestLoginEndpoint(t *testing.T) { tt := test t.Run(tt.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(tt.method, tt.path, nil) - if test.csrfCookie != "" { - req.Header.Set("Cookie", test.csrfCookie) + if tt.csrfCookie != "" { + req.Header.Set("Cookie", tt.csrfCookie) } rsp := httptest.NewRecorder() @@ -414,7 +416,11 @@ func TestLoginEndpoint(t *testing.T) { subject.ServeHTTP(rsp, req) - testutil.RequireSecurityHeaders(t, rsp) + if tt.method == http.MethodPost { + testutil.RequireSecurityHeadersWithFormPostCSPs(t, rsp) + } else { + testutil.RequireSecurityHeadersWithoutFormPostCSPs(t, rsp) + } require.Equal(t, tt.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) diff --git a/internal/oidc/login/post_login_handler_test.go b/internal/oidc/login/post_login_handler_test.go index 1e4fa437..f74d67d8 100644 --- a/internal/oidc/login/post_login_handler_test.go +++ b/internal/oidc/login/post_login_handler_test.go @@ -617,6 +617,8 @@ func TestPostLoginEndpoint(t *testing.T) { tt := test t.Run(tt.name, func(t *testing.T) { + t.Parallel() + kubeClient := fake.NewSimpleClientset() secretsClient := kubeClient.CoreV1().Secrets("some-namespace") @@ -650,7 +652,7 @@ func TestPostLoginEndpoint(t *testing.T) { require.Equal(t, tt.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) - require.Equal(t, test.wantBodyString, rsp.Body.String()) + require.Equal(t, tt.wantBodyString, rsp.Body.String()) actualLocation := rsp.Header().Get("Location") @@ -660,30 +662,30 @@ func TestPostLoginEndpoint(t *testing.T) { oidctestutil.RequireAuthCodeRegexpMatch( t, actualLocation, - test.wantRedirectLocationRegexp, + tt.wantRedirectLocationRegexp, kubeClient, secretsClient, kubeOauthStore, - test.wantDownstreamGrantedScopes, - test.wantDownstreamIDTokenSubject, - test.wantDownstreamIDTokenUsername, - test.wantDownstreamIDTokenGroups, - test.wantDownstreamRequestedScopes, - test.wantDownstreamPKCEChallenge, - test.wantDownstreamPKCEChallengeMethod, - test.wantDownstreamNonce, + tt.wantDownstreamGrantedScopes, + tt.wantDownstreamIDTokenSubject, + tt.wantDownstreamIDTokenUsername, + tt.wantDownstreamIDTokenGroups, + tt.wantDownstreamRequestedScopes, + tt.wantDownstreamPKCEChallenge, + tt.wantDownstreamPKCEChallengeMethod, + tt.wantDownstreamNonce, downstreamClientID, - test.wantDownstreamRedirectURI, - test.wantDownstreamCustomSessionData, + tt.wantDownstreamRedirectURI, + tt.wantDownstreamCustomSessionData, ) case tt.wantRedirectToLoginPageError != "": expectedLocation := downstreamIssuer + oidc.PinnipedLoginPath + "?err=" + tt.wantRedirectToLoginPageError + "&state=" + happyEncodedUpstreamState require.Equal(t, expectedLocation, actualLocation) - require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords) + require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords) case tt.wantRedirectLocationString != "": require.Equal(t, tt.wantRedirectLocationString, actualLocation) - require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords) + require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords) default: require.Failf(t, "test should have expected a redirect", "actual location was %q", actualLocation) diff --git a/internal/testutil/assertions.go b/internal/testutil/assertions.go index 9286bff1..b592e07e 100644 --- a/internal/testutil/assertions.go +++ b/internal/testutil/assertions.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testutil @@ -54,9 +54,35 @@ func RequireNumberOfSecretsMatchingLabelSelector(t *testing.T, secrets v1.Secret require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets) } -func RequireSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) { - // This is a more relaxed assertion rather than an exact match, so it can cover all the CSP headers we use. - require.Contains(t, response.Header().Get("Content-Security-Policy"), "default-src 'none'") +func RequireSecurityHeadersWithFormPostCSPs(t *testing.T, response *httptest.ResponseRecorder) { + // Loosely confirm that the unique CSPs needed for the form_post page were used. + cspHeader := response.Header().Get("Content-Security-Policy") + require.Contains(t, cspHeader, "script-src '") // loose assertion + require.Contains(t, cspHeader, "style-src '") // loose assertion + require.Contains(t, cspHeader, "img-src data:") + require.Contains(t, cspHeader, "connect-src *") + + // Also require all the usual security headers. + requireSecurityHeaders(t, response) +} + +func RequireSecurityHeadersWithoutFormPostCSPs(t *testing.T, response *httptest.ResponseRecorder) { + // Confirm that the unique CSPs needed for the form_post page were NOT used. + cspHeader := response.Header().Get("Content-Security-Policy") + require.NotContains(t, cspHeader, "script-src") + require.NotContains(t, cspHeader, "style-src") + require.NotContains(t, cspHeader, "img-src data:") + require.NotContains(t, cspHeader, "connect-src *") + + // Also require all the usual security headers. + requireSecurityHeaders(t, response) +} + +func requireSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) { + // Loosely confirm that the generic CSPs were used. + cspHeader := response.Header().Get("Content-Security-Policy") + require.Contains(t, cspHeader, "default-src 'none'") + require.Contains(t, cspHeader, "frame-ancestors 'none'") require.Equal(t, "DENY", response.Header().Get("X-Frame-Options")) require.Equal(t, "1; mode=block", response.Header().Get("X-XSS-Protection")) From 329d41aac7cc8b39671313b0f8c657ea99802baf Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Thu, 5 May 2022 08:49:58 -0700 Subject: [PATCH 14/26] Add the full end to end test for ldap web ui Signed-off-by: Margo Crawford --- test/integration/e2e_test.go | 37 ++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 13a70757..e4a73b66 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -974,6 +974,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo page := browsertest.Open(t) expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue + expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs setupClusterForEndToEndLDAPTest(t, expectedUsername, env) @@ -992,6 +993,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + start := time.Now() kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6") kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) @@ -1078,8 +1080,39 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo regex := regexp.MustCompile(`\A` + downstream.Spec.Issuer + `/login.+`) browsertest.WaitForURL(t, page, regex) - browsertest.WaitForVisibleElements(t, page, "input#username", "input#password", "button#submit") - // TODO actually log in :P + usernameSelector := "input#username" + passwordSelector := "input[type='password']" + loginButtonSelector := "button#submit" + browsertest.WaitForVisibleElements(t, page, usernameSelector, passwordSelector, loginButtonSelector) + + // Fill in the username and password and click "submit". + t.Logf("logging into ldap") + require.NoError(t, page.First(usernameSelector).Fill(expectedUsername)) + require.NoError(t, page.First(passwordSelector).Fill(env.SupervisorUpstreamLDAP.TestUserPassword)) + require.NoError(t, page.First(loginButtonSelector).Click()) + + formpostExpectSuccessState(t, page) + + // Expect the CLI to output a list of namespaces. + t.Logf("waiting for kubectl to output namespace list") + var kubectlOutput string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for kubectl output") + case kubectlOutput = <-kubectlOutputChan: + } + requireKubectlGetNamespaceOutput(t, env, kubectlOutput) + + t.Logf("first kubectl command took %s", time.Since(start).String()) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, + downstream, + kubeconfigPath, + sessionCachePath, + pinnipedExe, + expectedUsername, + expectedGroups, + ) }) } From 6ca7c932aec6b923f40db175fe901c2bda14cb77 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 4 May 2022 12:12:14 -0700 Subject: [PATCH 15/26] Add unit test for rendering form_post response from POST /login --- internal/oidc/login/login_handler.go | 8 +-- .../oidc/login/post_login_handler_test.go | 57 ++++++++++++++++++- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go index ce1b3810..eb9d8251 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/oidc/login/login_handler.go @@ -84,15 +84,11 @@ func NewHandler( func wrapSecurityHeaders(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var wrapped http.Handler - switch r.Method { - case http.MethodPost: + wrapped := securityheader.Wrap(handler) + if r.Method == http.MethodPost { // POST requests can result in the form_post html page, so allow it with CSP headers. wrapped = securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy()) - default: - wrapped = securityheader.Wrap(handler) } - wrapped.ServeHTTP(w, r) }) } diff --git a/internal/oidc/login/post_login_handler_test.go b/internal/oidc/login/post_login_handler_test.go index f74d67d8..267c5e08 100644 --- a/internal/oidc/login/post_login_handler_test.go +++ b/internal/oidc/login/post_login_handler_test.go @@ -249,6 +249,7 @@ func TestPostLoginEndpoint(t *testing.T) { // upstream LDAP or AD provider. wantRedirectLocationRegexp string // for loose matching wantRedirectLocationString string // for exact matching instead + wantBodyFormResponseRegexp string // for form_post html page matching instead wantDownstreamRedirectURI string wantDownstreamGrantedScopes []string wantDownstreamIDTokenSubject string @@ -311,6 +312,30 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, }, + { + name: "happy LDAP login when downstream response_mode=form_post returns 200 with HTML+JS form", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + query := copyOfHappyDownstreamRequestParamsQuery() + query["response_mode"] = []string{"form_post"} + data.AuthParams = query.Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBodyFormResponseRegexp: `(?s).*To finish logging in, paste this authorization code` + + `.*.*(.+).*`, // "(?s)" means match "." across newlines + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, { name: "happy LDAP login when downstream redirect uri matches what is configured for client except for the port number", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -652,12 +677,13 @@ func TestPostLoginEndpoint(t *testing.T) { require.Equal(t, tt.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) - require.Equal(t, tt.wantBodyString, rsp.Body.String()) actualLocation := rsp.Header().Get("Location") switch { case tt.wantRedirectLocationRegexp != "": + // Expecting a success redirect to the client. + require.Equal(t, tt.wantBodyString, rsp.Body.String()) require.Len(t, rsp.Header().Values("Location"), 1) oidctestutil.RequireAuthCodeRegexpMatch( t, @@ -679,15 +705,42 @@ func TestPostLoginEndpoint(t *testing.T) { tt.wantDownstreamCustomSessionData, ) case tt.wantRedirectToLoginPageError != "": + // Expecting an error redirect to the login UI page. + require.Equal(t, tt.wantBodyString, rsp.Body.String()) expectedLocation := downstreamIssuer + oidc.PinnipedLoginPath + "?err=" + tt.wantRedirectToLoginPageError + "&state=" + happyEncodedUpstreamState require.Equal(t, expectedLocation, actualLocation) require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords) case tt.wantRedirectLocationString != "": + // Expecting an error redirect to the client. + require.Equal(t, tt.wantBodyString, rsp.Body.String()) require.Equal(t, tt.wantRedirectLocationString, actualLocation) require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords) + case tt.wantBodyFormResponseRegexp != "": + // Expecting the body of the response to be a html page with a form (for "response_mode=form_post"). + _, hasLocationHeader := rsp.Header()["Location"] + require.False(t, hasLocationHeader) + oidctestutil.RequireAuthCodeRegexpMatch( + t, + rsp.Body.String(), + tt.wantBodyFormResponseRegexp, + kubeClient, + secretsClient, + kubeOauthStore, + tt.wantDownstreamGrantedScopes, + tt.wantDownstreamIDTokenSubject, + tt.wantDownstreamIDTokenUsername, + tt.wantDownstreamIDTokenGroups, + tt.wantDownstreamRequestedScopes, + tt.wantDownstreamPKCEChallenge, + tt.wantDownstreamPKCEChallengeMethod, + tt.wantDownstreamNonce, + downstreamClientID, + tt.wantDownstreamRedirectURI, + tt.wantDownstreamCustomSessionData, + ) default: - require.Failf(t, "test should have expected a redirect", + require.Failf(t, "test should have expected a redirect or form body", "actual location was %q", actualLocation) } }) From cffa353ffb76983dc172a9d211158ddbaf0155df Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 5 May 2022 13:12:06 -0700 Subject: [PATCH 16/26] Login page styling/structure for users, screen readers, passwd managers Also: - Add CSS to login page - Refactor login page HTML and CSS into a new package - New custom CSP headers for the login page, because the requirements are different from the form_post page --- internal/oidc/auth/auth_handler_test.go | 2 +- .../oidc/callback/callback_handler_test.go | 2 +- internal/oidc/login/get_login_handler.go | 60 ++++------ internal/oidc/login/get_login_handler_test.go | 106 ++++++------------ internal/oidc/login/login_form.gohtml | 39 ------- internal/oidc/login/login_handler.go | 3 +- internal/oidc/login/login_handler_test.go | 4 +- internal/oidc/login/loginhtml/login_form.css | 94 ++++++++++++++++ .../oidc/login/loginhtml/login_form.gohtml | 40 +++++++ internal/oidc/login/loginhtml/loginhtml.go | 65 +++++++++++ .../oidc/login/loginhtml/loginhtml_test.go | 68 +++++++++++ internal/oidc/provider/csp/csp.go | 15 +++ internal/oidc/provider/csp/csp_test.go | 15 +++ .../provider/formposthtml/formposthtml.go | 15 +-- .../formposthtml/formposthtml_test.go | 4 - internal/oidc/provider/manager/manager.go | 2 +- internal/testutil/assertions.go | 20 +++- internal/testutil/loginhtml.go | 68 +++++++++++ 18 files changed, 449 insertions(+), 173 deletions(-) delete mode 100644 internal/oidc/login/login_form.gohtml create mode 100644 internal/oidc/login/loginhtml/login_form.css create mode 100644 internal/oidc/login/loginhtml/login_form.gohtml create mode 100644 internal/oidc/login/loginhtml/loginhtml.go create mode 100644 internal/oidc/login/loginhtml/loginhtml_test.go create mode 100644 internal/oidc/provider/csp/csp.go create mode 100644 internal/oidc/provider/csp/csp_test.go create mode 100644 internal/testutil/loginhtml.go diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 058cb70c..e2b3f000 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -2566,7 +2566,7 @@ func TestAuthorizationEndpoint(t *testing.T) { require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) - testutil.RequireSecurityHeadersWithoutFormPostCSPs(t, rsp) + testutil.RequireSecurityHeadersWithoutCustomCSPs(t, rsp) if test.wantPasswordGrantCall != nil { test.wantPasswordGrantCall.args.Ctx = reqContext diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index e92974d9..d8f08822 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -1034,7 +1034,7 @@ func TestCallbackEndpoint(t *testing.T) { t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) - testutil.RequireSecurityHeadersWithFormPostCSPs(t, rsp) + testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) if test.wantAuthcodeExchangeCall != nil { test.wantAuthcodeExchangeCall.args.Ctx = reqContext diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go index a34f487c..3e33c937 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/oidc/login/get_login_handler.go @@ -4,53 +4,39 @@ package login import ( - _ "embed" - "html/template" "net/http" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/login/loginhtml" ) -const defaultErrorMessage = "An internal error occurred. Please contact your administrator for help." - -var ( - //go:embed login_form.gohtml - rawHTMLTemplate string - - errorMappings = map[string]string{ - "login_error": "Incorrect username or password.", - } +const ( + internalErrorMessage = "An internal error occurred. Please contact your administrator for help." + incorrectUsernameOrPasswordErrorMessage = "Incorrect username or password." ) -type PageData struct { - State string - IDPName string - HasAlertError bool - AlertMessage string - Title string - PostPath string -} - -func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc { - var parsedHTMLTemplate = template.Must(template.New("login_post.gohtml").Parse(rawHTMLTemplate)) +func NewGetHandler() HandlerFunc { return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { - alertError := r.URL.Query().Get("err") - message := errorMappings[alertError] - if message == "" { - message = defaultErrorMessage - } - err := parsedHTMLTemplate.Execute(w, &PageData{ + alertMessage, hasAlert := getAlert(r) + + pageInputs := &loginhtml.PageData{ + PostPath: r.URL.Path, // the path for POST is the same as for GET State: encodedState, IDPName: decodedState.UpstreamName, - HasAlertError: alertError != "", - AlertMessage: message, - Title: "Pinniped", - PostPath: r.URL.Path, // the path for POST is the same as for GET - }) - if err != nil { - return err + HasAlertError: hasAlert, + AlertMessage: alertMessage, } - - return nil + return loginhtml.Template().Execute(w, pageInputs) } } + +func getAlert(r *http.Request) (string, bool) { + errorParamValue := r.URL.Query().Get(errParamName) + + message := internalErrorMessage + if errorParamValue == string(ShowBadUserPassErr) { + message = incorrectUsernameOrPasswordErrorMessage + } + + return message, errorParamValue != "" +} diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go index 484ee450..472148d5 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/oidc/login/get_login_handler_test.go @@ -4,21 +4,23 @@ package login import ( - "fmt" "net/http" "net/http/httptest" "testing" - "go.pinniped.dev/internal/testutil" - "github.com/stretchr/testify/require" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/login/loginhtml" + "go.pinniped.dev/internal/testutil" ) func TestGetLogin(t *testing.T) { const ( - happyLdapIDPName = "some-ldap-idp" + testPath = "/some/path/login" + testUpstreamName = "some-ldap-idp" + testUpstreamType = "ldap" + testEncodedState = "fake-encoded-state-value" ) tests := []struct { @@ -34,63 +36,57 @@ func TestGetLogin(t *testing.T) { { name: "Happy path ldap", decodedState: &oidc.UpstreamStateParamData{ - UpstreamName: happyLdapIDPName, - UpstreamType: "ldap", + UpstreamName: testUpstreamName, + UpstreamType: testUpstreamType, }, - encodedState: "foo", // the encoded and decoded state don't match, but that verification is handled one level up. + encodedState: testEncodedState, // the encoded and decoded state don't match, but that verification is handled one level up. wantStatus: http.StatusOK, wantContentType: htmlContentType, - wantBody: getHTMLResult(""), + wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, ""), // no alert message }, { name: "displays error banner when err=login_error param is sent", decodedState: &oidc.UpstreamStateParamData{ - UpstreamName: happyLdapIDPName, - UpstreamType: "ldap", + UpstreamName: testUpstreamName, + UpstreamType: testUpstreamType, }, - encodedState: "foo", + encodedState: testEncodedState, errParam: "login_error", wantStatus: http.StatusOK, wantContentType: htmlContentType, - wantBody: getHTMLResult(` -
- Incorrect username or password. -
-`), + wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, + "Incorrect username or password.", + ), }, { name: "displays error banner when err=internal_error param is sent", decodedState: &oidc.UpstreamStateParamData{ - UpstreamName: happyLdapIDPName, - UpstreamType: "ldap", + UpstreamName: testUpstreamName, + UpstreamType: testUpstreamType, }, - encodedState: "foo", + encodedState: testEncodedState, errParam: "internal_error", wantStatus: http.StatusOK, wantContentType: htmlContentType, - wantBody: getHTMLResult(` -
- An internal error occurred. Please contact your administrator for help. -
-`), + wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, + "An internal error occurred. Please contact your administrator for help.", + ), }, - // If we get an error that we don't recognize, that's also an error, so we - // should probably just tell you to contact your administrator... { + // If we get an error that we don't recognize, that's also an error, so we + // should probably just tell you to contact your administrator... name: "displays generic error banner when unrecognized err param is sent", decodedState: &oidc.UpstreamStateParamData{ - UpstreamName: happyLdapIDPName, - UpstreamType: "ldap", + UpstreamName: testUpstreamName, + UpstreamType: testUpstreamType, }, - encodedState: "foo", + encodedState: testEncodedState, errParam: "some_other_error", wantStatus: http.StatusOK, wantContentType: htmlContentType, - wantBody: getHTMLResult(` -
- An internal error occurred. Please contact your administrator for help. -
-`), + wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, + "An internal error occurred. Please contact your administrator for help.", + ), }, } @@ -100,8 +96,8 @@ func TestGetLogin(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - handler := NewGetHandler(tt.idps) - target := "/some/path/login?state=" + tt.encodedState + handler := NewGetHandler() + target := testPath + "?state=" + tt.encodedState if tt.errParam != "" { target += "&err=" + tt.errParam } @@ -113,44 +109,8 @@ func TestGetLogin(t *testing.T) { require.Equal(t, tt.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) body := rsp.Body.String() + // t.Log("actual body:", body) // useful when updating expected values require.Equal(t, tt.wantBody, body) }) } } - -func getHTMLResult(errorBanner string) string { - happyGetResult := ` - - - Pinniped - - - -

Pinniped

-

some-ldap-idp

-%s - - -
- - -
- -
- - -
- -
- -
- - - - - - - -` - return fmt.Sprintf(happyGetResult, errorBanner) -} diff --git a/internal/oidc/login/login_form.gohtml b/internal/oidc/login/login_form.gohtml deleted file mode 100644 index 6dd4819d..00000000 --- a/internal/oidc/login/login_form.gohtml +++ /dev/null @@ -1,39 +0,0 @@ - - - - {{.Title}} - - - -

Pinniped

-

{{ .IDPName }}

-{{if .HasAlertError}} -
- {{.AlertMessage}} -
-{{end}} -
- -
- - -
- -
- - -
- -
- -
- - - -
- - - diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go index eb9d8251..06444bc1 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/oidc/login/login_handler.go @@ -11,6 +11,7 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/login/loginhtml" "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" ) @@ -84,7 +85,7 @@ func NewHandler( func wrapSecurityHeaders(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - wrapped := securityheader.Wrap(handler) + wrapped := securityheader.WrapWithCustomCSP(handler, loginhtml.ContentSecurityPolicy()) if r.Method == http.MethodPost { // POST requests can result in the form_post html page, so allow it with CSP headers. wrapped = securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy()) diff --git a/internal/oidc/login/login_handler_test.go b/internal/oidc/login/login_handler_test.go index 79a0ee65..11380950 100644 --- a/internal/oidc/login/login_handler_test.go +++ b/internal/oidc/login/login_handler_test.go @@ -417,9 +417,9 @@ func TestLoginEndpoint(t *testing.T) { subject.ServeHTTP(rsp, req) if tt.method == http.MethodPost { - testutil.RequireSecurityHeadersWithFormPostCSPs(t, rsp) + testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) } else { - testutil.RequireSecurityHeadersWithoutFormPostCSPs(t, rsp) + testutil.RequireSecurityHeadersWithLoginPageCSPs(t, rsp) } require.Equal(t, tt.wantStatus, rsp.Code) diff --git a/internal/oidc/login/loginhtml/login_form.css b/internal/oidc/login/loginhtml/login_form.css new file mode 100644 index 00000000..5eba47e0 --- /dev/null +++ b/internal/oidc/login/loginhtml/login_form.css @@ -0,0 +1,94 @@ +/* Copyright 2022 the Pinniped contributors. All Rights Reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +html { + height: 100%; +} + +body { + font-family: "Metropolis-Light", Helvetica, sans-serif; + display: flex; + flex-flow: column wrap; + justify-content: flex-start; + align-items: center; + /* subtle gradient make the login box stand out */ + background: linear-gradient(to top, #f8f8f8, white); + min-height: 100%; +} + +h1 { + font-size: 20px; + margin: 0; +} + +.box { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + border-radius: 4px; + border-color: #ddd; + border-width: 1px; + border-style: solid; + width: 400px; + padding:30px 30px 0; + margin: 60px 20px 0; + background: white; + font-size: 14px; +} + +input { + color: inherit; + font: inherit; + border: 0; + margin: 0; + outline: 0; + padding: 0; +} + +.form-field { + display: flex; + margin-bottom: 30px; +} + +.form-field input[type="password"], .form-field input[type="text"], .form-field input[type="submit"] { + width: 100%; + padding: 1em; +} + +.form-field input[type="password"], .form-field input[type="text"] { + border-radius: 3px; + border-width: 1px; + border-style: solid; + border-color: #a6a6a6; +} + +.form-field input[type="submit"] { + background-color: #218fcf; /* this is a color from the Pinniped logo :) */ + color: #eee; + font-weight: bold; + cursor: pointer; + transition: all .3s; +} + +.form-field input[type="submit"]:focus, .form-field input[type="submit"]:hover { + background-color: #1abfd3; /* this is a color from the Pinniped logo :) */ +} + +.form-field input[type="submit"]:active { + transform: scale(.99); +} + +.hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.alert { + color: crimson; +} diff --git a/internal/oidc/login/loginhtml/login_form.gohtml b/internal/oidc/login/loginhtml/login_form.gohtml new file mode 100644 index 00000000..a92e406f --- /dev/null +++ b/internal/oidc/login/loginhtml/login_form.gohtml @@ -0,0 +1,40 @@ + + + + Pinniped + + + + + +
+
+

Log in to {{.IDPName}}

+
+ {{if .HasAlertError}} +
+ {{.AlertMessage}} +
+ {{end}} +
+ +
+ + +
+
+ + +
+
+ +
+
+
+ + diff --git a/internal/oidc/login/loginhtml/loginhtml.go b/internal/oidc/login/loginhtml/loginhtml.go new file mode 100644 index 00000000..1493979f --- /dev/null +++ b/internal/oidc/login/loginhtml/loginhtml.go @@ -0,0 +1,65 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package loginhtml defines HTML templates used by the Supervisor. +//nolint: gochecknoglobals // This package uses globals to ensure that all parsing and minifying happens at init. +package loginhtml + +import ( + _ "embed" // Needed to trigger //go:embed directives below. + "html/template" + "strings" + + "github.com/tdewolff/minify/v2/minify" + + "go.pinniped.dev/internal/oidc/provider/csp" +) + +var ( + //go:embed login_form.css + rawCSS string + minifiedCSS = mustMinify(minify.CSS(rawCSS)) + + //go:embed login_form.gohtml + rawHTMLTemplate string +) + +// Parse the Go templated HTML and inject functions providing the minified inline CSS and JS. +var parsedHTMLTemplate = template.Must(template.New("login_form.gohtml").Funcs(template.FuncMap{ + "minifiedCSS": func() template.CSS { return template.CSS(minifiedCSS) }, +}).Parse(rawHTMLTemplate)) + +// Generate the CSP header value once since it's effectively constant. +var cspValue = strings.Join([]string{ + `default-src 'none'`, + `style-src '` + csp.Hash(minifiedCSS) + `'`, + `frame-ancestors 'none'`, +}, "; ") + +func mustMinify(s string, err error) string { + if err != nil { + panic(err) + } + return s +} + +// ContentSecurityPolicy returns the Content-Security-Policy header value to make the Template() operate correctly. +// +// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy. +func ContentSecurityPolicy() string { return cspValue } + +// Template returns the html/template.Template for rendering the login page. +func Template() *template.Template { return parsedHTMLTemplate } + +// CSS returns the minified CSS that will be embedded into the page template. +func CSS() string { return minifiedCSS } + +// PageData represents the inputs to the template. +type PageData struct { + State string + IDPName string + HasAlertError bool + AlertMessage string + MinifiedCSS template.CSS + PostPath string +} diff --git a/internal/oidc/login/loginhtml/loginhtml_test.go b/internal/oidc/login/loginhtml/loginhtml_test.go new file mode 100644 index 00000000..a2e91ed1 --- /dev/null +++ b/internal/oidc/login/loginhtml/loginhtml_test.go @@ -0,0 +1,68 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package loginhtml + +import ( + "bytes" + "fmt" + "testing" + + "go.pinniped.dev/internal/testutil" + + "github.com/stretchr/testify/require" +) + +var ( + testExpectedCSS = `html{height:100%}body{font-family:metropolis-light,Helvetica,sans-serif;display:flex;flex-flow:column wrap;justify-content:flex-start;align-items:center;background:linear-gradient(to top,#f8f8f8,white);min-height:100%}h1{font-size:20px;margin:0}.box{display:flex;flex-direction:column;flex-wrap:nowrap;border-radius:4px;border-color:#ddd;border-width:1px;border-style:solid;width:400px;padding:30px 30px 0;margin:60px 20px 0;background:#fff;font-size:14px}input{color:inherit;font:inherit;border:0;margin:0;outline:0;padding:0}.form-field{display:flex;margin-bottom:30px}.form-field input[type=password],.form-field input[type=text],.form-field input[type=submit]{width:100%;padding:1em}.form-field input[type=password],.form-field input[type=text]{border-radius:3px;border-width:1px;border-style:solid;border-color:#a6a6a6}.form-field input[type=submit]{background-color:#218fcf;color:#eee;font-weight:700;cursor:pointer;transition:all .3s}.form-field input[type=submit]:focus,.form-field input[type=submit]:hover{background-color:#1abfd3}.form-field input[type=submit]:active{transform:scale(.99)}.hidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.alert{color:crimson}` + + // It's okay if this changes in the future, but this gives us a chance to eyeball the formatting. + // Our browser-based integration tests should find any incompatibilities. + testExpectedCSP = `default-src 'none'; ` + + `style-src 'sha256-QC9ckaUFAdcN0Ysmu8q8iqCazYFgrJSQDJPa/przPXU='; ` + + `frame-ancestors 'none'` +) + +func TestTemplate(t *testing.T) { + const ( + testUpstreamName = "test-idp-name" + testPath = "test-post-path" + testEncodedState = "test-encoded-state" + testAlert = "test-alert-message" + ) + + var buf bytes.Buffer + pageInputs := &PageData{ + PostPath: testPath, + State: testEncodedState, + IDPName: testUpstreamName, + HasAlertError: true, + AlertMessage: testAlert, + } + + // Render with an alert. + expectedHTMLWithAlert := testutil.ExpectedLoginPageHTML(testExpectedCSS, testUpstreamName, testPath, testEncodedState, testAlert) + require.NoError(t, Template().Execute(&buf, pageInputs)) + // t.Logf("actual value:\n%s", buf.String()) // useful when updating minify library causes new output + require.Equal(t, expectedHTMLWithAlert, buf.String()) + + // Render again without an alert. + pageInputs.HasAlertError = false + expectedHTMLWithoutAlert := testutil.ExpectedLoginPageHTML(testExpectedCSS, testUpstreamName, testPath, testEncodedState, "") + buf = bytes.Buffer{} // clear previous result from buffer + require.NoError(t, Template().Execute(&buf, pageInputs)) + require.Equal(t, expectedHTMLWithoutAlert, buf.String()) +} + +func TestContentSecurityPolicy(t *testing.T) { + require.Equal(t, testExpectedCSP, ContentSecurityPolicy()) +} + +func TestCSS(t *testing.T) { + require.Equal(t, testExpectedCSS, CSS()) +} + +func TestHelpers(t *testing.T) { + require.Equal(t, "test", mustMinify("test", nil)) + require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) }) +} diff --git a/internal/oidc/provider/csp/csp.go b/internal/oidc/provider/csp/csp.go new file mode 100644 index 00000000..d3f97e50 --- /dev/null +++ b/internal/oidc/provider/csp/csp.go @@ -0,0 +1,15 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package csp defines helpers related to HTML Content Security Policies. +package csp + +import ( + "crypto/sha256" + "encoding/base64" +) + +func Hash(s string) string { + hashBytes := sha256.Sum256([]byte(s)) + return "sha256-" + base64.StdEncoding.EncodeToString(hashBytes[:]) +} diff --git a/internal/oidc/provider/csp/csp_test.go b/internal/oidc/provider/csp/csp_test.go new file mode 100644 index 00000000..746d5822 --- /dev/null +++ b/internal/oidc/provider/csp/csp_test.go @@ -0,0 +1,15 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package csp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHash(t *testing.T) { + // Example test vector from https://content-security-policy.com/hash/. + require.Equal(t, "sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=", Hash("doSomething();")) +} diff --git a/internal/oidc/provider/formposthtml/formposthtml.go b/internal/oidc/provider/formposthtml/formposthtml.go index 6552c9a1..b96f0d5d 100644 --- a/internal/oidc/provider/formposthtml/formposthtml.go +++ b/internal/oidc/provider/formposthtml/formposthtml.go @@ -6,13 +6,13 @@ package formposthtml import ( - "crypto/sha256" _ "embed" // Needed to trigger //go:embed directives below. - "encoding/base64" "html/template" "strings" "github.com/tdewolff/minify/v2/minify" + + "go.pinniped.dev/internal/oidc/provider/csp" ) var ( @@ -37,8 +37,8 @@ var parsedHTMLTemplate = template.Must(template.New("form_post.gohtml").Funcs(te // Generate the CSP header value once since it's effectively constant. var cspValue = strings.Join([]string{ `default-src 'none'`, - `script-src '` + cspHash(minifiedJS) + `'`, - `style-src '` + cspHash(minifiedCSS) + `'`, + `script-src '` + csp.Hash(minifiedJS) + `'`, + `style-src '` + csp.Hash(minifiedCSS) + `'`, `img-src data:`, `connect-src *`, `frame-ancestors 'none'`, @@ -51,14 +51,9 @@ func mustMinify(s string, err error) string { return s } -func cspHash(s string) string { - hashBytes := sha256.Sum256([]byte(s)) - return "sha256-" + base64.StdEncoding.EncodeToString(hashBytes[:]) -} - // ContentSecurityPolicy returns the Content-Security-Policy header value to make the Template() operate correctly. // -// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src#:~:text=%27%3Chash-algorithm%3E-%3Cbase64-value%3E%27. +// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy. func ContentSecurityPolicy() string { return cspValue } // Template returns the html/template.Template for rendering the response_type=form_post response page. diff --git a/internal/oidc/provider/formposthtml/formposthtml_test.go b/internal/oidc/provider/formposthtml/formposthtml_test.go index 07fb508a..d5d69c9d 100644 --- a/internal/oidc/provider/formposthtml/formposthtml_test.go +++ b/internal/oidc/provider/formposthtml/formposthtml_test.go @@ -93,10 +93,6 @@ func TestContentSecurityPolicyHashes(t *testing.T) { } func TestHelpers(t *testing.T) { - // These are silly tests but it's easy to we might as well have them. require.Equal(t, "test", mustMinify("test", nil)) require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) }) - - // Example test vector from https://content-security-policy.com/hash/. - require.Equal(t, "sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=", cspHash("doSomething();")) } diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 3da0c2c3..ffa33139 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -139,7 +139,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler( upstreamStateEncoder, csrfCookieEncoder, - login.NewGetHandler(m.upstreamIDPs), + login.NewGetHandler(), login.NewPostHandler(issuer, m.upstreamIDPs, oauthHelperWithKubeStorage), ) diff --git a/internal/testutil/assertions.go b/internal/testutil/assertions.go index b592e07e..ee7bc2ed 100644 --- a/internal/testutil/assertions.go +++ b/internal/testutil/assertions.go @@ -54,7 +54,7 @@ func RequireNumberOfSecretsMatchingLabelSelector(t *testing.T, secrets v1.Secret require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets) } -func RequireSecurityHeadersWithFormPostCSPs(t *testing.T, response *httptest.ResponseRecorder) { +func RequireSecurityHeadersWithFormPostPageCSPs(t *testing.T, response *httptest.ResponseRecorder) { // Loosely confirm that the unique CSPs needed for the form_post page were used. cspHeader := response.Header().Get("Content-Security-Policy") require.Contains(t, cspHeader, "script-src '") // loose assertion @@ -66,8 +66,20 @@ func RequireSecurityHeadersWithFormPostCSPs(t *testing.T, response *httptest.Res requireSecurityHeaders(t, response) } -func RequireSecurityHeadersWithoutFormPostCSPs(t *testing.T, response *httptest.ResponseRecorder) { - // Confirm that the unique CSPs needed for the form_post page were NOT used. +func RequireSecurityHeadersWithLoginPageCSPs(t *testing.T, response *httptest.ResponseRecorder) { + // Loosely confirm that the unique CSPs needed for the login page were used. + cspHeader := response.Header().Get("Content-Security-Policy") + require.Contains(t, cspHeader, "style-src '") // loose assertion + require.NotContains(t, cspHeader, "script-src") // only needed by form_post page + require.NotContains(t, cspHeader, "img-src data:") // only needed by form_post page + require.NotContains(t, cspHeader, "connect-src *") // only needed by form_post page + + // Also require all the usual security headers. + requireSecurityHeaders(t, response) +} + +func RequireSecurityHeadersWithoutCustomCSPs(t *testing.T, response *httptest.ResponseRecorder) { + // Confirm that the unique CSPs needed for the form_post or login page were NOT used. cspHeader := response.Header().Get("Content-Security-Policy") require.NotContains(t, cspHeader, "script-src") require.NotContains(t, cspHeader, "style-src") @@ -79,7 +91,7 @@ func RequireSecurityHeadersWithoutFormPostCSPs(t *testing.T, response *httptest. } func requireSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) { - // Loosely confirm that the generic CSPs were used. + // Loosely confirm that the generic default CSPs were used. cspHeader := response.Header().Get("Content-Security-Policy") require.Contains(t, cspHeader, "default-src 'none'") require.Contains(t, cspHeader, "frame-ancestors 'none'") diff --git a/internal/testutil/loginhtml.go b/internal/testutil/loginhtml.go new file mode 100644 index 00000000..431f708d --- /dev/null +++ b/internal/testutil/loginhtml.go @@ -0,0 +1,68 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "fmt" + + "go.pinniped.dev/internal/here" +) + +func ExpectedLoginPageHTML(wantCSS, wantIDPName, wantPostPath, wantEncodedState, wantAlert string) string { + alertHTML := "" + if wantAlert != "" { + alertHTML = fmt.Sprintf("\n"+ + "
\n"+ + " %s\n"+ + "
\n ", + wantAlert, + ) + } + + // Note that "role", "aria-*", and "alert" attributes are hints to screen readers. + // Also note that some structure and attributes used here are hints to password managers, + // see https://support.1password.com/compatible-website-design/. + // Please take care when changing the HTML of this form, + // and test with a screen reader and password manager after changes. + return here.Docf(` + + + Pinniped + + + + + +
+
+

Log in to %s

+
+ %s +
+ +
+ + +
+
+ + +
+
+ +
+
+
+ + + `, + wantCSS, + wantIDPName, + alertHTML, + wantPostPath, + wantEncodedState, + ) +} From 00d68845c4e148c66d93ed89b315827312a3d392 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 5 May 2022 13:42:23 -0700 Subject: [PATCH 17/26] Add `--flow` to choose login flow in prepare-supervisor-on-kind.sh --- hack/prepare-supervisor-on-kind.sh | 50 ++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/hack/prepare-supervisor-on-kind.sh b/hack/prepare-supervisor-on-kind.sh index 9c17d26c..6a573b1c 100755 --- a/hack/prepare-supervisor-on-kind.sh +++ b/hack/prepare-supervisor-on-kind.sh @@ -25,11 +25,36 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +function log_error() { + RED='\033[0;31m' + NC='\033[0m' + if [[ ${COLORTERM:-unknown} =~ ^(truecolor|24bit)$ ]]; then + echo -e "🙁${RED} Error: $* ${NC}" + else + echo ":( Error: $*" + fi +} + use_oidc_upstream=no use_ldap_upstream=no use_ad_upstream=no +use_flow="" while (("$#")); do case "$1" in + --flow) + shift + # If there are no more command line arguments, or there is another command line argument but it starts with a dash, then error + if [[ "$#" == "0" || "$1" == -* ]]; then + log_error "--flow requires a flow name to be specified (e.g. cli_password or browser_authcode" + exit 1 + fi + if [[ "$1" != "browser_authcode" && "$1" != "cli_password" ]]; then + log_error "--flow must be cli_password or browser_authcode" + exit 1 + fi + use_flow=$1 + shift + ;; --ldap) use_ldap_upstream=yes shift @@ -56,7 +81,7 @@ while (("$#")); do done if [[ "$use_oidc_upstream" == "no" && "$use_ldap_upstream" == "no" && "$use_ad_upstream" == "no" ]]; then - echo "Error: Please use --oidc, --ldap, or --ad to specify which type of upstream identity provider(s) you would like" + log_error "Error: Please use --oidc, --ldap, or --ad to specify which type of upstream identity provider(s) you would like" exit 1 fi @@ -127,6 +152,7 @@ spec: certificateAuthorityData: "$PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER_CA_BUNDLE" authorizationConfig: additionalScopes: [ ${PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES} ] + allowPasswordGrant: true claims: username: "$PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME_CLAIM" groups: "$PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_GROUPS_CLAIM" @@ -196,7 +222,7 @@ EOF fi if [[ "$use_ad_upstream" == "yes" ]]; then - # Make an ActiveDirectoryIdentityProvider. + # Make an ActiveDirectoryIdentityProvider. Needs to be pointed to a real AD server by env vars. cat <kubeconfig +flow_arg="" +if [[ -n "$use_flow" ]]; then + flow_arg="--upstream-identity-provider-flow $use_flow" +fi +https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" ./pinniped get kubeconfig --oidc-skip-browser $flow_arg >kubeconfig # Clear the local CLI cache to ensure that the kubectl command below will need to perform a fresh login. rm -f "$HOME/.config/pinniped/sessions.yaml" @@ -265,25 +295,27 @@ rm -f "$HOME/.config/pinniped/credentials.yaml" echo echo "Ready! 🚀" -if [[ "$use_oidc_upstream" == "yes" ]]; then +if [[ "$use_oidc_upstream" == "yes" || "$use_flow" == "browser_authcode" ]]; then echo echo "To be able to access the login URL shown below, start Chrome like this:" echo " open -a \"Google Chrome\" --args --proxy-server=\"$PINNIPED_TEST_PROXY\"" - echo "Then use these credentials at the Dex login page:" + echo "Note that Chrome must be fully quit before being started with --proxy-server." + echo "Then open the login URL shown below in that new Chrome window." + echo + echo "When prompted for username and password, use these values:" +fi + +if [[ "$use_oidc_upstream" == "yes" ]]; then echo " Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME" echo " Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD" fi if [[ "$use_ldap_upstream" == "yes" ]]; then - echo - echo "When prompted for username and password by the CLI, use these values:" echo " Username: $PINNIPED_TEST_LDAP_USER_CN" echo " Password: $PINNIPED_TEST_LDAP_USER_PASSWORD" fi if [[ "$use_ad_upstream" == "yes" ]]; then - echo - echo "When prompted for username and password by the CLI, use these values:" echo " Username: $PINNIPED_TEST_AD_USER_USER_PRINCIPAL_NAME" echo " Password: $PINNIPED_TEST_AD_USER_PASSWORD" fi From 6e6e1f4add06a40b1b8ac73f189c609572dcff03 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 5 May 2022 13:56:38 -0700 Subject: [PATCH 18/26] Update login page CSS selectors in e2e test --- test/integration/e2e_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index e4a73b66..ea5e0e50 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -1080,9 +1080,9 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo regex := regexp.MustCompile(`\A` + downstream.Spec.Issuer + `/login.+`) browsertest.WaitForURL(t, page, regex) - usernameSelector := "input#username" - passwordSelector := "input[type='password']" - loginButtonSelector := "button#submit" + usernameSelector := "#username" + passwordSelector := "#password" + loginButtonSelector := "#submit" browsertest.WaitForVisibleElements(t, page, usernameSelector, passwordSelector, loginButtonSelector) // Fill in the username and password and click "submit". From ec22b5715b35b5a684c8237bfae18f161fce8d5d Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 5 May 2022 14:46:07 -0700 Subject: [PATCH 19/26] =?UTF-8?q?Add=20Pinniped=20favicon=20to=20login=20U?= =?UTF-8?q?I=20page=20=F0=9F=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/oidc/login/loginhtml/login_form.gohtml | 14 ++++++++++++-- internal/testutil/loginhtml.go | 10 +++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/oidc/login/loginhtml/login_form.gohtml b/internal/oidc/login/loginhtml/login_form.gohtml index a92e406f..15a13fcf 100644 --- a/internal/oidc/login/loginhtml/login_form.gohtml +++ b/internal/oidc/login/loginhtml/login_form.gohtml @@ -1,13 +1,23 @@ - Pinniped + Pinniped Login - +
diff --git a/internal/testutil/loginhtml.go b/internal/testutil/loginhtml.go index 431f708d..f46c55a3 100644 --- a/internal/testutil/loginhtml.go +++ b/internal/testutil/loginhtml.go @@ -20,18 +20,14 @@ func ExpectedLoginPageHTML(wantCSS, wantIDPName, wantPostPath, wantEncodedState, ) } - // Note that "role", "aria-*", and "alert" attributes are hints to screen readers. - // Also note that some structure and attributes used here are hints to password managers, - // see https://support.1password.com/compatible-website-design/. - // Please take care when changing the HTML of this form, - // and test with a screen reader and password manager after changes. return here.Docf(` - Pinniped + Pinniped Login - +
From 4c44f583e9e38af74b541c7886f2e0538eb9fdf6 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 6 May 2022 12:00:46 -0700 Subject: [PATCH 20/26] Don't add pinniped_idp_name pinniped_idp_type params into upstream state --- internal/oidc/auth/auth_handler.go | 32 +++++++++++++++++++++---- internal/oidc/auth/auth_handler_test.go | 24 ++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 0c3df1e8..ae502d3a 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -7,6 +7,7 @@ package auth import ( "fmt" "net/http" + "net/url" "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" @@ -53,6 +54,12 @@ func NewHandler( return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) } + // Note that the client might have used supervisoroidc.AuthorizeUpstreamIDPNameParamName and + // supervisoroidc.AuthorizeUpstreamIDPTypeParamName query params to request a certain upstream IDP. + // The Pinniped CLI has been sending these params since v0.9.0. + // Currently, these are ignored because the Supervisor does not yet support logins when multiple IDPs + // are configured. However, these params should be honored in the future when choosing an upstream + // here, e.g. by calling supervisoroidc.FindUpstreamIDPByNameAndType() when the params are present. oidcUpstream, ldapUpstream, idpType, err := chooseUpstreamIDP(idpLister) if err != nil { plog.WarningErr("authorize upstream config", err) @@ -65,7 +72,7 @@ func NewHandler( // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) } - return handleAuthRequestForOIDCUpstreamAuthcodeGrant(r, w, + return handleAuthRequestForOIDCUpstreamBrowserFlow(r, w, oauthHelperWithoutStorage, generateCSRF, generateNonce, generatePKCE, oidcUpstream, @@ -75,7 +82,7 @@ func NewHandler( ) } - // we know it's an AD/LDAP upstream. + // We know it's an AD/LDAP upstream. if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 || len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. @@ -236,7 +243,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } -func handleAuthRequestForOIDCUpstreamAuthcodeGrant( +func handleAuthRequestForOIDCUpstreamBrowserFlow( r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, @@ -487,7 +494,12 @@ func upstreamStateParam( encoder oidc.Encoder, ) (string, error) { stateParamData := oidc.UpstreamStateParamData{ - AuthParams: authorizeRequester.GetRequestForm().Encode(), + // The auth params might have included supervisoroidc.AuthorizeUpstreamIDPNameParamName and + // supervisoroidc.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers + // that are reading from the encoded upstream state param being built here. + // The UpstreamName and UpstreamType struct fields can be used instead. + // Remove those params here to avoid potential confusion about which should be used later. + AuthParams: removeCustomIDPParams(authorizeRequester.GetRequestForm()).Encode(), UpstreamName: upstreamName, UpstreamType: upstreamType, Nonce: nonceValue, @@ -502,6 +514,18 @@ func upstreamStateParam( return encodedStateParamValue, nil } +func removeCustomIDPParams(params url.Values) url.Values { + p := url.Values{} + // Copy all params. + for k, v := range params { + p[k] = v + } + // Remove the unnecessary params. + delete(p, supervisoroidc.AuthorizeUpstreamIDPNameParamName) + delete(p, supervisoroidc.AuthorizeUpstreamIDPTypeParamName) + return p +} + func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken, codec oidc.Encoder) error { encodedCSRFValue, err := codec.Encode(oidc.CSRFCookieEncodingName, csrfValue) if err != nil { diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index e2b3f000..dc93c42a 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -847,8 +847,6 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), - contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, @@ -856,6 +854,24 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "OIDC upstream browser flow happy path with custom IDP name and type query params, which are excluded from the query params in the upstream state", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": "currently-ignored", "pinniped_idp_type": "oidc"}), + contentType: formContentType, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyStringWithLocationInHref: true, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + }, { name: "OIDC upstream browser flow happy path with extra params that get passed through", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().WithAdditionalAuthcodeParams(map[string]string{"prompt": "consent", "abc": "123", "def": "456"}).Build()), @@ -866,8 +882,6 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), - contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, @@ -885,8 +899,6 @@ func TestAuthorizationEndpoint(t *testing.T) { cookieEncoder: happyCookieEncoder, method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}), - contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), From a4e32d8f3d922f115fda8450ace0db33a8287c49 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 9 May 2022 15:43:36 -0700 Subject: [PATCH 21/26] Extract browsertest.LoginToUpstreamLDAP() integration test helper --- test/integration/cli_test.go | 2 +- test/integration/e2e_test.go | 26 +++++------------ test/integration/supervisor_login_test.go | 2 +- test/integration/supervisor_warnings_test.go | 2 +- test/testlib/browsertest/browsertest.go | 30 ++++++++++++++++++-- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 5a8358ae..1e2d0357 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -338,7 +338,7 @@ func runPinnipedLoginOIDC( require.NoError(t, page.Navigate(loginURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.CLIUpstreamOIDC) + browsertest.LoginToUpstreamOIDC(t, page, env.CLIUpstreamOIDC) // Expect to be redirected to the localhost callback. t.Logf("waiting for redirect to callback") diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index ea5e0e50..b3fa7266 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -245,7 +245,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo require.NoError(t, page.Navigate(loginURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. t.Logf("waiting for response page %s", downstream.Spec.Issuer) @@ -358,7 +358,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo require.NoError(t, page.Navigate(loginURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. t.Logf("waiting for response page %s", downstream.Spec.Issuer) @@ -486,7 +486,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo require.NoError(t, page.Navigate(loginURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. t.Logf("waiting for response page %s", downstream.Spec.Issuer) @@ -965,7 +965,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo ) }) - // Add an OIDC upstream IDP and try using it to authenticate during kubectl commands. + // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands. t.Run("with Supervisor LDAP upstream IDP and browser flow", func(t *testing.T) { testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) @@ -1075,21 +1075,9 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo t.Logf("navigating to login page: %q", loginURL) require.NoError(t, page.Navigate(loginURL)) - // Expect to be redirected to the supervisor's ldap login page. - t.Logf("waiting for redirect to supervisor ldap login page") - regex := regexp.MustCompile(`\A` + downstream.Spec.Issuer + `/login.+`) - browsertest.WaitForURL(t, page, regex) - - usernameSelector := "#username" - passwordSelector := "#password" - loginButtonSelector := "#submit" - browsertest.WaitForVisibleElements(t, page, usernameSelector, passwordSelector, loginButtonSelector) - - // Fill in the username and password and click "submit". - t.Logf("logging into ldap") - require.NoError(t, page.First(usernameSelector).Fill(expectedUsername)) - require.NoError(t, page.First(passwordSelector).Fill(env.SupervisorUpstreamLDAP.TestUserPassword)) - require.NoError(t, page.First(loginButtonSelector).Click()) + // Confirm that we got to the login page, fill out the form, and submit the form. + browsertest.LoginToUpstreamLDAP(t, page, downstream.Spec.Issuer, + expectedUsername, env.SupervisorUpstreamLDAP.TestUserPassword) formpostExpectSuccessState(t, page) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 31089ec4..b849df2e 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -2005,7 +2005,7 @@ func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthor require.NoError(t, page.Navigate(downstreamAuthorizeURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC) // Wait for the login to happen and us be redirected back to a localhost callback. t.Logf("waiting for redirect to callback") diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index 65fa06e1..f4ae43ef 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -448,7 +448,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { require.NoError(t, page.Navigate(loginURL)) // Expect to be redirected to the upstream provider and log in. - browsertest.LoginToUpstream(t, page, env.SupervisorUpstreamOIDC) + browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. t.Logf("waiting for response page %s", downstream.Spec.Issuer) diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index d6686ea6..b04b1e1f 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -125,9 +125,9 @@ func WaitForURL(t *testing.T, page *agouti.Page, pat *regexp.Regexp) { ) } -// LoginToUpstream expects the page to be redirected to one of several known upstream IDPs. +// LoginToUpstreamOIDC expects the page to be redirected to one of several known upstream IDPs. // It knows how to enter the test username/password and submit the upstream login form. -func LoginToUpstream(t *testing.T, page *agouti.Page, upstream testlib.TestOIDCUpstream) { +func LoginToUpstreamOIDC(t *testing.T, page *agouti.Page, upstream testlib.TestOIDCUpstream) { t.Helper() type config struct { @@ -182,3 +182,29 @@ func LoginToUpstream(t *testing.T, page *agouti.Page, upstream testlib.TestOIDCU require.NoError(t, page.First(cfg.PasswordSelector).Fill(upstream.Password)) require.NoError(t, page.First(cfg.LoginButtonSelector).Click()) } + +// LoginToUpstreamLDAP expects the page to be redirected to the Supervisor's login UI for an LDAP/AD IDP. +// It knows how to enter the test username/password and submit the upstream login form. +func LoginToUpstreamLDAP(t *testing.T, page *agouti.Page, issuer, username, password string) { + t.Helper() + + usernameSelector := "#username" + passwordSelector := "#password" + loginButtonSelector := "#submit" + + loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `.+\z`) + require.NoError(t, err) + + // Expect to be redirected to the login page. + t.Logf("waiting for redirect to %s/login page", issuer) + WaitForURL(t, page, loginURLRegexp) + + // Wait for the login page to be rendered. + WaitForVisibleElements(t, page, usernameSelector, passwordSelector, loginButtonSelector) + + // Fill in the username and password and click "submit". + t.Logf("logging in via Supervisor's upstream LDAP/AD login UI page") + require.NoError(t, page.First(usernameSelector).Fill(username)) + require.NoError(t, page.First(passwordSelector).Fill(password)) + require.NoError(t, page.First(loginButtonSelector).Click()) +} From ab302cf2b79e8d2ce4406d7af3ebcf04d053bf15 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 10 May 2022 10:30:32 -0700 Subject: [PATCH 22/26] Add AD via browser login e2e test and refactor e2e tests to share code --- test/integration/e2e_test.go | 396 ++++++++++++++++++----------------- 1 file changed, 201 insertions(+), 195 deletions(-) diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index b3fa7266..998933d9 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -26,6 +26,7 @@ import ( coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/creack/pty" + "github.com/sclevine/agouti" "github.com/stretchr/testify/require" authorizationv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" @@ -57,7 +58,6 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo // Build pinniped CLI. pinnipedExe := testlib.PinnipedCLIPath(t) - tempDir := testutil.TempDir(t) // Infer the downstream issuer URL from the callback associated with the upstream test client registration. issuerURL, err := url.Parse(env.SupervisorUpstreamOIDC.CallbackURL) @@ -72,7 +72,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo require.NoError(t, err) // Save that bundle plus the one that signs the upstream issuer, for test purposes. - testCABundlePath := filepath.Join(tempDir, "test-ca.pem") + testCABundlePath := filepath.Join(testutil.TempDir(t), "test-ca.pem") testCABundlePEM := []byte(string(ca.Bundle()) + "\n" + env.SupervisorUpstreamOIDC.CABundle) testCABundleBase64 := base64.StdEncoding.EncodeToString(testCABundlePEM) require.NoError(t, ioutil.WriteFile(testCABundlePath, testCABundlePEM, 0600)) @@ -108,10 +108,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }) // Add an OIDC upstream IDP and try using it to authenticate during kubectl commands. - t.Run("with Supervisor OIDC upstream IDP and automatic flow", func(t *testing.T) { + t.Run("with Supervisor OIDC upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) { testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. page := browsertest.Open(t) @@ -149,7 +151,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }, idpv1alpha1.PhaseReady) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/oidc-test-sessions.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -162,89 +164,13 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. - start := time.Now() kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6") kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) - // Wrap the stdout and stderr pipes with TeeReaders which will copy each incremental read to an - // in-memory buffer, so we can have the full output available to us at the end. - originalStderrPipe, err := kubectlCmd.StderrPipe() - require.NoError(t, err) - originalStdoutPipe, err := kubectlCmd.StdoutPipe() - require.NoError(t, err) - var stderrPipeBuf, stdoutPipeBuf bytes.Buffer - stderrPipe := io.TeeReader(originalStderrPipe, &stderrPipeBuf) - stdoutPipe := io.TeeReader(originalStdoutPipe, &stdoutPipeBuf) + // Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser. + kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, page) - t.Logf("starting kubectl subprocess") - require.NoError(t, kubectlCmd.Start()) - t.Cleanup(func() { - // Consume readers so that the tee buffers will contain all the output so far. - _, stdoutReadAllErr := readAllCtx(testCtx, stdoutPipe) - _, stderrReadAllErr := readAllCtx(testCtx, stderrPipe) - - // Note that Wait closes the stdout/stderr pipes, so we don't need to close them ourselves. - waitErr := kubectlCmd.Wait() - t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) - - // Upon failure, print the full output so far of the kubectl command. - var testAlreadyFailedErr error - if t.Failed() { - testAlreadyFailedErr = errors.New("test failed prior to clean up function") - } - cleanupErrs := utilerrors.NewAggregate([]error{waitErr, stdoutReadAllErr, stderrReadAllErr, testAlreadyFailedErr}) - - if cleanupErrs != nil { - t.Logf("kubectl stdout was:\n----start of stdout\n%s\n----end of stdout", stdoutPipeBuf.String()) - t.Logf("kubectl stderr was:\n----start of stderr\n%s\n----end of stderr", stderrPipeBuf.String()) - } - require.NoErrorf(t, cleanupErrs, "kubectl process did not exit cleanly and/or the test failed. "+ - "Note: if kubectl's first call to the Pinniped CLI results in the Pinniped CLI returning an error, "+ - "then kubectl may call the Pinniped CLI again, which may hang because it will wait for the user "+ - "to finish the login. This test will kill the kubectl process after a timeout. In this case, the "+ - " kubectl output printed above will include multiple prompts for the user to enter their authcode.", - ) - }) - - // Start a background goroutine to read stderr from the CLI and parse out the login URL. - loginURLChan := make(chan string, 1) - spawnTestGoroutine(testCtx, t, func() error { - reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderrPipe)) - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - loginURL, err := url.Parse(strings.TrimSpace(scanner.Text())) - if err == nil && loginURL.Scheme == "https" { - loginURLChan <- loginURL.String() // this channel is buffered so this will not block - return nil - } - } - return fmt.Errorf("expected stderr to contain login URL") - }) - - // Start a background goroutine to read stdout from kubectl and return the result as a string. - kubectlOutputChan := make(chan string, 1) - spawnTestGoroutine(testCtx, t, func() error { - output, err := readAllCtx(testCtx, stdoutPipe) - if err != nil { - return err - } - t.Logf("kubectl output:\n%s\n", output) - kubectlOutputChan <- string(output) // this channel is buffered so this will not block - return nil - }) - - // Wait for the CLI to print out the login URL and open the browser to it. - t.Logf("waiting for CLI to output login URL") - var loginURL string - select { - case <-time.After(1 * time.Minute): - require.Fail(t, "timed out waiting for login URL") - case loginURL = <-loginURLChan: - } - t.Logf("navigating to login page: %q", loginURL) - require.NoError(t, page.Navigate(loginURL)) - - // Expect to be redirected to the upstream provider and log in. + // Confirm that we got to the upstream IDP's login page, fill out the form, and submit the form. browsertest.LoginToUpstreamOIDC(t, page, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. @@ -255,17 +181,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo // It should now be in the "success" state. formpostExpectSuccessState(t, page) - // Expect the CLI to output a list of namespaces. - t.Logf("waiting for kubectl to output namespace list") - var kubectlOutput string - select { - case <-time.After(1 * time.Minute): - require.Fail(t, "timed out waiting for kubectl output") - case kubectlOutput = <-kubectlOutputChan: - } - requireKubectlGetNamespaceOutput(t, env, kubectlOutput) - - t.Logf("first kubectl command took %s", time.Since(start).String()) + requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, @@ -281,6 +197,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. page := browsertest.Open(t) @@ -318,7 +236,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }, idpv1alpha1.PhaseReady) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/oidc-test-sessions-manual.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", "--concierge-api-group-suffix", env.APIGroupSuffix, @@ -395,6 +314,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. page := browsertest.Open(t) @@ -440,7 +361,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }, idpv1alpha1.PhaseReady) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/oidc-test-sessions-manual.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", "--concierge-api-group-suffix", env.APIGroupSuffix, @@ -534,6 +456,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + expectedUsername := env.SupervisorUpstreamOIDC.Username expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups @@ -569,7 +493,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }, idpv1alpha1.PhaseReady) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/oidc-test-sessions-password-grant.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", "--concierge-api-group-suffix", env.APIGroupSuffix, @@ -620,6 +545,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + // Create upstream OIDC provider and wait for it to become ready. oidcIdentityProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, @@ -640,7 +567,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }, idpv1alpha1.PhaseReady) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/oidc-test-sessions-password-grant-negative-test.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", "--concierge-api-group-suffix", env.APIGroupSuffix, @@ -700,6 +628,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { t.Skip("LDAP integration test requires connectivity to an LDAP server") } @@ -710,7 +640,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo setupClusterForEndToEndLDAPTest(t, expectedUsername, env) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/ldap-test-sessions.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -760,6 +690,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { t.Skip("LDAP integration test requires connectivity to an LDAP server") } @@ -770,7 +702,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo setupClusterForEndToEndLDAPTest(t, expectedUsername, env) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/ldap-test-with-env-vars-sessions.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -832,6 +764,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { t.Skip("Active Directory integration test requires connectivity to an LDAP server") } @@ -845,7 +779,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/ad-test-sessions.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -895,6 +829,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { t.Skip("ActiveDirectory integration test requires connectivity to an LDAP server") } @@ -909,7 +845,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/ad-test-with-env-vars-sessions.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -965,11 +901,13 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo ) }) - // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands. - t.Run("with Supervisor LDAP upstream IDP and browser flow", func(t *testing.T) { + // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the browser flow. + t.Run("with Supervisor LDAP upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) { testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) t.Cleanup(cancel) + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. page := browsertest.Open(t) @@ -979,7 +917,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo setupClusterForEndToEndLDAPTest(t, expectedUsername, env) // Use a specific session cache for this test. - sessionCachePath := tempDir + "/ldap-test-sessions.yaml" + sessionCachePath := tempDir + "/test-sessions.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -993,105 +931,19 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo }) // Run "kubectl get namespaces" which should trigger a browser login via the plugin. - start := time.Now() kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6") kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) - // Wrap the stdout and stderr pipes with TeeReaders which will copy each incremental read to an - // in-memory buffer, so we can have the full output available to us at the end. - originalStderrPipe, err := kubectlCmd.StderrPipe() - require.NoError(t, err) - originalStdoutPipe, err := kubectlCmd.StdoutPipe() - require.NoError(t, err) - var stderrPipeBuf, stdoutPipeBuf bytes.Buffer - stderrPipe := io.TeeReader(originalStderrPipe, &stderrPipeBuf) - stdoutPipe := io.TeeReader(originalStdoutPipe, &stdoutPipeBuf) + // Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser. + kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, page) - t.Logf("starting kubectl subprocess") - require.NoError(t, kubectlCmd.Start()) - t.Cleanup(func() { - // Consume readers so that the tee buffers will contain all the output so far. - _, stdoutReadAllErr := readAllCtx(testCtx, stdoutPipe) - _, stderrReadAllErr := readAllCtx(testCtx, stderrPipe) - - // Note that Wait closes the stdout/stderr pipes, so we don't need to close them ourselves. - waitErr := kubectlCmd.Wait() - t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) - - // Upon failure, print the full output so far of the kubectl command. - var testAlreadyFailedErr error - if t.Failed() { - testAlreadyFailedErr = errors.New("test failed prior to clean up function") - } - cleanupErrs := utilerrors.NewAggregate([]error{waitErr, stdoutReadAllErr, stderrReadAllErr, testAlreadyFailedErr}) - - if cleanupErrs != nil { - t.Logf("kubectl stdout was:\n----start of stdout\n%s\n----end of stdout", stdoutPipeBuf.String()) - t.Logf("kubectl stderr was:\n----start of stderr\n%s\n----end of stderr", stderrPipeBuf.String()) - } - require.NoErrorf(t, cleanupErrs, "kubectl process did not exit cleanly and/or the test failed. "+ - "Note: if kubectl's first call to the Pinniped CLI results in the Pinniped CLI returning an error, "+ - "then kubectl may call the Pinniped CLI again, which may hang because it will wait for the user "+ - "to finish the login. This test will kill the kubectl process after a timeout. In this case, the "+ - " kubectl output printed above will include multiple prompts for the user to enter their authcode.", - ) - }) - - // Start a background goroutine to read stderr from the CLI and parse out the login URL. - loginURLChan := make(chan string, 1) - spawnTestGoroutine(testCtx, t, func() error { - reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderrPipe)) - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - loginURL, err := url.Parse(strings.TrimSpace(scanner.Text())) - if err == nil && loginURL.Scheme == "https" { - loginURLChan <- loginURL.String() // this channel is buffered so this will not block - return nil - } - } - return fmt.Errorf("expected stderr to contain login URL") - }) - - // Start a background goroutine to read stdout from kubectl and return the result as a string. - kubectlOutputChan := make(chan string, 1) - spawnTestGoroutine(testCtx, t, func() error { - output, err := readAllCtx(testCtx, stdoutPipe) - if err != nil { - return err - } - t.Logf("kubectl output:\n%s\n", output) - kubectlOutputChan <- string(output) // this channel is buffered so this will not block - return nil - }) - - // Wait for the CLI to print out the login URL and open the browser to it. - t.Logf("waiting for CLI to output login URL") - var loginURL string - select { - case <-time.After(1 * time.Minute): - require.Fail(t, "timed out waiting for login URL") - case loginURL = <-loginURLChan: - } - t.Logf("navigating to login page: %q", loginURL) - require.NoError(t, page.Navigate(loginURL)) - - // Confirm that we got to the login page, fill out the form, and submit the form. + // Confirm that we got to the Supervisor's login page, fill out the form, and submit the form. browsertest.LoginToUpstreamLDAP(t, page, downstream.Spec.Issuer, expectedUsername, env.SupervisorUpstreamLDAP.TestUserPassword) formpostExpectSuccessState(t, page) - // Expect the CLI to output a list of namespaces. - t.Logf("waiting for kubectl to output namespace list") - var kubectlOutput string - select { - case <-time.After(1 * time.Minute): - require.Fail(t, "timed out waiting for kubectl output") - case kubectlOutput = <-kubectlOutputChan: - } - requireKubectlGetNamespaceOutput(t, env, kubectlOutput) - - t.Logf("first kubectl command took %s", time.Since(start).String()) + requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, @@ -1102,6 +954,160 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo expectedGroups, ) }) + + // Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands, using the browser flow. + t.Run("with Supervisor Active Directory upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) { + testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests + + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. + page := browsertest.Open(t) + + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("Active Directory integration test requires connectivity to an LDAP server") + } + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + + expectedUsername := env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue + expectedGroups := env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames + + setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/test-sessions.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-ca-bundle", testCABundlePath, + "--upstream-identity-provider-flow", "browser_authcode", + "--oidc-session-cache", sessionCachePath, + }) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin. + kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath, "-v", "6") + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + + // Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser. + kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, page) + + // Confirm that we got to the Supervisor's login page, fill out the form, and submit the form. + browsertest.LoginToUpstreamLDAP(t, page, downstream.Spec.Issuer, + expectedUsername, env.SupervisorUpstreamActiveDirectory.TestUserPassword) + + formpostExpectSuccessState(t, page) + + requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, + downstream, + kubeconfigPath, + sessionCachePath, + pinnipedExe, + expectedUsername, + expectedGroups, + ) + }) +} + +func startKubectlAndOpenAuthorizationURLInBrowser(testCtx context.Context, t *testing.T, kubectlCmd *exec.Cmd, page *agouti.Page) chan string { + // Wrap the stdout and stderr pipes with TeeReaders which will copy each incremental read to an + // in-memory buffer, so we can have the full output available to us at the end. + originalStderrPipe, err := kubectlCmd.StderrPipe() + require.NoError(t, err) + originalStdoutPipe, err := kubectlCmd.StdoutPipe() + require.NoError(t, err) + var stderrPipeBuf, stdoutPipeBuf bytes.Buffer + stderrPipe := io.TeeReader(originalStderrPipe, &stderrPipeBuf) + stdoutPipe := io.TeeReader(originalStdoutPipe, &stdoutPipeBuf) + + t.Logf("starting kubectl subprocess") + require.NoError(t, kubectlCmd.Start()) + t.Cleanup(func() { + // Consume readers so that the tee buffers will contain all the output so far. + _, stdoutReadAllErr := readAllCtx(testCtx, stdoutPipe) + _, stderrReadAllErr := readAllCtx(testCtx, stderrPipe) + + // Note that Wait closes the stdout/stderr pipes, so we don't need to close them ourselves. + waitErr := kubectlCmd.Wait() + t.Logf("kubectl subprocess exited with code %d", kubectlCmd.ProcessState.ExitCode()) + + // Upon failure, print the full output so far of the kubectl command. + var testAlreadyFailedErr error + if t.Failed() { + testAlreadyFailedErr = errors.New("test failed prior to clean up function") + } + cleanupErrs := utilerrors.NewAggregate([]error{waitErr, stdoutReadAllErr, stderrReadAllErr, testAlreadyFailedErr}) + + if cleanupErrs != nil { + t.Logf("kubectl stdout was:\n----start of stdout\n%s\n----end of stdout", stdoutPipeBuf.String()) + t.Logf("kubectl stderr was:\n----start of stderr\n%s\n----end of stderr", stderrPipeBuf.String()) + } + require.NoErrorf(t, cleanupErrs, "kubectl process did not exit cleanly and/or the test failed. "+ + "Note: if kubectl's first call to the Pinniped CLI results in the Pinniped CLI returning an error, "+ + "then kubectl may call the Pinniped CLI again, which may hang because it will wait for the user "+ + "to finish the login. This test will kill the kubectl process after a timeout. In this case, the "+ + " kubectl output printed above will include multiple prompts for the user to enter their authcode.", + ) + }) + + // Start a background goroutine to read stderr from the CLI and parse out the login URL. + loginURLChan := make(chan string, 1) + spawnTestGoroutine(testCtx, t, func() error { + reader := bufio.NewReader(testlib.NewLoggerReader(t, "stderr", stderrPipe)) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + loginURL, err := url.Parse(strings.TrimSpace(scanner.Text())) + if err == nil && loginURL.Scheme == "https" { + loginURLChan <- loginURL.String() // this channel is buffered so this will not block + return nil + } + } + return fmt.Errorf("expected stderr to contain login URL") + }) + + // Start a background goroutine to read stdout from kubectl and return the result as a string. + kubectlOutputChan := make(chan string, 1) + spawnTestGoroutine(testCtx, t, func() error { + output, err := readAllCtx(testCtx, stdoutPipe) + if err != nil { + return err + } + t.Logf("kubectl output:\n%s\n", output) + kubectlOutputChan <- string(output) // this channel is buffered so this will not block + return nil + }) + + // Wait for the CLI to print out the login URL and open the browser to it. + t.Logf("waiting for CLI to output login URL") + var loginURL string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for login URL") + case loginURL = <-loginURLChan: + } + t.Logf("navigating to login page: %q", loginURL) + require.NoError(t, page.Navigate(loginURL)) + + return kubectlOutputChan +} + +func waitForKubectlOutput(t *testing.T, kubectlOutputChan chan string) string { + t.Logf("waiting for kubectl output") + var kubectlOutput string + select { + case <-time.After(1 * time.Minute): + require.Fail(t, "timed out waiting for kubectl output") + case kubectlOutput = <-kubectlOutputChan: + } + return kubectlOutput } func setupClusterForEndToEndLDAPTest(t *testing.T, username string, env *testlib.TestEnv) { From 0b106c245e038de0d6f691e44a24ff207a22a535 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 10 May 2022 12:54:40 -0700 Subject: [PATCH 23/26] Add LDAP browser flow login test to supervisor_login_test.go --- test/integration/supervisor_login_test.go | 166 +++++++++++++++++----- test/testlib/browsertest/browsertest.go | 2 +- test/testlib/iotest.go | 4 +- 3 files changed, 136 insertions(+), 36 deletions(-) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index b849df2e..880ee0b3 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -47,7 +47,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { maybeSkip func(t *testing.T) createTestUser func(t *testing.T) (string, string) deleteTestUser func(t *testing.T, username string) - requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) + requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) createIDP func(t *testing.T) string wantDownstreamIDTokenSubjectToMatch string wantDownstreamIDTokenUsernameToMatch func(username string) string @@ -80,7 +80,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, idpv1alpha1.PhaseReady) return oidcIDP.Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { pinnipedSessionData := pinnipedSession.Custom pinnipedSessionData.OIDC.UpstreamIssuer = "wrong-issuer" @@ -115,7 +115,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, idpv1alpha1.PhaseReady) return oidcIDP.Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { fositeSessionData := pinnipedSession.Fosite fositeSessionData.Claims.Extra["username"] = "some-incorrect-username" @@ -169,7 +169,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, idpv1alpha1.PhaseReady) return oidcIDP.Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { fositeSessionData := pinnipedSession.Fosite fositeSessionData.Claims.Extra["username"] = "some-incorrect-username" @@ -199,7 +199,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, idpv1alpha1.PhaseReady) return oidcIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamOIDC.Username, // username to present to server during login @@ -267,7 +267,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -301,6 +301,72 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, + { + name: "ldap with browser flow", + maybeSkip: func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + }, + createIDP: func(t *testing.T) string { + t.Helper() + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, + }, + ) + ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ + Host: env.SupervisorUpstreamLDAP.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), + }, + Bind: idpv1alpha1.LDAPIdentityProviderBind{ + SecretName: secret.Name, + }, + UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ + Base: env.SupervisorUpstreamLDAP.UserSearchBase, + Filter: "", + Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ + Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, + UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, + }, + }, + GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ + Base: env.SupervisorUpstreamLDAP.GroupSearchBase, + Filter: "", + Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ + GroupName: "dn", + }, + }, + }, idpv1alpha1.LDAPPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) + return ldapIDP.Name + }, + createTestUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamLDAP.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ + "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), + ) + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + }, { name: "ldap skip group refresh", maybeSkip: func(t *testing.T) { @@ -350,7 +416,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -440,7 +506,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -522,7 +588,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login @@ -596,7 +662,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -674,7 +740,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, time.Minute, 500*time.Millisecond) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -781,7 +847,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, time.Minute, 500*time.Millisecond) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -844,7 +910,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -871,7 +937,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, - }, { + }, + { name: "activedirectory with custom options", maybeSkip: func(t *testing.T) { t.Helper() @@ -921,7 +988,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login @@ -1003,7 +1070,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, time.Minute, 500*time.Millisecond) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -1099,7 +1166,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, time.Minute, 500*time.Millisecond) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -1168,7 +1235,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { deleteTestUser: func(t *testing.T, username string) { testlib.DeleteTestADUser(t, env, username) }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, testUserName, // username to present to server during login @@ -1230,7 +1297,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { deleteTestUser: func(t *testing.T, username string) { testlib.DeleteTestADUser(t, env, username) }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, testUserName, // username to present to server during login @@ -1292,7 +1359,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { deleteTestUser: func(t *testing.T, username string) { testlib.DeleteTestADUser(t, env, username) }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, testUserName, // username to present to server during login @@ -1348,7 +1415,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login @@ -1409,7 +1476,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -1489,7 +1556,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, idpv1alpha1.LDAPPhaseReady) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -1573,6 +1640,7 @@ func requireSuccessfulLDAPIdentityProviderConditions(t *testing.T, ldapIDP *idpv {"LDAPConnectionValid", "True", "Success"}, }, conditionsSummary) } + func requireSuccessfulActiveDirectoryIdentityProviderConditions(t *testing.T, adIDP *idpv1alpha1.ActiveDirectoryIdentityProvider, expectedActiveDirectoryConnectionValidMessage string) { require.Len(t, adIDP.Status.Conditions, 4) @@ -1669,7 +1737,7 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes func testSupervisorLogin( t *testing.T, createIDP func(t *testing.T) string, - requestAuthorization func(t *testing.T, downstreamAuthorizeURL string, downstreamCallbackURL string, username string, password string, httpClient *http.Client), + requestAuthorization func(t *testing.T, downstreamIssuer string, downstreamAuthorizeURL string, downstreamCallbackURL string, username string, password string, httpClient *http.Client), editRefreshSessionDataWithoutBreaking func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string) []string, breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string), createTestUser func(t *testing.T) (string, string), @@ -1769,7 +1837,9 @@ func testSupervisorLogin( username, password := "", "" if createTestUser != nil { username, password = createTestUser(t) - defer deleteTestUser(t, username) + if deleteTestUser != nil { + defer deleteTestUser(t, username) + } } // Perform OIDC discovery for our downstream. @@ -1784,6 +1854,9 @@ func testSupervisorLogin( localCallbackServer := startLocalCallbackServer(t) // Form the OAuth2 configuration corresponding to our CLI client. + // Note that this is not using response_type=form_post, so the Supervisor will redirect to the callback endpoint + // directly, without using the Javascript form_post HTML page to POST back to the callback endpoint. The e2e + // tests which use the Pinniped CLI are testing the form_post part of the flow, so that is covered elsewhere. downstreamOAuth2Config := oauth2.Config{ // This is the hardcoded public client that the supervisor supports. ClientID: "pinniped-cli", @@ -1807,7 +1880,7 @@ func testSupervisorLogin( ) // Perform parameterized auth code acquisition. - requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient) + requestAuthorization(t, downstream.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient) // Expect that our callback handler was invoked. callback := localCallbackServer.waitForCallback(10 * time.Second) @@ -1984,7 +2057,7 @@ func verifyTokenResponse( require.True(t, strings.HasPrefix(tokenResponse.RefreshToken, "pin_rt_"), "token %q did not have expected prefix 'pin_rt_'", tokenResponse.RefreshToken) } -func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { +func requestAuthorizationUsingBrowserAuthcodeFlowOIDC(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { t.Helper() env := testlib.IntegrationEnv(t) @@ -1992,12 +2065,7 @@ func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthor defer cancelFunc() // Make the authorize request once "manually" so we can check its response security headers. - authorizeRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil) - require.NoError(t, err) - authorizeResp, err := httpClient.Do(authorizeRequest) - require.NoError(t, err) - require.NoError(t, authorizeResp.Body.Close()) - expectSecurityHeaders(t, authorizeResp, false) + makeAuthorizationRequestAndRequireSecurityHeaders(ctx, t, downstreamAuthorizeURL, httpClient) // Open the web browser and navigate to the downstream authorize URL. page := browsertest.Open(t) @@ -2013,6 +2081,38 @@ func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthor browsertest.WaitForURL(t, page, callbackURLPattern) } +func requestAuthorizationUsingBrowserAuthcodeFlowLDAP(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) { + t.Helper() + + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Minute) + defer cancelFunc() + + // Make the authorize request once "manually" so we can check its response security headers. + makeAuthorizationRequestAndRequireSecurityHeaders(ctx, t, downstreamAuthorizeURL, httpClient) + + // Open the web browser and navigate to the downstream authorize URL. + page := browsertest.Open(t) + t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL)) + require.NoError(t, page.Navigate(downstreamAuthorizeURL)) + + // Expect to be redirected to the upstream provider and log in. + browsertest.LoginToUpstreamLDAP(t, page, downstreamIssuer, username, password) + + // Wait for the login to happen and us be redirected back to a localhost callback. + t.Logf("waiting for redirect to callback") + callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(downstreamCallbackURL) + `\?.+\z`) + browsertest.WaitForURL(t, page, callbackURLPattern) +} + +func makeAuthorizationRequestAndRequireSecurityHeaders(ctx context.Context, t *testing.T, downstreamAuthorizeURL string, httpClient *http.Client) { + authorizeRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil) + require.NoError(t, err) + authorizeResp, err := httpClient.Do(authorizeRequest) + require.NoError(t, err) + require.NoError(t, authorizeResp.Body.Close()) + expectSecurityHeaders(t, authorizeResp, false) +} + func requestAuthorizationUsingCLIPasswordFlow(t *testing.T, downstreamAuthorizeURL, upstreamUsername, upstreamPassword string, httpClient *http.Client, wantErr bool) { t.Helper() diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index b04b1e1f..fa5325f9 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -112,7 +112,7 @@ func WaitForURL(t *testing.T, page *agouti.Page, pat *regexp.Regexp) { func(requireEventually *require.Assertions) { url, err := page.URL() if url != lastURL { - t.Logf("saw URL %s", url) + t.Logf("saw URL %s", testlib.MaskTokens(url)) lastURL = url } requireEventually.NoError(err) diff --git a/test/testlib/iotest.go b/test/testlib/iotest.go index 6f59f659..4837a165 100644 --- a/test/testlib/iotest.go +++ b/test/testlib/iotest.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testlib @@ -36,7 +36,7 @@ func (l *testlogReader) Read(p []byte) (n int, err error) { } // MaskTokens makes a best-effort attempt to mask out things that look like secret tokens in test output. -// The goal is more to have readable test output than for any security reason. +// Provides more readable test output, but also obscures sensitive state params and authcodes from public test output. func MaskTokens(in string) string { var tokenLike = regexp.MustCompile(`(?mi)[a-zA-Z0-9._-]{30,}|[a-zA-Z0-9]{20,}`) return tokenLike.ReplaceAllStringFunc(in, func(t string) string { From aa732a41fbff120f28cf51b18f510fce550a2112 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 10 May 2022 16:22:07 -0700 Subject: [PATCH 24/26] Add LDAP browser flow login failure tests to supervisor_login_test.go Also do some refactoring to share more common test setup code in supervisor_login_test.go. --- .../oidc/login/loginhtml/login_form.gohtml | 2 +- internal/testutil/loginhtml.go | 2 +- test/integration/e2e_test.go | 2 +- test/integration/supervisor_login_test.go | 1141 +++++------------ test/testlib/browsertest/browsertest.go | 34 +- 5 files changed, 382 insertions(+), 799 deletions(-) diff --git a/internal/oidc/login/loginhtml/login_form.gohtml b/internal/oidc/login/loginhtml/login_form.gohtml index 15a13fcf..c1ab8ba3 100644 --- a/internal/oidc/login/loginhtml/login_form.gohtml +++ b/internal/oidc/login/loginhtml/login_form.gohtml @@ -26,7 +26,7 @@ Notes:
{{if .HasAlertError}}
- {{.AlertMessage}} + {{.AlertMessage}}
{{end}}
diff --git a/internal/testutil/loginhtml.go b/internal/testutil/loginhtml.go index f46c55a3..0cf20bfa 100644 --- a/internal/testutil/loginhtml.go +++ b/internal/testutil/loginhtml.go @@ -14,7 +14,7 @@ func ExpectedLoginPageHTML(wantCSS, wantIDPName, wantPostPath, wantEncodedState, if wantAlert != "" { alertHTML = fmt.Sprintf("\n"+ "
\n"+ - " %s\n"+ + " %s\n"+ "
\n ", wantAlert, ) diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 998933d9..0e16bde2 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -50,7 +50,7 @@ import ( ) // TestE2EFullIntegration_Browser tests a full integration scenario that combines the supervisor, concierge, and CLI. -func TestE2EFullIntegration_Browser(t *testing.T) { // nolint:gocyclo +func TestE2EFullIntegration_Browser(t *testing.T) { env := testlib.IntegrationEnv(t) topSetupCtx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Minute) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 880ee0b3..3713175a 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "encoding/base64" "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" @@ -38,10 +39,127 @@ import ( "go.pinniped.dev/test/testlib/browsertest" ) -// nolint:gocyclo func TestSupervisorLogin_Browser(t *testing.T) { env := testlib.IntegrationEnv(t) + skipNever := func(t *testing.T) { + // never need to skip this test + } + + skipLDAPTests := func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + } + + skipActiveDirectoryTests := func(t *testing.T) { + t.Helper() + skipLDAPTests(t) + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + } + + basicOIDCIdentityProviderSpec := func() idpv1alpha1.OIDCIdentityProviderSpec { + return 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, + }, + } + } + + createActiveDirectoryIdentityProvider := func(t *testing.T, edit func(spec *idpv1alpha1.ActiveDirectoryIdentityProviderSpec)) (*idpv1alpha1.ActiveDirectoryIdentityProvider, *v1.Secret) { + t.Helper() + + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, + }, + ) + + spec := idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + Host: env.SupervisorUpstreamActiveDirectory.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), + }, + Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ + SecretName: secret.Name, + }, + } + + if edit != nil { + edit(&spec) + } + + adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, spec, idpv1alpha1.ActiveDirectoryPhaseReady) + + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + spec.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) + + return adIDP, secret + } + + createLDAPIdentityProvider := func(t *testing.T, edit func(spec *idpv1alpha1.LDAPIdentityProviderSpec)) (*idpv1alpha1.LDAPIdentityProvider, *v1.Secret) { + t.Helper() + + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, + }, + ) + + spec := idpv1alpha1.LDAPIdentityProviderSpec{ + Host: env.SupervisorUpstreamLDAP.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), + }, + Bind: idpv1alpha1.LDAPIdentityProviderBind{ + SecretName: secret.Name, + }, + UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ + Base: env.SupervisorUpstreamLDAP.UserSearchBase, + Filter: "", + Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ + Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, + UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, + }, + }, + GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ + Base: env.SupervisorUpstreamLDAP.GroupSearchBase, + Filter: "", + Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ + GroupName: "dn", + }, + }, + } + + if edit != nil { + edit(&spec) + } + + ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, spec, idpv1alpha1.LDAPPhaseReady) + + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + spec.Host, env.SupervisorUpstreamLDAP.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) + + return ldapIDP, secret + } + tests := []struct { name string maybeSkip func(t *testing.T) @@ -49,6 +167,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { deleteTestUser func(t *testing.T, username string) requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) createIDP func(t *testing.T) string + wantLocalhostCallbackToNeverHappen bool wantDownstreamIDTokenSubjectToMatch string wantDownstreamIDTokenUsernameToMatch func(username string) string wantDownstreamIDTokenGroups []string @@ -63,22 +182,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string }{ { - name: "oidc with default username and groups claim settings", - maybeSkip: func(t *testing.T) { - // never need to skip this test - }, + name: "oidc with default username and groups claim settings", + maybeSkip: skipNever, createIDP: func(t *testing.T) string { - t.Helper() - oidcIDP := 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, - }, - }, idpv1alpha1.PhaseReady) - return oidcIDP.Name + return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { @@ -91,29 +198,18 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, { - name: "oidc with custom username and groups claim settings", - maybeSkip: func(t *testing.T) { - // never need to skip this test - }, + name: "oidc with custom username and groups claim settings", + maybeSkip: skipNever, createIDP: func(t *testing.T) string { - t.Helper() - oidcIDP := 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, - }, - Claims: idpv1alpha1.OIDCClaims{ - Username: env.SupervisorUpstreamOIDC.UsernameClaim, - Groups: env.SupervisorUpstreamOIDC.GroupsClaim, - }, - AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ - AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, - }, - }, idpv1alpha1.PhaseReady) - return oidcIDP.Name + spec := basicOIDCIdentityProviderSpec() + spec.Claims = idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + } + spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, + } + return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { @@ -134,12 +230,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, }, { - name: "oidc without refresh token", - maybeSkip: func(t *testing.T) { - // never need to skip this test - }, + name: "oidc without refresh token", + maybeSkip: skipNever, createIDP: func(t *testing.T) string { - t.Helper() var additionalScopes []string // keep all the scopes except for offline access so we can test the access token based refresh flow. if len(env.ToolsNamespace) == 0 { @@ -151,23 +244,15 @@ func TestSupervisorLogin_Browser(t *testing.T) { } } } - oidcIDP := 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, - }, - Claims: idpv1alpha1.OIDCClaims{ - Username: env.SupervisorUpstreamOIDC.UsernameClaim, - Groups: env.SupervisorUpstreamOIDC.GroupsClaim, - }, - AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ - AdditionalScopes: additionalScopes, - }, - }, idpv1alpha1.PhaseReady) - return oidcIDP.Name + spec := basicOIDCIdentityProviderSpec() + spec.Claims = idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + } + spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: additionalScopes, + } + return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { @@ -179,25 +264,14 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, { - name: "oidc with CLI password flow", - maybeSkip: func(t *testing.T) { - // never need to skip this test - }, + name: "oidc with CLI password flow", + maybeSkip: skipNever, createIDP: func(t *testing.T) string { - t.Helper() - oidcIDP := 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) - return oidcIDP.Name + spec := basicOIDCIdentityProviderSpec() + spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{ + AllowPasswordGrant: true, // allow the CLI password flow for this OIDCIdentityProvider + } + return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -220,52 +294,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + 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) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -302,52 +335,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "ldap with browser flow", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap with browser flow", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name }, createTestUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test @@ -368,53 +360,68 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "ldap skip group refresh", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap with browser flow with wrong password", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - SkipGroupRefresh: true, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createTestUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + "this is the wrong password" // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentials, + wantLocalhostCallbackToNeverHappen: true, // we should have been sent back to the login page to retry login + }, + { + name: "ldap with browser flow with wrong username", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createTestUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return "this is the wrong username", // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentials, + wantLocalhostCallbackToNeverHappen: true, // we should have been sent back to the login page to retry login + }, + { + name: "ldap with browser flow with wrong password and then correct password", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createTestUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentialsAndThenGoodCredentials, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamLDAP.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ + "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), + ) + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + }, + { + name: "ldap skip group refresh", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, func(spec *idpv1alpha1.LDAPIdentityProviderSpec) { + spec.GroupSearch.SkipGroupRefresh = true + }) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -454,10 +461,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { { name: "ldap with email as username and group search base that doesn't return anything, and using an LDAP provider which supports TLS", maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } + skipLDAPTests(t) if env.SupervisorUpstreamLDAP.UserSearchBase == env.SupervisorUpstreamLDAP.GroupSearchBase { // This test relies on using the user search base as the group search base, to simulate // searching for groups and not finding any. @@ -467,44 +471,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { } }, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, // groups not stored at the user search base - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, func(spec *idpv1alpha1.LDAPIdentityProviderSpec) { + spec.GroupSearch.Base = env.SupervisorUpstreamLDAP.UserSearchBase // groups not stored at the user search base + }) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -541,52 +511,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: []string{}, }, { - name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.StartTLSOnlyHost, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "cn={}", // try using a non-default search filter - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: "dn", // try using the user's DN as the downstream username - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "cn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.StartTLSOnlyHost, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, func(spec *idpv1alpha1.LDAPIdentityProviderSpec) { + spec.Host = env.SupervisorUpstreamLDAP.StartTLSOnlyHost + spec.UserSearch.Filter = "cn={}" // try using a non-default search filter + spec.UserSearch.Attributes.Username = "dn" // try using the user's DN as the downstream username + spec.GroupSearch.Attributes.GroupName = "cn" + }) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -615,52 +549,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs, }, { - name: "logging in to ldap with the wrong password fails", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "logging in to ldap with the wrong password fails", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -675,47 +568,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantErrorType: "access_denied", }, { - name: "ldap login still works after updating bind secret", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap login still works after updating bind secret", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { t.Helper() - - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - secretName := secret.Name - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secretName, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) + idp, secret := createLDAPIdentityProvider(t, nil) secret.Annotations = map[string]string{"pinniped.dev/test": "", "another-label": "another-key"} // update that secret, which will cause the cache to recheck tls and search base values @@ -734,11 +591,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { testlib.RequireEventually(t, func(requireEventually *require.Assertions) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - ldapIDP, err = supervisorClient.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace).Get(ctx, ldapIDP.Name, metav1.GetOptions{}) + idp, err = supervisorClient.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{}) requireEventually.NoError(err) - requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, ldapIDP, expectedMsg) + requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, idp, expectedMsg) }, time.Minute, 500*time.Millisecond) - return ldapIDP.Name + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -768,53 +625,17 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "ldap login still works after deleting and recreating the bind secret", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap login still works after deleting and recreating the bind secret", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { t.Helper() - - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - secretName := secret.Name - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secretName, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) + idp, secret := createLDAPIdentityProvider(t, nil) // delete, then recreate that secret, which will cause the cache to recheck tls and search base values client := testlib.NewKubernetesClientset(t) deleteCtx, deleteCancel := context.WithTimeout(context.Background(), time.Minute) defer deleteCancel() - err := client.CoreV1().Secrets(env.SupervisorNamespace).Delete(deleteCtx, secretName, metav1.DeleteOptions{}) + err := client.CoreV1().Secrets(env.SupervisorNamespace).Delete(deleteCtx, secret.Name, metav1.DeleteOptions{}) require.NoError(t, err) // create the secret again @@ -822,7 +643,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { defer recreateCancel() recreatedSecret, err := client.CoreV1().Secrets(env.SupervisorNamespace).Create(recreateCtx, &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: secretName, + Name: secret.Name, Namespace: env.SupervisorNamespace, }, Type: v1.SecretTypeBasicAuth, @@ -841,11 +662,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { testlib.RequireEventually(t, func(requireEventually *require.Assertions) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - ldapIDP, err = supervisorClient.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace).Get(ctx, ldapIDP.Name, metav1.GetOptions{}) + idp, err = supervisorClient.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{}) requireEventually.NoError(err) - requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, ldapIDP, expectedMsg) + requireEventuallySuccessfulLDAPIdentityProviderConditions(t, requireEventually, idp, expectedMsg) }, time.Minute, 500*time.Millisecond) - return ldapIDP.Name + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -875,40 +696,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "activedirectory with all default options", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory with all default options", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secret.Name, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) - return adIDP.Name + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -939,54 +731,26 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { - name: "activedirectory with custom options", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory with custom options", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.ActiveDirectoryIdentityProviderUserSearch{ + idp, _ := createActiveDirectoryIdentityProvider(t, func(spec *idpv1alpha1.ActiveDirectoryIdentityProviderSpec) { + spec.UserSearch = idpv1alpha1.ActiveDirectoryIdentityProviderUserSearch{ Base: env.SupervisorUpstreamActiveDirectory.UserSearchBase, Filter: env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeName + "={}", Attributes: idpv1alpha1.ActiveDirectoryIdentityProviderUserSearchAttributes{ Username: env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeName, }, - }, - GroupSearch: idpv1alpha1.ActiveDirectoryIdentityProviderGroupSearch{ + } + spec.GroupSearch = idpv1alpha1.ActiveDirectoryIdentityProviderGroupSearch{ Filter: "member={}", // excluding nested groups Base: env.SupervisorUpstreamActiveDirectory.GroupSearchBase, Attributes: idpv1alpha1.ActiveDirectoryIdentityProviderGroupSearchAttributes{ GroupName: "dn", }, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) - return adIDP.Name + } + }) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1017,35 +781,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs, }, { - name: "active directory login still works after updating bind secret", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory login still works after updating bind secret", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { t.Helper() - - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - secretName := secret.Name - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secretName, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) + idp, secret := createActiveDirectoryIdentityProvider(t, nil) secret.Annotations = map[string]string{"pinniped.dev/test": "", "another-label": "another-key"} // update that secret, which will cause the cache to recheck tls and search base values @@ -1064,11 +804,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { testlib.RequireEventually(t, func(requireEventually *require.Assertions) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - adIDP, err = supervisorClient.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace).Get(ctx, adIDP.Name, metav1.GetOptions{}) + idp, err = supervisorClient.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{}) requireEventually.NoError(err) - requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, adIDP, expectedMsg) + requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, idp, expectedMsg) }, time.Minute, 500*time.Millisecond) - return adIDP.Name + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1098,41 +838,17 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { - name: "active directory login still works after deleting and recreating bind secret", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory login still works after deleting and recreating bind secret", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { t.Helper() - - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - secretName := secret.Name - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secretName, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) + idp, secret := createActiveDirectoryIdentityProvider(t, nil) // delete the secret client := testlib.NewKubernetesClientset(t) deleteCtx, deleteCancel := context.WithTimeout(context.Background(), time.Minute) defer deleteCancel() - err := client.CoreV1().Secrets(env.SupervisorNamespace).Delete(deleteCtx, secretName, metav1.DeleteOptions{}) + err := client.CoreV1().Secrets(env.SupervisorNamespace).Delete(deleteCtx, secret.Name, metav1.DeleteOptions{}) require.NoError(t, err) // create the secret again @@ -1140,7 +856,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { defer recreateCancel() recreatedSecret, err := client.CoreV1().Secrets(env.SupervisorNamespace).Create(recreateCtx, &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: secretName, + Name: secret.Name, Namespace: env.SupervisorNamespace, }, Type: v1.SecretTypeBasicAuth, @@ -1160,11 +876,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { testlib.RequireEventually(t, func(requireEventually *require.Assertions) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - adIDP, err = supervisorClient.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace).Get(ctx, adIDP.Name, metav1.GetOptions{}) + idp, err = supervisorClient.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace).Get(ctx, idp.Name, metav1.GetOptions{}) requireEventually.NoError(err) - requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, adIDP, expectedMsg) + requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t, requireEventually, idp, expectedMsg) }, time.Minute, 500*time.Millisecond) - return adIDP.Name + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1194,40 +910,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { - name: "active directory login fails after the user password is changed", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory login fails after the user password is changed", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secret.Name, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) - return adIDP.Name + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name }, createTestUser: func(t *testing.T) (string, string) { return testlib.CreateFreshADTestUser(t, env) @@ -1256,40 +943,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: []string{}, // none for now. }, { - name: "active directory login fails after the user is deactivated", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory login fails after the user is deactivated", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secret.Name, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) - return adIDP.Name + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name }, createTestUser: func(t *testing.T) (string, string) { return testlib.CreateFreshADTestUser(t, env) @@ -1318,40 +976,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: []string{}, // none for now. }, { - name: "active directory login fails after the user is locked", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "active directory login fails after the user is locked", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secret.Name, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) - return adIDP.Name + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name }, createTestUser: func(t *testing.T) (string, string) { return testlib.CreateFreshADTestUser(t, env) @@ -1380,40 +1009,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: []string{}, }, { - name: "logging in to activedirectory with a deactivated user fails", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - if env.SupervisorUpstreamActiveDirectory.Host == "" { - t.Skip("Active Directory hostname not specified") - } - }, + name: "logging in to active directory with a deactivated user fails", + maybeSkip: skipActiveDirectoryTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, - }, - ) - adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ - Host: env.SupervisorUpstreamActiveDirectory.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), - }, - Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ - SecretName: secret.Name, - }, - }, idpv1alpha1.ActiveDirectoryPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) - return adIDP.Name + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1429,52 +1029,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantErrorType: "access_denied", }, { - name: "ldap refresh fails when username changes from email as username to dn as username", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap refresh fails when username changes from email as username to dn as username", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secret.Name, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - expectedMsg := fmt.Sprintf( - `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, - env.SupervisorUpstreamLDAP.Host, env.SupervisorUpstreamLDAP.BindUsername, - secret.Name, secret.ResourceVersion, - ) - requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1513,48 +1072,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { - name: "ldap refresh updates groups to be empty after deleting the group search base", - maybeSkip: func(t *testing.T) { - t.Helper() - if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { - t.Skip("LDAP integration test requires connectivity to an LDAP server") - } - }, + name: "ldap refresh updates groups to be empty after deleting the group search base", + maybeSkip: skipLDAPTests, createIDP: func(t *testing.T) string { - t.Helper() - - secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", v1.SecretTypeBasicAuth, - map[string]string{ - v1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername, - v1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword, - }, - ) - secretName := secret.Name - ldapIDP := testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ - Host: env.SupervisorUpstreamLDAP.Host, - TLS: &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), - }, - Bind: idpv1alpha1.LDAPIdentityProviderBind{ - SecretName: secretName, - }, - UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{ - Base: env.SupervisorUpstreamLDAP.UserSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{ - Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName, - UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName, - }, - }, - GroupSearch: idpv1alpha1.LDAPIdentityProviderGroupSearch{ - Base: env.SupervisorUpstreamLDAP.GroupSearchBase, - Filter: "", - Attributes: idpv1alpha1.LDAPIdentityProviderGroupSearchAttributes{ - GroupName: "dn", - }, - }, - }, idpv1alpha1.LDAPPhaseReady) - return ldapIDP.Name + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, @@ -1606,6 +1128,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { tt.breakRefreshSessionData, tt.createTestUser, tt.deleteTestUser, + tt.wantLocalhostCallbackToNeverHappen, tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenGroups, @@ -1742,6 +1265,7 @@ func testSupervisorLogin( breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string), createTestUser func(t *testing.T) (string, string), deleteTestUser func(t *testing.T, username string), + wantLocalhostCallbackToNeverHappen bool, wantDownstreamIDTokenSubjectToMatch string, wantDownstreamIDTokenUsernameToMatch func(username string) string, wantDownstreamIDTokenGroups []string, @@ -1883,7 +1407,17 @@ func testSupervisorLogin( requestAuthorization(t, downstream.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient) // Expect that our callback handler was invoked. - callback := localCallbackServer.waitForCallback(10 * time.Second) + callback, err := localCallbackServer.waitForCallback(10 * time.Second) + if wantLocalhostCallbackToNeverHappen { + require.Error(t, err) + // When we want the localhost callback to have never happened, then this is the end of the test. The login was + // unable to finish so there is nothing to assert about what should have happened with the callback, and there + // won't be any error sent to the callback either. + return + } + // Else, no error. + require.NoError(t, err) + t.Logf("got callback request: %s", testlib.MaskTokens(callback.URL.String())) if wantErrorType == "" { require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) @@ -2104,6 +1638,40 @@ func requestAuthorizationUsingBrowserAuthcodeFlowLDAP(t *testing.T, downstreamIs browsertest.WaitForURL(t, page, callbackURLPattern) } +func requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentials(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, _, username, password string, _ *http.Client) { + t.Helper() + + // Open the web browser and navigate to the downstream authorize URL. + page := browsertest.Open(t) + t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL)) + require.NoError(t, page.Navigate(downstreamAuthorizeURL)) + + // This functions assumes that it has been passed either a bad username or a bad password, and submits the + // provided credentials. Expect to be redirected to the upstream provider and attempt to log in. + browsertest.LoginToUpstreamLDAP(t, page, downstreamIssuer, username, password) + + // After failing login expect to land back on the login page again with an error message. + browsertest.WaitForUpstreamLDAPLoginPageWithError(t, page, downstreamIssuer) +} + +func requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentialsAndThenGoodCredentials(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, _, username, password string, _ *http.Client) { + t.Helper() + + // Open the web browser and navigate to the downstream authorize URL. + page := browsertest.Open(t) + t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL)) + require.NoError(t, page.Navigate(downstreamAuthorizeURL)) + + // Expect to be redirected to the upstream provider and attempt to log in. + browsertest.LoginToUpstreamLDAP(t, page, downstreamIssuer, username, "this is the wrong password!") + + // After failing login expect to land back on the login page again with an error message. + browsertest.WaitForUpstreamLDAPLoginPageWithError(t, page, downstreamIssuer) + + // Already at the login page, so this time can directly submit it using the provided username and password. + browsertest.SubmitUpstreamLDAPLoginForm(t, page, username, password) +} + func makeAuthorizationRequestAndRequireSecurityHeaders(ctx context.Context, t *testing.T, downstreamAuthorizeURL string, httpClient *http.Client) { authorizeRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, downstreamAuthorizeURL, nil) require.NoError(t, err) @@ -2191,13 +1759,12 @@ type localCallbackServer struct { callbacks <-chan *http.Request } -func (s *localCallbackServer) waitForCallback(timeout time.Duration) *http.Request { +func (s *localCallbackServer) waitForCallback(timeout time.Duration) (*http.Request, error) { select { case callback := <-s.callbacks: - return callback + return callback, nil case <-time.After(timeout): - require.Fail(s.t, "timed out waiting for callback request") - return nil + return nil, errors.New("timed out waiting for callback request") } } diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index fa5325f9..4a770b1a 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -188,11 +188,7 @@ func LoginToUpstreamOIDC(t *testing.T, page *agouti.Page, upstream testlib.TestO func LoginToUpstreamLDAP(t *testing.T, page *agouti.Page, issuer, username, password string) { t.Helper() - usernameSelector := "#username" - passwordSelector := "#password" - loginButtonSelector := "#submit" - - loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `.+\z`) + loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `\?state=.+\z`) require.NoError(t, err) // Expect to be redirected to the login page. @@ -200,11 +196,31 @@ func LoginToUpstreamLDAP(t *testing.T, page *agouti.Page, issuer, username, pass WaitForURL(t, page, loginURLRegexp) // Wait for the login page to be rendered. - WaitForVisibleElements(t, page, usernameSelector, passwordSelector, loginButtonSelector) + WaitForVisibleElements(t, page, "#username", "#password", "#submit") + + // Fill in the username and password and click "submit". + SubmitUpstreamLDAPLoginForm(t, page, username, password) +} + +func SubmitUpstreamLDAPLoginForm(t *testing.T, page *agouti.Page, username string, password string) { + t.Helper() // Fill in the username and password and click "submit". t.Logf("logging in via Supervisor's upstream LDAP/AD login UI page") - require.NoError(t, page.First(usernameSelector).Fill(username)) - require.NoError(t, page.First(passwordSelector).Fill(password)) - require.NoError(t, page.First(loginButtonSelector).Click()) + require.NoError(t, page.First("#username").Fill(username)) + require.NoError(t, page.First("#password").Fill(password)) + require.NoError(t, page.First("#submit").Click()) +} + +func WaitForUpstreamLDAPLoginPageWithError(t *testing.T, page *agouti.Page, issuer string) { + t.Helper() + + // Wait for redirect back to the login page again with an error. + t.Logf("waiting for redirect to back to login page with error message") + loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `\?err=login_error&state=.+\z`) + require.NoError(t, err) + WaitForURL(t, page, loginURLRegexp) + + // Wait for the login page to be rendered again, this time also with an error message. + WaitForVisibleElements(t, page, "#username", "#password", "#submit", "#alert") } From 4101a55001544b79df41fd47a98737a17e76cdbc Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 11 May 2022 11:19:08 -0700 Subject: [PATCH 25/26] Update docs for new LDAP/AD browser-based login flow Also fix some comments that didn't fit onto one line in the yaml examples, be consistent about putting a blank line above `---` yaml separators, and some other small doc improvements. --- ...nfigure-supervisor-with-activedirectory.md | 24 +++++++---- .../howto/configure-supervisor-with-dex.md | 22 ++++++---- .../howto/configure-supervisor-with-gitlab.md | 5 +++ ...configure-supervisor-with-jumpcloudldap.md | 5 ++- .../howto/configure-supervisor-with-okta.md | 5 +++ .../configure-supervisor-with-openldap.md | 6 ++- ...re-supervisor-with-workspace_one_access.md | 21 ++++++---- .../docs/howto/configure-supervisor.md | 4 +- site/content/docs/howto/login.md | 42 ++++++++++++++----- .../docs/reference/code-walkthrough.md | 2 + 10 files changed, 98 insertions(+), 38 deletions(-) diff --git a/site/content/docs/howto/configure-supervisor-with-activedirectory.md b/site/content/docs/howto/configure-supervisor-with-activedirectory.md index 4b5aa387..0941f607 100644 --- a/site/content/docs/howto/configure-supervisor-with-activedirectory.md +++ b/site/content/docs/howto/configure-supervisor-with-activedirectory.md @@ -27,8 +27,8 @@ Create an [ActiveDirectoryIdentityProvider](https://github.com/vmware-tanzu/pinn ### ActiveDirectoryIdentityProvider with default options This ActiveDirectoryIdentityProvider uses all the default configuration options. - -Learn more about the default configuration [here]({{< ref "../reference/active-directory-configuration">}}) +The default configuration options are documented in the +[Active Directory configuration reference]({{< ref "../reference/active-directory-configuration">}}). ```yaml apiVersion: idp.supervisor.pinniped.dev/v1alpha1 @@ -41,14 +41,13 @@ spec: # Specify the host of the Active Directory server. host: "activedirectory.example.com:636" - # Specify the name of the Kubernetes Secret that contains your Active Directory - # bind account credentials. This service account will be used by the - # Supervisor to perform LDAP user and group searches. + # Specify the name of the Kubernetes Secret that contains your Active + # Directory bind account credentials. This service account will be + # used by the Supervisor to perform LDAP user and group searches. bind: secretName: "active-directory-bind-account" --- - apiVersion: v1 kind: Secret metadata: @@ -64,6 +63,10 @@ stringData: password: "YOUR_PASSWORD" ``` +Note that the `metadata.name` of the ActiveDirectoryIdentityProvider resource may be visible to end users at login prompts, +so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-active-directory` over `my-idp`. + If you've saved this into a file `activedirectory.yaml`, then install it into your cluster using: ```sh @@ -140,13 +143,16 @@ spec: # successful authentication. groupName: "dn" - # Specify the name of the Kubernetes Secret that contains your Active Directory - # bind account credentials. This service account will be used by the - # Supervisor to perform LDAP user and group searches. + # Specify the name of the Kubernetes Secret that contains your Active + # Directory bind account credentials. This service account will be + # used by the Supervisor to perform LDAP user and group searches. bind: secretName: "active-directory-bind-account" ``` +More information about the defaults for these configuration options can be found in +the [Active Directory configuration reference]({{< ref "../reference/active-directory-configuration">}}). + ## Next steps Next, [configure the Concierge to validate JWTs issued by the Supervisor]({{< ref "configure-concierge-supervisor-jwt" >}})! diff --git a/site/content/docs/howto/configure-supervisor-with-dex.md b/site/content/docs/howto/configure-supervisor-with-dex.md index 8c707653..fe549ca8 100644 --- a/site/content/docs/howto/configure-supervisor-with-dex.md +++ b/site/content/docs/howto/configure-supervisor-with-dex.md @@ -104,19 +104,21 @@ spec: # to the "username" claim in downstream tokens minted by the Supervisor. username: email - # Specify the name of the claim in your Dex ID token that represents the groups - # that the user belongs to. This matches what you specified above + # Specify the name of the claim in your Dex ID token that represents the + # groups to which the user belongs. This matches what you specified above # with the Groups claim filter. # Note that the group claims from Github are in the format of "org:team". - # To query for the group scope, you should set the organization you want Dex to - # search against in its configuration, otherwise your group claim would be empty. - # An example config can be found at - https://dexidp.io/docs/connectors/github/#configuration + # To query for the group scope, you should set the organization you + # want Dex to search against in its configuration, otherwise your group + # claim would be empty. An example config can be found at + # https://dexidp.io/docs/connectors/github/#configuration groups: groups # Specify the name of the Kubernetes Secret that contains your Dex # application's client credentials (created below). client: secretName: dex-client-credentials + --- apiVersion: v1 kind: Secret @@ -125,13 +127,19 @@ metadata: name: dex-client-credentials type: secrets.pinniped.dev/oidc-client stringData: - # The "Client ID" that you set in Dex. For example, in our case this is "pinniped-supervisor" + # The "Client ID" that you set in Dex. For example, in our case + # this is "pinniped-supervisor". clientID: "" - # The "Client secret" that you set in Dex. For example, in our case this is "pinniped-supervisor-secret" + # The "Client secret" that you set in Dex. For example, in our + # case this is "pinniped-supervisor-secret". clientSecret: "" ``` +Note that the `metadata.name` of the OIDCIdentityProvider resource may be visible to end users at login prompts +if you choose to enable `allowPasswordGrant`, so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-ldap` over `my-idp`. + Once your OIDCIdentityProvider resource has been created, you can validate your configuration by running: ```bash diff --git a/site/content/docs/howto/configure-supervisor-with-gitlab.md b/site/content/docs/howto/configure-supervisor-with-gitlab.md index d495d67b..75e08414 100644 --- a/site/content/docs/howto/configure-supervisor-with-gitlab.md +++ b/site/content/docs/howto/configure-supervisor-with-gitlab.md @@ -89,6 +89,7 @@ spec: # application's client credentials (created below). client: secretName: gitlab-client-credentials + --- apiVersion: v1 kind: Secret @@ -105,6 +106,10 @@ stringData: clientSecret: "" ``` +Note that the `metadata.name` of the OIDCIdentityProvider resource may be visible to end users at login prompts +if you choose to enable `allowPasswordGrant`, so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-gitlab` over `my-idp`. + Once your OIDCIdentityProvider has been created, you can validate your configuration by running: ```shell diff --git a/site/content/docs/howto/configure-supervisor-with-jumpcloudldap.md b/site/content/docs/howto/configure-supervisor-with-jumpcloudldap.md index 732e9e77..7faa2e7c 100644 --- a/site/content/docs/howto/configure-supervisor-with-jumpcloudldap.md +++ b/site/content/docs/howto/configure-supervisor-with-jumpcloudldap.md @@ -120,7 +120,6 @@ spec: secretName: "jumpcloudldap-bind-account" --- - apiVersion: v1 kind: Secret metadata: @@ -138,6 +137,10 @@ stringData: password: "YOUR_PASSWORD" ``` +Note that the `metadata.name` of the LDAPIdentityProvider resource may be visible to end users at login prompts, +so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-ldap` over `my-idp`. + If you've saved this into a file `jumpcloud.yaml`, then install it into your cluster using: ```sh diff --git a/site/content/docs/howto/configure-supervisor-with-okta.md b/site/content/docs/howto/configure-supervisor-with-okta.md index 6e37573f..1c955b6f 100644 --- a/site/content/docs/howto/configure-supervisor-with-okta.md +++ b/site/content/docs/howto/configure-supervisor-with-okta.md @@ -97,6 +97,7 @@ spec: # application's client credentials (created below). client: secretName: okta-client-credentials + --- apiVersion: v1 kind: Secret @@ -113,6 +114,10 @@ stringData: clientSecret: "" ``` +Note that the `metadata.name` of the OIDCIdentityProvider resource may be visible to end users at login prompts +if you choose to enable `allowPasswordGrant`, so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-okta` over `my-idp`. + Once your OIDCIdentityProvider has been created, you can validate your configuration by running: ```shell diff --git a/site/content/docs/howto/configure-supervisor-with-openldap.md b/site/content/docs/howto/configure-supervisor-with-openldap.md index 35605024..aafb635f 100644 --- a/site/content/docs/howto/configure-supervisor-with-openldap.md +++ b/site/content/docs/howto/configure-supervisor-with-openldap.md @@ -158,6 +158,7 @@ spec: - name: certs secret: secretName: certs + --- apiVersion: v1 kind: Service @@ -265,7 +266,6 @@ spec: secretName: openldap-bind-account --- - apiVersion: v1 kind: Secret metadata: @@ -284,6 +284,10 @@ stringData: EOF ``` +Note that the `metadata.name` of the LDAPIdentityProvider resource may be visible to end users at login prompts, +so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-ldap` over `my-idp`. + Once your LDAPIdentityProvider has been created, you can validate your configuration by running: ```sh diff --git a/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md b/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md index a77c83ef..7d411169 100644 --- a/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md +++ b/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md @@ -76,7 +76,8 @@ spec: # the default claims in your token. The "openid" scope is always # included. # - # See the example claims below to learn how to customize the claims returned. + # See the example claims below to learn how to customize the + # claims returned. additionalScopes: [group, email] # Specify how Workspace ONE Access claims are mapped to Kubernetes identities. @@ -85,22 +86,22 @@ spec: # Specify the name of the claim in your Workspace ONE Access token that # will be mapped to the username in your Kubernetes environment. # - # User's emails can change. Use the sub claim if - # your environment requires a stable identifier. + # User's emails can change. Use the sub claim if your environment + # requires a stable identifier. username: email - # Specify the name of the claim in Workspace ONE Access that represents the - # groups the user belongs to. + # Specify the name of the claim in Workspace ONE Access that represents + # the groups to which the user belongs. # - # Group names may not be unique and can change. - # The group_ids claim is recommended for environments - # that want to use a more stable identifier. + # Group names may not be unique and can change. The group_ids claim is + # recommended for environments that want to use a more stable identifier. groups: group_names # Specify the name of the Kubernetes Secret that contains your # Workspace ONE Access application's client credentials (created below). client: secretName: ws1-client-credentials + --- apiVersion: v1 kind: Secret @@ -117,6 +118,10 @@ stringData: clientSecret: "" ``` +Note that the `metadata.name` of the OIDCIdentityProvider resource may be visible to end users at login prompts +if you choose to enable `allowPasswordGrant`, so choose a name which will be understood by your end users. +For example, if you work at Acme Corp, choose something like `acme-corporate-workspace-one` over `my-idp`. + The following claims are returned by Workspace ONE Access. The `group` scope is required to use the `group_ids` and `group_names` claims. The `email` scope is required to use the `email` claim. The remaining claims are always available. diff --git a/site/content/docs/howto/configure-supervisor.md b/site/content/docs/howto/configure-supervisor.md index 5f2c7e47..baf998ad 100644 --- a/site/content/docs/howto/configure-supervisor.md +++ b/site/content/docs/howto/configure-supervisor.md @@ -244,6 +244,6 @@ should be signed by a certificate authority that is trusted by their browsers. ## Next steps Next, configure an OIDCIdentityProvider, ActiveDirectoryIdentityProvider, or an LDAPIdentityProvider for the Supervisor -(several examples are available in these guides), -and [configure the Concierge to use the Supervisor for authentication]({{< ref "configure-concierge-supervisor-jwt" >}}) +(several examples are available in these guides). Then +[configure the Concierge to use the Supervisor for authentication]({{< ref "configure-concierge-supervisor-jwt" >}}) on each cluster! diff --git a/site/content/docs/howto/login.md b/site/content/docs/howto/login.md index 45cfb3ad..b2ae46a0 100644 --- a/site/content/docs/howto/login.md +++ b/site/content/docs/howto/login.md @@ -72,6 +72,9 @@ pinniped get kubeconfig \ The new Pinniped-compatible kubeconfig YAML will be output as stdout, and can be redirected to a file. Various default behaviors of `pinniped get kubeconfig` can be overridden using [its command-line options]({{< ref "cli" >}}). +One flag of note is `--upstream-identity-provider-flow browser_authcode` to choose end-user `kubectl` login via a web browser +(the default for OIDCIdentityProviders), and `--upstream-identity-provider-flow cli_password` to choose end-user `kubectl` +login via CLI username/password prompts (the default for LDAPIdentityProviders and ActiveDirectoryIdentityProviders). ## Use the generated kubeconfig with `kubectl` to access the cluster @@ -94,20 +97,33 @@ 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, there are two supported client flows. +- 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 + 1. 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. - When using the optional CLI-based flow, `kubectl` will interactively prompt the user for their username and password at the CLI. + 2. 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. Note that the optional CLI-based flow must be enabled by the + administrator in the OIDCIdentityProvider configuration before use + (see `allowPasswordGrant` in the + [API docs](https://github.com/vmware-tanzu/pinniped/blob/main/generated/{{< latestcodegenversion >}}/README.adoc#oidcauthorizationconfig) + for more details). + +- For LDAP and Active Directory identity providers, there are also two supported client flows: + + 1. When using the default 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. + 2. When using the optional browser-based flow, `kubectl` will open the user's web browser and direct it to a login page + hosted by the Pinniped Supervisor. When the user enters their username and password, the Supervisor will authenticate + the user using the LDAP or Active Directory 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. + Unlike the optional flow for OIDC providers described above, this optional flow does not need to be configured in + the LDAPIdentityProvider or ActiveDirectoryIdentityProvider resource, so it is always available for end-users. Once the user completes authentication, the `kubectl` command will automatically continue and complete the user's requested command. For the example above, `kubectl` would list the cluster's namespaces. @@ -135,8 +151,14 @@ in the upstream identity provider, for example: --group auditors ``` -## Other notes +## Session and credential caching by the CLI -- Temporary session credentials such as ID, access, and refresh tokens are stored in: - - `~/.config/pinniped/sessions.yaml` (macOS/Linux) - - `%USERPROFILE%/.config/pinniped/sessions.yaml` (Windows). +Temporary session credentials such as ID, access, and refresh tokens are stored in: + - `$HOME/.config/pinniped/sessions.yaml` (macOS/Linux) + - `%USERPROFILE%/.config/pinniped/sessions.yaml` (Windows). + +Temporary cluster credentials such mTLS client certificates are stored in: + - `$HOME/.config/pinniped/credentials.yaml` (macOS/Linux) + - `%USERPROFILE%/.config/pinniped/credentials.yaml` (Windows). + +Deleting the contents of these directories is equivalent to performing a client-side logout. diff --git a/site/content/docs/reference/code-walkthrough.md b/site/content/docs/reference/code-walkthrough.md index de077f34..5ce69299 100644 --- a/site/content/docs/reference/code-walkthrough.md +++ b/site/content/docs/reference/code-walkthrough.md @@ -206,6 +206,8 @@ The per-FederationDomain endpoints are: See [internal/oidc/callback/callback_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/callback/callback_handler.go). - `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. See [internal/oidc/idpdiscovery/idp_discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/idpdiscovery/idp_discovery_handler.go). +- `/login` is a login UI page to support the optional browser-based login flow for LDAP and Active Directory identity providers. + See [internal/oidc/login/login_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/login/login_handler.go). The OIDC specifications implemented by the Supervisor can be found at [openid.net](https://openid.net/connect). From 39fd9ba2701477db1f8b1032e1fb6fe8c8f87a15 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 19 May 2022 16:02:08 -0700 Subject: [PATCH 26/26] Small refactors and comments for LDAP/AD UI --- cmd/pinniped/cmd/kubeconfig.go | 4 +- cmd/pinniped/cmd/kubeconfig_test.go | 3 +- internal/oidc/auth/auth_handler.go | 58 ++++++++++++------- internal/oidc/login/get_login_handler.go | 4 +- internal/oidc/login/get_login_handler_test.go | 2 +- internal/oidc/login/login_handler.go | 3 + internal/oidc/login/loginhtml/loginhtml.go | 6 +- .../oidc/login/loginhtml/loginhtml_test.go | 4 +- internal/oidc/oidc.go | 3 + .../provider/formposthtml/formposthtml.go | 6 +- .../formposthtml/formposthtml_test.go | 4 +- internal/oidc/provider/manager/manager.go | 5 +- ...re-supervisor-with-workspace_one_access.md | 4 -- 13 files changed, 64 insertions(+), 42 deletions(-) diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 74280ec5..e46eebde 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -922,7 +922,9 @@ func selectUpstreamIDPFlow(discoveredIDPFlows []idpdiscoveryv1alpha1.IDPFlow, se return discoveredIDPFlows[0], nil default: // The user did not specify a flow, and more than one was found. - log.Info("multiple client flows found, selecting first value as default: "+discoveredIDPFlows[0].String(), "idpName", selectedIDPName, "idpType", selectedIDPType) + log.Info("multiple client flows found, selecting first value as default", + "idpName", selectedIDPName, "idpType", selectedIDPType, + "selectedFlow", discoveredIDPFlows[0].String(), "availableFlows", discoveredIDPFlows) return discoveredIDPFlows[0], nil } } diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index e5c27797..76a216f0 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -1305,7 +1305,8 @@ func TestGetKubeconfig(t *testing.T) { base64.StdEncoding.EncodeToString([]byte(issuerCABundle))) }, wantLogs: func(_ string, _ string) []string { - return []string{"\"level\"=0 \"msg\"=\"multiple client flows found, selecting first value as default: cli_password\" \"idpName\"=\"some-ldap-idp\" \"idpType\"=\"ldap\""} + return []string{`"level"=0 "msg"="multiple client flows found, selecting first value as default" ` + + `"availableFlows"=["cli_password","flow2"] "idpName"="some-ldap-idp" "idpType"="ldap" "selectedFlow"="cli_password"`} }, }, { diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index ae502d3a..b4b9fccd 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -159,7 +159,7 @@ func handleAuthRequestForLDAPUpstreamBrowserFlow( upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, ) error { - encodedStateParamValue, _, _, err := handleBrowserAuthRequest( + authRequestState, err := handleBrowserFlowAuthRequest( r, w, oauthHelper, @@ -174,11 +174,12 @@ func handleAuthRequestForLDAPUpstreamBrowserFlow( if err != nil { return err } - if encodedStateParamValue == "" { + if authRequestState == nil { + // There was an error but handleBrowserFlowAuthRequest() already took care of writing the response for it. return nil } - return login.RedirectToLoginPage(r, w, downstreamIssuer, encodedStateParamValue, login.ShowNoError) + return login.RedirectToLoginPage(r, w, downstreamIssuer, authRequestState.encodedStateParam, login.ShowNoError) } func handleAuthRequestForOIDCUpstreamPasswordGrant( @@ -255,7 +256,7 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, ) error { - encodedStateParamValue, pkceValue, nonceValue, err := handleBrowserAuthRequest( + authRequestState, err := handleBrowserFlowAuthRequest( r, w, oauthHelper, @@ -270,7 +271,8 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( if err != nil { return err } - if encodedStateParamValue == "" { + if authRequestState == nil { + // There was an error but handleBrowserFlowAuthRequest() already took care of writing the response for it. return nil } @@ -284,9 +286,9 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( } authCodeOptions := []oauth2.AuthCodeOption{ - nonceValue.Param(), - pkceValue.Challenge(), - pkceValue.Method(), + authRequestState.nonce.Param(), + authRequestState.pkce.Challenge(), + authRequestState.pkce.Method(), } for key, val := range oidcUpstream.GetAdditionalAuthcodeParams() { @@ -295,7 +297,7 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( http.Redirect(w, r, upstreamOAuthConfig.AuthCodeURL( - encodedStateParamValue, + authRequestState.encodedStateParam, authCodeOptions..., ), http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 @@ -387,10 +389,21 @@ func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider } } -// handleBrowserAuthRequest performs the shared validations and setup between browser based auth requests -// regardless of IDP type-- LDAP, Active Directory and OIDC. +type browserFlowAuthRequestState struct { + encodedStateParam string + pkce pkce.Code + nonce nonce.Nonce +} + +// handleBrowserFlowAuthRequest performs the shared validations and setup between browser based +// auth requests regardless of IDP type-- LDAP, Active Directory and OIDC. // It generates the state param, sets the CSRF cookie, and validates the prompt param. -func handleBrowserAuthRequest( +// It returns an error when it encounters an error without handling it, leaving it to +// the caller to decide how to handle it. +// It returns nil with no error when it encounters an error and also has already handled writing +// the error response to the ResponseWriter, in which case the caller should not also try to +// write the error response. +func handleBrowserFlowAuthRequest( r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, @@ -401,10 +414,10 @@ func handleBrowserAuthRequest( idpType psession.ProviderType, cookieCodec oidc.Codec, upstreamStateEncoder oidc.Encoder, -) (string, pkce.Code, nonce.Nonce, error) { +) (*browserFlowAuthRequestState, error) { authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false) if !created { - return "", "", "", nil + return nil, nil // already wrote the error response, don't return error } now := time.Now() @@ -420,13 +433,13 @@ func handleBrowserAuthRequest( }) if err != nil { oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, err, false) - return "", "", "", nil + return nil, nil // already wrote the error response, don't return error } csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) if err != nil { plog.Error("authorize generate error", err) - return "", "", "", err + return nil, err } csrfFromCookie := readCSRFCookie(r, cookieCodec) if csrfFromCookie != "" { @@ -444,13 +457,13 @@ func handleBrowserAuthRequest( ) if err != nil { plog.Error("authorize upstream state param error", err) - return "", "", "", err + return nil, err } promptParam := r.Form.Get(promptParamName) if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false) - return "", "", "", nil + return nil, nil // already wrote the error response, don't return error } if csrfFromCookie == "" { @@ -458,10 +471,15 @@ func handleBrowserAuthRequest( err = addCSRFSetCookieHeader(w, csrfValue, cookieCodec) if err != nil { plog.Error("error setting CSRF cookie", err) - return "", "", "", err + return nil, err } } - return encodedStateParamValue, pkceValue, nonceValue, nil + + return &browserFlowAuthRequestState{ + encodedStateParam: encodedStateParamValue, + pkce: pkceValue, + nonce: nonceValue, + }, nil } func generateValues( diff --git a/internal/oidc/login/get_login_handler.go b/internal/oidc/login/get_login_handler.go index 3e33c937..d6da85a6 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/oidc/login/get_login_handler.go @@ -15,12 +15,12 @@ const ( incorrectUsernameOrPasswordErrorMessage = "Incorrect username or password." ) -func NewGetHandler() HandlerFunc { +func NewGetHandler(loginPath string) HandlerFunc { return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { alertMessage, hasAlert := getAlert(r) pageInputs := &loginhtml.PageData{ - PostPath: r.URL.Path, // the path for POST is the same as for GET + PostPath: loginPath, State: encodedState, IDPName: decodedState.UpstreamName, HasAlertError: hasAlert, diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/oidc/login/get_login_handler_test.go index 472148d5..bb85b8f2 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/oidc/login/get_login_handler_test.go @@ -96,7 +96,7 @@ func TestGetLogin(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - handler := NewGetHandler() + handler := NewGetHandler(testPath) target := testPath + "?state=" + tt.encodedState if tt.errParam != "" { target += "&err=" + tt.errParam diff --git a/internal/oidc/login/login_handler.go b/internal/oidc/login/login_handler.go index 06444bc1..1b358f2b 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/oidc/login/login_handler.go @@ -94,6 +94,9 @@ func wrapSecurityHeaders(handler http.Handler) http.Handler { }) } +// RedirectToLoginPage redirects to the GET /login page of the specified issuer. +// The specified issuer should never end with a "/", which is validated by +// provider.FederationDomainIssuer when the issuer string comes from that type. func RedirectToLoginPage( r *http.Request, w http.ResponseWriter, diff --git a/internal/oidc/login/loginhtml/loginhtml.go b/internal/oidc/login/loginhtml/loginhtml.go index 1493979f..2cd97c57 100644 --- a/internal/oidc/login/loginhtml/loginhtml.go +++ b/internal/oidc/login/loginhtml/loginhtml.go @@ -18,7 +18,7 @@ import ( var ( //go:embed login_form.css rawCSS string - minifiedCSS = mustMinify(minify.CSS(rawCSS)) + minifiedCSS = panicOnError(minify.CSS(rawCSS)) //go:embed login_form.gohtml rawHTMLTemplate string @@ -26,7 +26,7 @@ var ( // Parse the Go templated HTML and inject functions providing the minified inline CSS and JS. var parsedHTMLTemplate = template.Must(template.New("login_form.gohtml").Funcs(template.FuncMap{ - "minifiedCSS": func() template.CSS { return template.CSS(minifiedCSS) }, + "minifiedCSS": func() template.CSS { return template.CSS(CSS()) }, }).Parse(rawHTMLTemplate)) // Generate the CSP header value once since it's effectively constant. @@ -36,7 +36,7 @@ var cspValue = strings.Join([]string{ `frame-ancestors 'none'`, }, "; ") -func mustMinify(s string, err error) string { +func panicOnError(s string, err error) string { if err != nil { panic(err) } diff --git a/internal/oidc/login/loginhtml/loginhtml_test.go b/internal/oidc/login/loginhtml/loginhtml_test.go index a2e91ed1..50d8dc95 100644 --- a/internal/oidc/login/loginhtml/loginhtml_test.go +++ b/internal/oidc/login/loginhtml/loginhtml_test.go @@ -63,6 +63,6 @@ func TestCSS(t *testing.T) { } func TestHelpers(t *testing.T) { - require.Equal(t, "test", mustMinify("test", nil)) - require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) }) + require.Equal(t, "test", panicOnError("test", nil)) + require.PanicsWithError(t, "some error", func() { panicOnError("", fmt.Errorf("some error")) }) } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index b45e757a..79380df7 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -43,6 +43,9 @@ const ( // Just in case we need to make a breaking change to the format of the upstream state param, // we are including a format version number. This gives the opportunity for a future version of Pinniped // to have the consumer of this format decide to reject versions that it doesn't understand. + // + // Version 1 was the original version. + // Version 2 added the UpstreamType field to the UpstreamStateParamData struct. UpstreamStateParamFormatVersion = "2" // The `name` passed to the encoder for encoding the upstream state param value. This name is short diff --git a/internal/oidc/provider/formposthtml/formposthtml.go b/internal/oidc/provider/formposthtml/formposthtml.go index b96f0d5d..d1a26c34 100644 --- a/internal/oidc/provider/formposthtml/formposthtml.go +++ b/internal/oidc/provider/formposthtml/formposthtml.go @@ -18,11 +18,11 @@ import ( var ( //go:embed form_post.css rawCSS string - minifiedCSS = mustMinify(minify.CSS(rawCSS)) + minifiedCSS = panicOnError(minify.CSS(rawCSS)) //go:embed form_post.js rawJS string - minifiedJS = mustMinify(minify.JS(rawJS)) + minifiedJS = panicOnError(minify.JS(rawJS)) //go:embed form_post.gohtml rawHTMLTemplate string @@ -44,7 +44,7 @@ var cspValue = strings.Join([]string{ `frame-ancestors 'none'`, }, "; ") -func mustMinify(s string, err error) string { +func panicOnError(s string, err error) string { if err != nil { panic(err) } diff --git a/internal/oidc/provider/formposthtml/formposthtml_test.go b/internal/oidc/provider/formposthtml/formposthtml_test.go index d5d69c9d..e28714c0 100644 --- a/internal/oidc/provider/formposthtml/formposthtml_test.go +++ b/internal/oidc/provider/formposthtml/formposthtml_test.go @@ -93,6 +93,6 @@ func TestContentSecurityPolicyHashes(t *testing.T) { } func TestHelpers(t *testing.T) { - require.Equal(t, "test", mustMinify("test", nil)) - require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) }) + require.Equal(t, "test", panicOnError("test", nil)) + require.PanicsWithError(t, "some error", func() { panicOnError("", fmt.Errorf("some error")) }) } diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index ffa33139..2833efa2 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -8,8 +8,6 @@ import ( "strings" "sync" - "go.pinniped.dev/internal/oidc/login" - corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/internal/oidc" @@ -20,6 +18,7 @@ import ( "go.pinniped.dev/internal/oidc/dynamiccodec" "go.pinniped.dev/internal/oidc/idpdiscovery" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/oidc/login" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/token" "go.pinniped.dev/internal/plog" @@ -139,7 +138,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler( upstreamStateEncoder, csrfCookieEncoder, - login.NewGetHandler(), + login.NewGetHandler(incomingProvider.IssuerPath()+oidc.PinnipedLoginPath), login.NewPostHandler(issuer, m.upstreamIDPs, oauthHelperWithKubeStorage), ) diff --git a/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md b/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md index 7d411169..fc6b6ffd 100644 --- a/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md +++ b/site/content/docs/howto/configure-supervisor-with-workspace_one_access.md @@ -118,10 +118,6 @@ stringData: clientSecret: "" ``` -Note that the `metadata.name` of the OIDCIdentityProvider resource may be visible to end users at login prompts -if you choose to enable `allowPasswordGrant`, so choose a name which will be understood by your end users. -For example, if you work at Acme Corp, choose something like `acme-corporate-workspace-one` over `my-idp`. - The following claims are returned by Workspace ONE Access. The `group` scope is required to use the `group_ids` and `group_names` claims. The `email` scope is required to use the `email` claim. The remaining claims are always available.