diff --git a/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto b/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto index 28766ca518dac..212e790f02038 100644 --- a/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto +++ b/api/envoy/extensions/filters/http/oauth2/v3/oauth.proto @@ -151,7 +151,7 @@ message OAuth2Credentials { // OAuth config // -// [#next-free-field: 28] +// [#next-free-field: 30] message OAuth2Config { enum AuthType { // The ``client_id`` and ``client_secret`` will be sent in the URL encoded request body. @@ -187,6 +187,26 @@ message OAuth2Config { // If configured, the OAuth2 filter will redirect users to this endpoint when they access the signout_path. string end_session_endpoint = 23; + // Optional ``post_logout_redirect_uri`` parameter sent to the ``end_session_endpoint`` when a user + // accesses the ``signout_path``. If empty (the default), the filter falls back to + // ``:///`` constructed from the inbound request, preserving previous behavior. The + // value should be a fully qualified URI that the identity provider has registered for this client. + // It will be percent-encoded automatically when building the logout URL. + // + // This field is ignored if ``end_session_endpoint`` is not set or if + // ``disable_post_logout_redirect_uri`` is true. + string post_logout_redirect_uri = 28; + + // If true, the ``post_logout_redirect_uri`` query parameter is omitted from the OpenID Connect + // RP-Initiated Logout URL entirely. Per the + // `RP-Initiated Logout specification `_ + // the parameter is optional. Use this when the identity provider rejects unregistered post-logout + // redirect URIs and a post-logout redirect is not required. + // + // When set, ``post_logout_redirect_uri`` is ignored. This field is ignored if + // ``end_session_endpoint`` is not set. + bool disable_post_logout_redirect_uri = 29; + // Credentials used for OAuth. OAuth2Credentials credentials = 3 [(validate.rules).message = {required: true}]; diff --git a/changelogs/current/new_features/oauth2__added_support_to_configure_post_logout_redirect_uri_parameter.rst b/changelogs/current/new_features/oauth2__added_support_to_configure_post_logout_redirect_uri_parameter.rst new file mode 100644 index 0000000000000..9d25f53a53f68 --- /dev/null +++ b/changelogs/current/new_features/oauth2__added_support_to_configure_post_logout_redirect_uri_parameter.rst @@ -0,0 +1,6 @@ +Added :ref:`post_logout_redirect_uri +` +and :ref:`disable_post_logout_redirect_uri +` +to override or omit the ``post_logout_redirect_uri`` query parameter sent to the OpenID Connect +RP-Initiated Logout ``end_session_endpoint``. \ No newline at end of file diff --git a/source/extensions/filters/http/oauth2/filter.cc b/source/extensions/filters/http/oauth2/filter.cc index 33347a53809ce..5625c1cec8042 100644 --- a/source/extensions/filters/http/oauth2/filter.cc +++ b/source/extensions/filters/http/oauth2/filter.cc @@ -56,8 +56,10 @@ constexpr const char* CookieDeleteFormatString = constexpr const char* CookieTailHttpOnlyFormatString = ";path={};Max-Age={};secure;HttpOnly{}"; constexpr const char* CookieDomainFormatString = ";domain={}"; -constexpr const char* OIDCLogoutUrlFormatString = - "{0}?id_token_hint={1}&client_id={2}&post_logout_redirect_uri={3}"; +constexpr const char* OIDCLogoutUrlBaseFormatString = + "{0}?id_token_hint={1}&client_id={2}"; +constexpr const char* OIDCLogoutUrlPostLogoutRedirectFormatString = + "&post_logout_redirect_uri={0}"; constexpr absl::string_view UnauthorizedBodyMessage = "OAuth flow failed."; constexpr absl::string_view ServiceUnavailableBodyMessage = "Service Unavailable"; @@ -462,6 +464,8 @@ FilterConfig::FilterConfig( : oauth_token_endpoint_(proto_config.token_endpoint()), authorization_endpoint_(proto_config.authorization_endpoint()), end_session_endpoint_(proto_config.end_session_endpoint()), + post_logout_redirect_uri_(proto_config.post_logout_redirect_uri()), + disable_post_logout_redirect_uri_(proto_config.disable_post_logout_redirect_uri()), authorization_query_params_(buildAutorizationQueryParams(proto_config)), client_id_(proto_config.credentials().client_id()), redirect_uri_(proto_config.redirect_uri()), @@ -1104,19 +1108,30 @@ Http::FilterHeadersStatus OAuth2Filter::signOutUser(const Http::RequestHeaderMap maybe_secure_attr)); } - const std::string post_logout_redirect_url = + const std::string default_post_logout_redirect_url = absl::StrCat(headers.getSchemeValue(), "://", host_, "/"); // If the end session endpoint is set, redirect to it to log out the user from the OpenID // provider. if (!config_->endSessionEndpoint().empty()) { const std::string id_token = Http::Utility::parseCookieValue(headers, config_->cookieNames().id_token_); - const std::string oidc_logout_url = fmt::format( - OIDCLogoutUrlFormatString, config_->endSessionEndpoint(), id_token, config_->clientId(), - Http::Utility::PercentEncoding::encode(post_logout_redirect_url, ":/=&?")); + std::string oidc_logout_url = fmt::format(OIDCLogoutUrlBaseFormatString, + config_->endSessionEndpoint(), id_token, + config_->clientId()); + + if (!config_->disablePostLogoutRedirectUri()) { + const std::string& configured_uri = config_->postLogoutRedirectUri(); + const std::string redirect_uri = + configured_uri.empty() + ? default_post_logout_redirect_url + : configured_uri; + absl::StrAppend(&oidc_logout_url, + fmt::format(OIDCLogoutUrlPostLogoutRedirectFormatString, + Http::Utility::PercentEncoding::encode(redirect_uri, ":/=&?"))); + } response_headers->setLocation(oidc_logout_url); } else { - response_headers->setLocation(post_logout_redirect_url); + response_headers->setLocation(default_post_logout_redirect_url); } decoder_callbacks_->encodeHeaders(std::move(response_headers), true, SIGN_OUT); diff --git a/source/extensions/filters/http/oauth2/filter.h b/source/extensions/filters/http/oauth2/filter.h index ad649608a7f1e..d8a39e86e5037 100644 --- a/source/extensions/filters/http/oauth2/filter.h +++ b/source/extensions/filters/http/oauth2/filter.h @@ -170,6 +170,8 @@ class FilterConfig : public Router::RouteSpecificFilterConfig, const HttpUri& oauthTokenEndpoint() const { return oauth_token_endpoint_; } const Http::Utility::Url& authorizationEndpointUrl() const { return authorization_endpoint_url_; } const std::string& endSessionEndpoint() const { return end_session_endpoint_; } + const std::string& postLogoutRedirectUri() const { return post_logout_redirect_uri_; } + bool disablePostLogoutRedirectUri() const { return disable_post_logout_redirect_uri_; } const Http::Utility::QueryParamsMulti& authorizationQueryParams() const { return authorization_query_params_; } @@ -242,6 +244,8 @@ class FilterConfig : public Router::RouteSpecificFilterConfig, const std::string authorization_endpoint_; Http::Utility::Url authorization_endpoint_url_; const std::string end_session_endpoint_; + const std::string post_logout_redirect_uri_; + const bool disable_post_logout_redirect_uri_ : 1; const Http::Utility::QueryParamsMulti authorization_query_params_; const std::string client_id_; const std::string redirect_uri_; diff --git a/test/extensions/filters/http/oauth2/filter_test.cc b/test/extensions/filters/http/oauth2/filter_test.cc index ada81cfcfb250..4b700455bfbca 100644 --- a/test/extensions/filters/http/oauth2/filter_test.cc +++ b/test/extensions/filters/http/oauth2/filter_test.cc @@ -972,8 +972,10 @@ TEST_F(OAuth2Test, RequestSignout) { /** * Scenario: The OAuth filter receives a sign out request when end session endpoint is configured. + * Neider is the post_logout_redirect_uri explicitly defined, nor is it disabled. * - * Expected behavior: the filter should redirect to the end session endpoint. + * Expected behavior: the filter should redirect to the end session endpoint and use the request-derived default + * ``post_logout_redirect_uri`` query parameter (:///). */ TEST_F(OAuth2Test, RequestSignoutWhenEndSessionEndpointIsConfigured) { // Create a filter config with end session endpoint and openid scope. @@ -1030,6 +1032,190 @@ TEST_F(OAuth2Test, RequestSignoutWhenEndSessionEndpointIsConfigured) { filter_->decodeHeaders(request_headers, false)); } +/** + * Scenario: The OAuth filter receives a sign out request, ``end_session_endpoint`` is configured, + * and ``post_logout_redirect_uri`` is overridden in the filter configuration. + * + * Expected behavior: the filter should redirect to the end session endpoint and use the configured + * URI as the ``post_logout_redirect_uri`` query parameter, instead of the request-derived default. + */ +TEST_F(OAuth2Test, RequestSignoutWithCustomPostLogoutRedirectUri) { + // Create a filter config with end session endpoint, post logout redirect uri and openid scope. + envoy::extensions::filters::http::oauth2::v3::OAuth2Config p; + auto* endpoint = p.mutable_token_endpoint(); + endpoint->set_cluster("auth.example.com"); + endpoint->set_uri("auth.example.com/_oauth"); + endpoint->mutable_timeout()->set_seconds(1); + p.set_redirect_uri("%REQ(:scheme)%://%REQ(:authority)%" + TEST_CALLBACK); + p.mutable_redirect_path_matcher()->mutable_path()->set_exact(TEST_CALLBACK); + p.set_authorization_endpoint("https://auth.example.com/oauth/authorize/"); + p.mutable_signout_path()->mutable_path()->set_exact("/_signout"); + auto credentials = p.mutable_credentials(); + credentials->set_client_id(TEST_CLIENT_ID); + credentials->mutable_token_secret()->set_name("secret"); + credentials->mutable_hmac_secret()->set_name("hmac"); + p.set_end_session_endpoint("https://auth.example.com/oauth/logout"); + p.set_post_logout_redirect_uri("https://traffic.example.com/loggedout"); + p.add_auth_scopes("openid"); + + // Create the OAuth config. + auto secret_reader = std::make_shared(); + FilterConfigSharedPtr test_config_; + test_config_ = std::make_shared(p, factory_context_.server_factory_context_, + secret_reader, scope_, "test."); + init(test_config_); + + Http::TestRequestHeaderMapImpl request_headers{ + {Http::Headers::get().Path.get(), "/_signout"}, + {Http::Headers::get().Host.get(), "traffic.example.com"}, + {Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get}, + {Http::Headers::get().Scheme.get(), "https"}, + {Http::Headers::get().Cookie.get(), "IdToken=xyztoken"}, + }; + + Http::TestResponseHeaderMapImpl response_headers{ + {Http::Headers::get().Status.get(), "302"}, + {Http::Headers::get().SetCookie.get(), + "OauthHMAC=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "BearerToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "IdToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "RefreshToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "OauthExpires=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().Location.get(), "https://auth.example.com/oauth/" + "logout?id_token_hint=xyztoken&client_id=1&post_logout_" + "redirect_uri=https%3A%2F%2Ftraffic.example.com%2Floggedout"}, + }; + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(HeaderMapEqualRef(&response_headers), true)); + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, false)); +} + +/** + * Scenario: The OAuth filter receives a sign out request, ``end_session_endpoint`` is configured, + * and ``disable_post_logout_redirect_uri`` is true. + * + * Expected behavior: the filter should redirect to the end session endpoint without including the + * ``post_logout_redirect_uri`` query parameter at all. + */ +TEST_F(OAuth2Test, RequestSignoutWithDisabledPostLogoutRedirectUri) { + envoy::extensions::filters::http::oauth2::v3::OAuth2Config p; + auto* endpoint = p.mutable_token_endpoint(); + endpoint->set_cluster("auth.example.com"); + endpoint->set_uri("auth.example.com/_oauth"); + endpoint->mutable_timeout()->set_seconds(1); + p.set_redirect_uri("%REQ(:scheme)%://%REQ(:authority)%" + TEST_CALLBACK); + p.mutable_redirect_path_matcher()->mutable_path()->set_exact(TEST_CALLBACK); + p.set_authorization_endpoint("https://auth.example.com/oauth/authorize/"); + p.mutable_signout_path()->mutable_path()->set_exact("/_signout"); + auto credentials = p.mutable_credentials(); + credentials->set_client_id(TEST_CLIENT_ID); + credentials->mutable_token_secret()->set_name("secret"); + credentials->mutable_hmac_secret()->set_name("hmac"); + p.set_end_session_endpoint("https://auth.example.com/oauth/logout"); + p.set_disable_post_logout_redirect_uri(true); + p.add_auth_scopes("openid"); + + // Create the OAuth config. + auto secret_reader = std::make_shared(); + FilterConfigSharedPtr test_config_; + test_config_ = std::make_shared(p, factory_context_.server_factory_context_, + secret_reader, scope_, "test."); + init(test_config_); + + Http::TestRequestHeaderMapImpl request_headers{ + {Http::Headers::get().Path.get(), "/_signout"}, + {Http::Headers::get().Host.get(), "traffic.example.com"}, + {Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get}, + {Http::Headers::get().Scheme.get(), "https"}, + {Http::Headers::get().Cookie.get(), "IdToken=xyztoken"}, + }; + + Http::TestResponseHeaderMapImpl response_headers{ + {Http::Headers::get().Status.get(), "302"}, + {Http::Headers::get().SetCookie.get(), + "OauthHMAC=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "BearerToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "IdToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "RefreshToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "OauthExpires=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().Location.get(), "https://auth.example.com/oauth/" + "logout?id_token_hint=xyztoken&client_id=1"}, + }; + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(HeaderMapEqualRef(&response_headers), true)); + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, false)); +} + +/** + * Scenario: Both ``post_logout_redirect_uri`` and ``disable_post_logout_redirect_uri`` are set. + * + * Expected behavior: ``disable_post_logout_redirect_uri`` wins; the configured URI is not emitted + * and the ``post_logout_redirect_uri`` query parameter is omitted from the logout URL. + */ +TEST_F(OAuth2Test, RequestSignoutDisableOverridesCustomPostLogoutRedirectUri) { + envoy::extensions::filters::http::oauth2::v3::OAuth2Config p; + auto* endpoint = p.mutable_token_endpoint(); + endpoint->set_cluster("auth.example.com"); + endpoint->set_uri("auth.example.com/_oauth"); + endpoint->mutable_timeout()->set_seconds(1); + p.set_redirect_uri("%REQ(:scheme)%://%REQ(:authority)%" + TEST_CALLBACK); + p.mutable_redirect_path_matcher()->mutable_path()->set_exact(TEST_CALLBACK); + p.set_authorization_endpoint("https://auth.example.com/oauth/authorize/"); + p.mutable_signout_path()->mutable_path()->set_exact("/_signout"); + auto credentials = p.mutable_credentials(); + credentials->set_client_id(TEST_CLIENT_ID); + credentials->mutable_token_secret()->set_name("secret"); + credentials->mutable_hmac_secret()->set_name("hmac"); + p.set_end_session_endpoint("https://auth.example.com/oauth/logout"); + p.set_post_logout_redirect_uri("https://app.example.com/loggedout"); + p.set_disable_post_logout_redirect_uri(true); + p.add_auth_scopes("openid"); + + auto secret_reader = std::make_shared(); + FilterConfigSharedPtr test_config_; + test_config_ = std::make_shared(p, factory_context_.server_factory_context_, + secret_reader, scope_, "test."); + init(test_config_); + + Http::TestRequestHeaderMapImpl request_headers{ + {Http::Headers::get().Path.get(), "/_signout"}, + {Http::Headers::get().Host.get(), "traffic.example.com"}, + {Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get}, + {Http::Headers::get().Scheme.get(), "https"}, + {Http::Headers::get().Cookie.get(), "IdToken=xyztoken"}, + }; + + Http::TestResponseHeaderMapImpl response_headers{ + {Http::Headers::get().Status.get(), "302"}, + {Http::Headers::get().SetCookie.get(), + "OauthHMAC=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "BearerToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "IdToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "RefreshToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().SetCookie.get(), + "OauthExpires=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"}, + {Http::Headers::get().Location.get(), "https://auth.example.com/oauth/" + "logout?id_token_hint=xyztoken&client_id=1"}, + }; + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(HeaderMapEqualRef(&response_headers), true)); + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, false)); +} + /** * Scenario: The OAuth filter receives a request to an arbitrary path with valid OAuth cookies * (cookie values and validation are mocked out)