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)