Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion api/envoy/extensions/filters/http/oauth2/v3/oauth.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
// ``<scheme>://<host>/`` 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 <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_
// 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}];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Added :ref:`post_logout_redirect_uri
<envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.post_logout_redirect_uri>`
and :ref:`disable_post_logout_redirect_uri
<envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.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``.
29 changes: 22 additions & 7 deletions source/extensions/filters/http/oauth2/filter.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions source/extensions/filters/http/oauth2/filter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
}
Expand Down Expand Up @@ -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_;
Expand Down
188 changes: 187 additions & 1 deletion test/extensions/filters/http/oauth2/filter_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<schema>://<host>/).
*/
TEST_F(OAuth2Test, RequestSignoutWhenEndSessionEndpointIsConfigured) {
// Create a filter config with end session endpoint and openid scope.
Expand Down Expand Up @@ -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<MockSecretReader>();
FilterConfigSharedPtr test_config_;
test_config_ = std::make_shared<FilterConfig>(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<MockSecretReader>();
FilterConfigSharedPtr test_config_;
test_config_ = std::make_shared<FilterConfig>(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<MockSecretReader>();
FilterConfigSharedPtr test_config_;
test_config_ = std::make_shared<FilterConfig>(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)
Expand Down