@@ -4838,6 +4838,202 @@ TEST_F(OAuth2Test, OAuthTestCustomCookiePaths) {
48384838 }
48394839}
48404840
4841+ // Helper to build a FilterConfig with allowed_redirect_domains and
4842+ // match_redirect_url_to_redirect_uri.
4843+ class OAuth2RedirectDomainTest : public OAuth2Test {
4844+ public:
4845+ OAuth2RedirectDomainTest () : OAuth2Test(false ) {}
4846+
4847+ FilterConfigSharedPtr
4848+ getConfigWithRedirectDomains (const std::vector<std::string>& allowed_domains,
4849+ bool match_redirect_url = false ,
4850+ const std::string& redirect_uri = " " ) {
4851+ envoy::extensions::filters::http::oauth2::v3::OAuth2Config p;
4852+ auto * endpoint = p.mutable_token_endpoint ();
4853+ endpoint->set_cluster (" auth.example.com" );
4854+ endpoint->set_uri (" auth.example.com/_oauth" );
4855+ endpoint->mutable_timeout ()->set_seconds (1 );
4856+ p.set_redirect_uri (redirect_uri.empty () ? " %REQ(:scheme)%://%REQ(:authority)%" + TEST_CALLBACK
4857+ : redirect_uri);
4858+ p.mutable_redirect_path_matcher ()->mutable_path ()->set_exact (TEST_CALLBACK);
4859+ p.set_authorization_endpoint (" https://auth.example.com/oauth/authorize/" );
4860+ p.mutable_signout_path ()->mutable_path ()->set_exact (" /_signout" );
4861+ p.set_forward_bearer_token (true );
4862+ p.add_auth_scopes (" user" );
4863+ p.add_auth_scopes (" openid" );
4864+ p.add_auth_scopes (" email" );
4865+ p.add_resources (" oauth2-resource" );
4866+ p.add_resources (" http://example.com" );
4867+ p.add_resources (" https://example.com/some/path%2F..%2F/utf8\xc3\x83 ;foo=bar?var1=1&var2=2" );
4868+ auto credentials = p.mutable_credentials ();
4869+ credentials->set_client_id (TEST_CLIENT_ID);
4870+ credentials->mutable_token_secret ()->set_name (" secret" );
4871+ credentials->mutable_hmac_secret ()->set_name (" hmac" );
4872+ p.mutable_use_refresh_token ()->set_value (false );
4873+
4874+ for (const auto & domain : allowed_domains) {
4875+ p.add_allowed_redirect_domains (domain);
4876+ }
4877+ p.set_match_redirect_url_to_redirect_uri (match_redirect_url);
4878+
4879+ MessageUtil::validate (p, ProtobufMessage::getStrictValidationVisitor ());
4880+
4881+ auto secret_reader = std::make_shared<MockSecretReader>();
4882+ return std::make_shared<FilterConfig>(p, factory_context_.server_factory_context_ ,
4883+ secret_reader, scope_, " test." );
4884+ }
4885+ };
4886+
4887+ /* *
4888+ * Scenario: allowed_redirect_domains is set and the state URL host is not in the list.
4889+ *
4890+ * Expected behavior: the filter should reject the callback with 401.
4891+ */
4892+ TEST_F (OAuth2RedirectDomainTest, AllowedRedirectDomainsRejectsInvalidHost) {
4893+ // TEST_ENCODED_STATE contains url with host "traffic.example.com".
4894+ // Only allow "other.example.com".
4895+ init (getConfigWithRedirectDomains ({" other.example.com" }));
4896+
4897+ Http::TestRequestHeaderMapImpl request_headers{
4898+ {Http::Headers::get ().Path .get (), " /_oauth?code=123&state=" + TEST_ENCODED_STATE},
4899+ {Http::Headers::get ().Cookie .get (), " OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN},
4900+ {Http::Headers::get ().Cookie .get (),
4901+ " CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER},
4902+ {Http::Headers::get ().Host .get (), " traffic.example.com" },
4903+ {Http::Headers::get ().Scheme .get (), " https" },
4904+ {Http::Headers::get ().Method .get (), Http::Headers::get ().MethodValues .Get },
4905+ };
4906+
4907+ EXPECT_CALL (*validator_, setParams (_, _));
4908+ EXPECT_CALL (*validator_, isValid ()).WillOnce (Return (false ));
4909+
4910+ EXPECT_CALL (decoder_callbacks_, sendLocalReply (Http::Code::Unauthorized, _, _, _, _));
4911+
4912+ EXPECT_EQ (Http::FilterHeadersStatus::StopIteration,
4913+ filter_->decodeHeaders (request_headers, false ));
4914+ }
4915+
4916+ /* *
4917+ * Scenario: allowed_redirect_domains is set and the state URL host matches exactly.
4918+ *
4919+ * Expected behavior: the filter should allow the callback and proceed with token exchange.
4920+ */
4921+ TEST_F (OAuth2RedirectDomainTest, AllowedRedirectDomainsAllowsExactMatch) {
4922+ // TEST_ENCODED_STATE contains url with host "traffic.example.com".
4923+ init (getConfigWithRedirectDomains ({" traffic.example.com" }));
4924+
4925+ Http::TestRequestHeaderMapImpl request_headers{
4926+ {Http::Headers::get ().Path .get (), " /_oauth?code=123&state=" + TEST_ENCODED_STATE},
4927+ {Http::Headers::get ().Cookie .get (), " OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN},
4928+ {Http::Headers::get ().Cookie .get (),
4929+ " CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER},
4930+ {Http::Headers::get ().Host .get (), " traffic.example.com" },
4931+ {Http::Headers::get ().Scheme .get (), " https" },
4932+ {Http::Headers::get ().Method .get (), Http::Headers::get ().MethodValues .Get },
4933+ };
4934+
4935+ EXPECT_CALL (*validator_, setParams (_, _));
4936+ EXPECT_CALL (*validator_, isValid ()).WillOnce (Return (false ));
4937+
4938+ EXPECT_CALL (*oauth_client_, asyncGetAccessToken (" 123" , TEST_CLIENT_ID, " asdf_client_secret_fdsa" ,
4939+ " https://traffic.example.com" + TEST_CALLBACK,
4940+ TEST_CODE_VERIFIER, AuthType::UrlEncodedBody));
4941+
4942+ EXPECT_EQ (Http::FilterHeadersStatus::StopAllIterationAndBuffer,
4943+ filter_->decodeHeaders (request_headers, false ));
4944+ }
4945+
4946+ /* *
4947+ * Scenario: allowed_redirect_domains is set with a wildcard and the state URL host matches.
4948+ *
4949+ * Expected behavior: "*.example.com" should match "traffic.example.com".
4950+ */
4951+ TEST_F (OAuth2RedirectDomainTest, AllowedRedirectDomainsAllowsWildcardMatch) {
4952+ // TEST_ENCODED_STATE contains url with host "traffic.example.com".
4953+ init (getConfigWithRedirectDomains ({" *.example.com" }));
4954+
4955+ Http::TestRequestHeaderMapImpl request_headers{
4956+ {Http::Headers::get ().Path .get (), " /_oauth?code=123&state=" + TEST_ENCODED_STATE},
4957+ {Http::Headers::get ().Cookie .get (), " OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN},
4958+ {Http::Headers::get ().Cookie .get (),
4959+ " CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER},
4960+ {Http::Headers::get ().Host .get (), " traffic.example.com" },
4961+ {Http::Headers::get ().Scheme .get (), " https" },
4962+ {Http::Headers::get ().Method .get (), Http::Headers::get ().MethodValues .Get },
4963+ };
4964+
4965+ EXPECT_CALL (*validator_, setParams (_, _));
4966+ EXPECT_CALL (*validator_, isValid ()).WillOnce (Return (false ));
4967+
4968+ EXPECT_CALL (*oauth_client_, asyncGetAccessToken (" 123" , TEST_CLIENT_ID, " asdf_client_secret_fdsa" ,
4969+ " https://traffic.example.com" + TEST_CALLBACK,
4970+ TEST_CODE_VERIFIER, AuthType::UrlEncodedBody));
4971+
4972+ EXPECT_EQ (Http::FilterHeadersStatus::StopAllIterationAndBuffer,
4973+ filter_->decodeHeaders (request_headers, false ));
4974+ }
4975+
4976+ // {"url":"https://external.example.com/original_path?var1=1&var2=2",
4977+ // "csrf_token":"00000000075bcd15.na6kru4x1pHgocSIeU/mdtHYn58Gh1bqweS4XXoiqVg=",
4978+ // "flow_id":"00000000075bcd15"}
4979+ static const std::string TEST_ENCODED_STATE_EXTERNAL_HOST =
4980+ " eyJ1cmwiOiJodHRwczovL2V4dGVybmFsLmV4YW1wbGUuY29tL29yaWdpbmFsX3BhdGg_dmFyMT0xJnZhcjI9MiIsIm"
4981+ " NzcmZfdG9rZW4iOiIwMDAwMDAwMDA3NWJjZDE1Lm5hNmtydTR4MXBIZ29jU0llVS9tZHRIWW41OEdoMWJxd2VTNFhY"
4982+ " b2lxVmc9IiwiZmxvd19pZCI6IjAwMDAwMDAwMDc1YmNkMTUifQ" ;
4983+
4984+ /* *
4985+ * Scenario: match_redirect_url_to_redirect_uri is enabled with a static redirect_uri pointing
4986+ * to an external host. The internal :authority is "traffic.example.com" but the redirect_uri
4987+ * uses "external.example.com".
4988+ *
4989+ * Expected behavior: the state URL encoded in the redirect to the IdP should use
4990+ * "external.example.com" as the host, not "traffic.example.com".
4991+ */
4992+ TEST_F (OAuth2RedirectDomainTest, MatchRedirectUrlToRedirectUri) {
4993+ init (getConfigWithRedirectDomains ({}, true , " https://external.example.com/_oauth" ));
4994+
4995+ Http::TestRequestHeaderMapImpl first_request_headers{
4996+ {Http::Headers::get ().Path .get (), " /original_path?var1=1&var2=2" },
4997+ {Http::Headers::get ().Host .get (), " traffic.example.com" },
4998+ {Http::Headers::get ().Method .get (), Http::Headers::get ().MethodValues .Get },
4999+ {Http::Headers::get ().Scheme .get (), " https" },
5000+ };
5001+
5002+ Http::TestResponseHeaderMapImpl first_response_headers{
5003+ {Http::Headers::get ().Status .get (), " 302" },
5004+ {Http::Headers::get ().SetCookie .get (),
5005+ " OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN + " ;path=/;Max-Age=600;secure;HttpOnly" },
5006+ {Http::Headers::get ().SetCookie .get (),
5007+ " OauthNonce=" + TEST_CSRF_TOKEN + " ;path=/;Max-Age=600;secure;HttpOnly" },
5008+ {Http::Headers::get ().SetCookie .get (),
5009+ " CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER +
5010+ " ;path=/;Max-Age=600;secure;HttpOnly" },
5011+ {Http::Headers::get ().SetCookie .get (),
5012+ " CodeVerifier=" + TEST_ENCRYPTED_CODE_VERIFIER + " ;path=/;Max-Age=600;secure;HttpOnly" },
5013+ {Http::Headers::get ().Location .get (),
5014+ " https://auth.example.com/oauth/"
5015+ " authorize/?client_id=" +
5016+ TEST_CLIENT_ID + " &code_challenge=" + TEST_CODE_CHALLENGE +
5017+ " &code_challenge_method=S256"
5018+ " &redirect_uri=https%3A%2F%2Fexternal.example.com%2F_oauth"
5019+ " &response_type=code"
5020+ " &scope=" +
5021+ TEST_ENCODED_AUTH_SCOPES + " &state=" + TEST_ENCODED_STATE_EXTERNAL_HOST +
5022+ " &resource=oauth2-resource"
5023+ " &resource=http%3A%2F%2Fexample.com"
5024+ " &resource=https%3A%2F%2Fexample.com%2Fsome%2Fpath%252F..%252F%2Futf8%C3%83%3Bfoo%3Dbar%"
5025+ " 3Fvar1%3D1%26var2%3D2" },
5026+ };
5027+
5028+ EXPECT_CALL (*validator_, setParams (_, _));
5029+ EXPECT_CALL (*validator_, isValid ()).WillOnce (Return (false ));
5030+
5031+ EXPECT_CALL (decoder_callbacks_, encodeHeaders_ (HeaderMapEqualRef (&first_response_headers), true ));
5032+
5033+ EXPECT_EQ (Http::FilterHeadersStatus::StopIteration,
5034+ filter_->decodeHeaders (first_request_headers, false ));
5035+ }
5036+
48415037} // namespace Oauth2
48425038} // namespace HttpFilters
48435039} // namespace Extensions
0 commit comments