From 61c21d297707c7f89b6d88ab173087bf8d93d77e Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 18 Aug 2021 12:06:46 -0700 Subject: [PATCH] Refactor some authorize and callback error handling, and add more tests --- internal/oidc/auth/auth_handler.go | 54 +- internal/oidc/auth/auth_handler_test.go | 610 +++++++++++++++--- internal/oidc/callback/callback_handler.go | 2 +- .../oidc/callback/callback_handler_test.go | 6 +- .../downstreamsession/downstream_session.go | 21 +- 5 files changed, 548 insertions(+), 145 deletions(-) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index b6dd274a..fb289719 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -97,12 +97,8 @@ func handleAuthRequestForLDAPUpstream( return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication") } if !authenticated { - plog.Debug("failed upstream LDAP authentication", "upstreamName", ldapUpstream.GetName()) - // Return an error according to OIDC spec 3.1.2.6 (second paragraph). - err = errors.WithStack(fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider.")) - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider.")) } subject := downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) @@ -130,12 +126,9 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( if !oidcUpstream.AllowsPasswordGrant() { // Return a user-friendly error for this case which is entirely within our control. - err := errors.WithStack(fosite.ErrAccessDenied. - WithHint("Resource owner password credentials grant is not allowed for this upstream provider according to its configuration."), - ) - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHint( + "Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.")) } token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) @@ -147,16 +140,16 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( // However, the exact response is undefined in the sense that there is no such thing as a password grant in // the OIDC spec, so we don't try too hard to read the upstream errors in this case. (E.g. Dex departs from the // spec and returns something other than an "invalid_grant" error for bad resource owner credentials.) - // Return an error according to OIDC spec 3.1.2.6 (second paragraph). - err := errors.WithStack(fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client } subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) if err != nil { - return err + // Return a user-friendly error for this case which is entirely within our control. + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), + ) } return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups) @@ -189,9 +182,7 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( }, }) if err != nil { - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, err) } csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE) @@ -258,6 +249,14 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( return nil } +func writeAuthorizeError(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error) error { + errWithStack := errors.WithStack(err) + plog.Info("authorize response error", oidc.FositeErrorForLog(errWithStack)...) + // Return an error according to OIDC spec 3.1.2.6 (second paragraph). + oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + return nil +} + func makeDownstreamSessionAndReturnAuthcodeRedirect( r *http.Request, w http.ResponseWriter, @@ -271,9 +270,7 @@ func makeDownstreamSessionAndReturnAuthcodeRedirect( authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) if err != nil { - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) - return nil + return writeAuthorizeError(w, oauthHelper, authorizeRequester, err) } oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder) @@ -285,10 +282,8 @@ func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseW username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) if username == "" || password == "" { - // Return an error according to OIDC spec 3.1.2.6 (second paragraph). - err := errors.WithStack(fosite.ErrAccessDenied.WithHintf("Missing or blank username or password.")) - plog.Info("authorize response error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Missing or blank username or password.")) return "", "", false } return username, password, true @@ -297,8 +292,7 @@ func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseW func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider) (fosite.AuthorizeRequester, bool) { authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r) if err != nil { - plog.Info("authorize request error", oidc.FositeErrorForLog(err)...) - oauthHelper.WriteAuthorizeError(w, authorizeRequester, err) + _ = writeAuthorizeError(w, oauthHelper, authorizeRequester, err) return nil, false } diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index ce615297..bb02bd9f 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -151,6 +151,36 @@ func TestAuthorizationEndpoint(t *testing.T) { "error_description": "The resource owner or authorization server denied the request. Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.", "state": happyState, } + + fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: email_verified claim in upstream ID token has invalid format.", + "state": happyState, + } + + fositeAccessDeniedWithFalseEmailVerifiedHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: email_verified claim in upstream ID token has false value.", + "state": happyState, + } + + fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token missing.", + "state": happyState, + } + + fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token is empty.", + "state": happyState, + } + + fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token has invalid format.", + "state": happyState, + } ) hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } @@ -201,6 +231,14 @@ func TestAuthorizationEndpoint(t *testing.T) { WithUpstreamAuthcodeExchangeError(errors.New("should not have tried to exchange upstream authcode on this instance")) } + happyUpstreamPasswordGrantMockExpectation := &expectedPasswordGrant{ + performedByUpstreamName: oidcPasswordGrantUpstreamName, + args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ + Username: oidcUpstreamUsername, + Password: oidcUpstreamPassword, + }, + } + happyLDAPUsername := "some-ldap-user" happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" happyLDAPPassword := "some-ldap-password" //nolint:gosec @@ -428,18 +466,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyStringWithLocationInHref: true, }, { - name: "OIDC upstream password grant happy path using GET", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: happyGetRequestPath, - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "OIDC upstream password grant happy path using GET", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, @@ -510,20 +543,15 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, }, { - name: "OIDC upstream password grant happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodPost, - path: "/some/path", - contentType: "application/x-www-form-urlencoded", - body: encodeQuery(happyGetRequestQueryMap), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "OIDC upstream password grant happy path using POST", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodPost, + path: "/some/path", + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, @@ -625,14 +653,9 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid&state=` + happyState, @@ -1032,18 +1055,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing PKCE code_challenge in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "missing PKCE code_challenge in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeErrorQuery), @@ -1079,18 +1097,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "invalid value for PKCE code_challenge_method in request using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "invalid value for PKCE code_challenge_method in request using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidCodeChallengeErrorQuery), @@ -1126,18 +1139,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream password grant", // https://tools.ietf.org/html/rfc7636#section-4.3 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": "plain"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), @@ -1173,18 +1181,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantBodyString: "", }, { - name: "missing PKCE code_challenge_method in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "missing PKCE code_challenge_method in request using OIDC upstream password grant", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"code_challenge_method": ""}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), @@ -1224,18 +1227,13 @@ func TestAuthorizationEndpoint(t *testing.T) { { // This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running // through that part of the fosite library when using an OIDC upstream password grant. - name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream password grant", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), - method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream password grant", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: "application/json; charset=utf-8", wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositePromptHasNoneAndOtherValueErrorQuery), @@ -1282,15 +1280,10 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, // The following prompt value is illegal when openid is requested, but note that openid is not requested. - path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}), - customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), - customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), - wantPasswordGrantCall: &expectedPasswordGrant{ - performedByUpstreamName: oidcPasswordGrantUpstreamName, - args: &oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs{ - Username: oidcUpstreamUsername, - Password: oidcUpstreamPassword, - }}, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none login", "scope": "email"}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted @@ -1325,6 +1318,417 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, }, + { + name: "OIDC upstream password grant: upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutUsernameClaim().WithoutGroupsClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenGroups: []string{}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "joe@whitehouse.gov", + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", true).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "joe@whitehouse.gov", + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("some-claim"). + WithIDTokenClaim("some-claim", "joe"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", false).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: "joe", + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", "supposed to be boolean").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with false value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithUsernameClaim("email"). + WithIDTokenClaim("email", "joe@whitehouse.gov"). + WithIDTokenClaim("email_verified", false).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithFalseEmailVerifiedHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream IDP provides username claim configuration as `sub`, so the downstream token subject should be exactly what they asked for", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithUsernameClaim("sub").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamSubject, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token has a non-array value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithIDTokenClaim(oidcUpstreamGroupsClaim, "notAnArrayGroup1 notAnArrayGroup2").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: []string{"notAnArrayGroup1 notAnArrayGroup2"}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token is a slice of interfaces", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder(). + WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"group1", "group2"}).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: []string{"group1", "group2"}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain requested username claim", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim(oidcUpstreamUsernameClaim).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain requested groups claim", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim(oidcUpstreamGroupsClaim).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: []string{}, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + }, + { + name: "OIDC upstream password grant: upstream ID token contains username claim with weird format", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamUsernameClaim, 42).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains username claim with empty string value", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamUsernameClaim, "").Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim("iss").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does has an empty string value for iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("iss", "").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token has an non-string iss claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("iss", 42).WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does not contain sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutIDTokenClaim("sub").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimMissingHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token does has an empty string value for sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("sub", "").WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimEmptyHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token has an non-string sub claim when using default username claim config", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim("sub", 42).WithoutUsernameClaim().Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains groups claim with weird format", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamGroupsClaim, 42).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains groups claim where one element is invalid", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamGroupsClaim, []interface{}{"foo", 7}).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, + { + name: "OIDC upstream password grant: upstream ID token contains groups claim with invalid null type", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + passwordGrantUpstreamOIDCIdentityProviderBuilder().WithIDTokenClaim(oidcUpstreamGroupsClaim, nil).Build(), + ), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithRequiredClaimInvalidFormatHintErrorQuery), + wantBodyString: "", + }, { name: "downstream state does not have enough entropy using OIDC upstream browser flow", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index 73e37b75..b168e4b9 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -70,7 +70,7 @@ func NewHandler( subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) if err != nil { - return err + return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 7252da03..9cc5779e 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -803,7 +803,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -819,7 +819,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -835,7 +835,7 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusUnprocessableEntity, wantContentType: htmlContentType, - wantBody: "Unprocessable Entity: groups claim in upstream ID token has invalid format\n", + wantBody: "Unprocessable Entity: required claim in upstream ID token has invalid format\n", wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index c09ea8a7..64838d5e 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -6,7 +6,6 @@ package downstreamsession import ( "fmt" - "net/http" "net/url" "time" @@ -15,7 +14,7 @@ import ( "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/token/jwt" - "go.pinniped.dev/internal/httputil/httperr" + "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" @@ -27,6 +26,12 @@ const ( // The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims emailVerifiedClaimName = "email_verified" + + requiredClaimMissingErr = constable.Error("required claim in upstream ID token missing") + requiredClaimInvalidFormatErr = constable.Error("required claim in upstream ID token has invalid format") + requiredClaimEmptyErr = constable.Error("required claim in upstream ID token is empty") + emailVerifiedClaimInvalidFormatErr = constable.Error("email_verified claim in upstream ID token has invalid format") + emailVerifiedClaimFalseErr = constable.Error("email_verified claim in upstream ID token has false value") ) // MakeDownstreamSession creates a downstream OIDC session. @@ -107,7 +112,7 @@ func getSubjectAndUsernameFromUpstreamIDToken( "configuredUsernameClaim", usernameClaimName, "emailVerifiedClaim", emailVerifiedAsInterface, ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has invalid format") + return "", "", emailVerifiedClaimInvalidFormatErr } if !emailVerified { plog.Warning( @@ -115,7 +120,7 @@ func getSubjectAndUsernameFromUpstreamIDToken( "upstreamName", upstreamIDPConfig.GetName(), "configuredUsernameClaim", usernameClaimName, ) - return "", "", httperr.New(http.StatusUnprocessableEntity, "email_verified claim in upstream ID token has false value") + return "", "", emailVerifiedClaimFalseErr } } @@ -135,7 +140,7 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl "upstreamName", upstreamIDPName, "claimName", claimName, ) - return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token missing") + return "", requiredClaimMissingErr } valueAsString, ok := value.(string) @@ -145,7 +150,7 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl "upstreamName", upstreamIDPName, "claimName", claimName, ) - return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token has invalid format") + return "", requiredClaimInvalidFormatErr } if valueAsString == "" { @@ -154,7 +159,7 @@ func extractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl "upstreamName", upstreamIDPName, "claimName", claimName, ) - return "", httperr.New(http.StatusUnprocessableEntity, "required claim in upstream ID token is empty") + return "", requiredClaimEmptyErr } return valueAsString, nil @@ -190,7 +195,7 @@ func getGroupsFromUpstreamIDToken( "upstreamName", upstreamIDPConfig.GetName(), "configuredGroupsClaim", groupsClaimName, ) - return nil, httperr.New(http.StatusUnprocessableEntity, "groups claim in upstream ID token has invalid format") + return nil, requiredClaimInvalidFormatErr } return groupsAsArray, nil