blob: 8e975f1ddcd66b827bd1f115de275fb5a14f5818 [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "content/browser/webid/federated_auth_disconnect_request.h"
#include <memory>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "base/run_loop.h"
#include "base/task/sequenced_task_runner.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "components/ukm/test_ukm_recorder.h"
#include "content/browser/webid/metrics.h"
#include "content/browser/webid/test/mock_api_permission_delegate.h"
#include "content/browser/webid/test/mock_idp_network_request_manager.h"
#include "content/browser/webid/test/mock_permission_delegate.h"
#include "content/browser/webid/webid_utils.h"
#include "content/public/test/navigation_simulator.h"
#include "content/test/test_render_view_host.h"
#include "content/test/test_web_contents.h"
#include "net/http/http_status_code.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/webid/federated_auth_request.mojom.h"
#include "url/gurl.h"
#include "url/origin.h"
using PermissionStatus =
content::FederatedIdentityApiPermissionContextDelegate::PermissionStatus;
using FetchStatus = content::IdpNetworkRequestManager::FetchStatus;
using ParseStatus = content::IdpNetworkRequestManager::ParseStatus;
using ::testing::_;
using ::testing::NiceMock;
using ::testing::Return;
using DisconnectResponse =
content::IdpNetworkRequestManager::DisconnectResponse;
using DisconnectStatusForMetrics = content::webid::DisconnectStatus;
using LoginState = content::IdentityRequestAccount::LoginState;
using blink::mojom::DisconnectStatus;
namespace content {
namespace {
constexpr char kRpUrl[] = "https://rp.example";
constexpr char kProviderUrl[] = "https://idp.example/fedcm.json";
constexpr char kAccountsEndpoint[] = "https://idp.example/accounts";
constexpr char kDisconnectEndpoint[] = "https://idp.example/disconnect";
constexpr char kTokenEndpoint[] = "https://idp.example/token";
constexpr char kLoginUrl[] = "https://idp.example/login";
constexpr char kClientId[] = "client_id_123";
// Not used?
// constexpr char kIdpDisconnectUrl[] = "https://idp.example/disconnect";
struct AccountConfig {
std::string id;
std::optional<content::IdentityRequestAccount::LoginState> login_state;
bool was_granted_sharing_permission;
};
struct Config {
std::vector<AccountConfig> accounts;
FetchStatus config_fetch_status;
FetchStatus disconnect_fetch_status;
std::string config_url;
};
Config kValidConfig = {
/*accounts=*/
{{"account1", /*idp_claimed_login_state=*/std::nullopt,
/*was_granted_sharing_permission=*/true}},
/*config_fetch_status=*/{ParseStatus::kSuccess, net::HTTP_OK},
/*disconnect_fetch_status=*/{ParseStatus::kSuccess, net::HTTP_OK},
kProviderUrl};
url::Origin OriginFromString(const std::string& url_string) {
return url::Origin::Create(GURL(url_string));
}
// Helper class for receiving the Disconnect method callback.
class DisconnectRequestCallbackHelper {
public:
DisconnectRequestCallbackHelper() = default;
~DisconnectRequestCallbackHelper() = default;
DisconnectRequestCallbackHelper(const DisconnectRequestCallbackHelper&) =
delete;
DisconnectRequestCallbackHelper& operator=(
const DisconnectRequestCallbackHelper&) = delete;
DisconnectStatus status() const { return status_; }
// This can only be called once per lifetime of this object.
base::OnceCallback<void(DisconnectStatus)> callback() {
return base::BindOnce(&DisconnectRequestCallbackHelper::ReceiverMethod,
base::Unretained(this));
}
// Returns when callback() is called, which can be immediately if it has
// already been called.
void WaitForCallback() {
if (was_called_) {
return;
}
wait_for_callback_loop_.Run();
}
private:
void ReceiverMethod(DisconnectStatus status) {
status_ = status;
was_called_ = true;
wait_for_callback_loop_.Quit();
}
bool was_called_ = false;
base::RunLoop wait_for_callback_loop_;
DisconnectStatus status_;
};
class TestIdpNetworkRequestManager : public MockIdpNetworkRequestManager {
public:
explicit TestIdpNetworkRequestManager(const Config& config)
: config_(config) {}
~TestIdpNetworkRequestManager() override = default;
void FetchWellKnown(const GURL& provider,
FetchWellKnownCallback callback) override {
has_fetched_well_known_ = true;
FetchStatus fetch_status = {ParseStatus::kSuccess, net::HTTP_OK};
IdpNetworkRequestManager::WellKnown well_known;
std::set<GURL> well_known_urls = {GURL(config_.config_url)};
well_known.provider_urls = std::move(well_known_urls);
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE,
base::BindOnce(std::move(callback), fetch_status, well_known));
}
void FetchConfig(const GURL& provider,
blink::mojom::RpMode rp_mode,
int idp_brand_icon_ideal_size,
int idp_brand_icon_minimum_size,
FetchConfigCallback callback) override {
has_fetched_config_ = true;
IdpNetworkRequestManager::Endpoints endpoints;
endpoints.accounts = GURL(kAccountsEndpoint);
endpoints.token = GURL(kTokenEndpoint);
endpoints.disconnect = GURL(kDisconnectEndpoint);
IdentityProviderMetadata idp_metadata;
idp_metadata.config_url = GURL(config_.config_url);
idp_metadata.idp_login_url = GURL(kLoginUrl);
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE,
base::BindOnce(std::move(callback), config_.config_fetch_status,
endpoints, idp_metadata));
}
void SendDisconnectRequest(const GURL& disconnect_url,
const std::string& account_hint,
const std::string& client_id,
DisconnectCallback callback) override {
has_fetched_disconnect_ = true;
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE,
base::BindOnce(std::move(callback), config_.disconnect_fetch_status,
account_hint));
}
bool has_fetched_well_known_{false};
bool has_fetched_config_{false};
bool has_fetched_disconnect_{false};
private:
const Config config_;
};
class TestPermissionDelegate : public MockPermissionDelegate {
public:
std::optional<bool> GetIdpSigninStatus(
const url::Origin& idp_origin) override {
return true;
}
};
} // namespace
class FederatedAuthDisconnectRequestTest
: public RenderViewHostImplTestHarness {
public:
FederatedAuthDisconnectRequestTest() {
ukm_recorder_ = std::make_unique<ukm::TestAutoSetUkmRecorder>();
}
~FederatedAuthDisconnectRequestTest() override = default;
void SetUp() override {
RenderViewHostImplTestHarness::SetUp();
api_permission_delegate_ = std::make_unique<MockApiPermissionDelegate>();
permission_delegate_ = std::make_unique<TestPermissionDelegate>();
static_cast<TestWebContents*>(web_contents())
->NavigateAndCommit(GURL(kRpUrl), ui::PAGE_TRANSITION_LINK);
}
void TearDown() override {
network_manager_ = nullptr;
RenderViewHostImplTestHarness::TearDown();
}
void RunDisconnectTest(const Config& config,
DisconnectStatus expected_disconnect_status,
RenderFrameHost* rfh = nullptr) {
auto network_manager =
std::make_unique<TestIdpNetworkRequestManager>(config);
network_manager_ = network_manager.get();
if (!rfh) {
rfh = static_cast<RenderFrameHost*>(main_test_rfh());
}
if (expected_disconnect_status != DisconnectStatus::kSuccess) {
EXPECT_CALL(*permission_delegate_, RevokeSharingPermission(_, _, _, _))
.Times(0);
}
auto fedcm_metrics =
std::make_unique<webid::Metrics>(rfh->GetPageUkmSourceId());
blink::mojom::IdentityCredentialDisconnectOptionsPtr options =
blink::mojom::IdentityCredentialDisconnectOptions::New();
options->config = blink::mojom::IdentityProviderConfig::New();
options->config->config_url = GURL(config.config_url);
options->config->client_id = kClientId;
options->account_hint = "accountHint";
DisconnectRequestCallbackHelper callback_helper;
request_ = FederatedAuthDisconnectRequest::Create(
std::move(network_manager), permission_delegate_.get(), rfh,
std::move(fedcm_metrics), std::move(options));
request_->SetCallbackAndStart(callback_helper.callback(),
api_permission_delegate_.get());
callback_helper.WaitForCallback();
EXPECT_EQ(expected_disconnect_status, callback_helper.status());
}
void ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics status,
webid::RequesterFrameType requester_frame_type,
bool should_record_duration) {
histogram_tester_.ExpectUniqueSample("Blink.FedCm.Status.Disconnect",
status, 1);
histogram_tester_.ExpectUniqueSample("Blink.FedCm.Disconnect.FrameType",
requester_frame_type, 1);
histogram_tester_.ExpectTotalCount("Blink.FedCm.Timing.Disconnect",
should_record_duration ? 1 : 0);
ExpectDisconnectUKM(ukm::builders::Blink_FedCm::kEntryName, status,
requester_frame_type, should_record_duration);
ExpectDisconnectUKM(ukm::builders::Blink_FedCmIdp::kEntryName, status,
requester_frame_type, should_record_duration);
std::vector<std::string> messages =
RenderFrameHostTester::For(main_rfh())->GetConsoleMessages();
if (status == DisconnectStatusForMetrics::kSuccess) {
EXPECT_TRUE(messages.empty());
} else {
ASSERT_EQ(messages.size(), 1u);
EXPECT_EQ(messages[0], webid::GetDisconnectConsoleErrorMessage(status));
}
}
void ExpectDisconnectUKM(const char* entry_name,
DisconnectStatusForMetrics status,
webid::RequesterFrameType requester_frame_type,
bool should_record_duration) {
auto entries = ukm_recorder()->GetEntriesByName(entry_name);
ASSERT_FALSE(entries.empty())
<< "No " << entry_name << " entry was recorded";
// There are multiple types of metrics under the same FedCM UKM. We need to
// make sure that the metric only includes the expected one.
bool metric_found = false;
for (const ukm::mojom::UkmEntry* const entry : entries) {
if (!should_record_duration) {
EXPECT_FALSE(ukm_recorder()->GetEntryMetric(entry, "Timing.Disconnect"))
<< "Timing.Disconnect must not be present";
}
const int64_t* metric =
ukm_recorder()->GetEntryMetric(entry, "Status.Disconnect");
if (!metric) {
EXPECT_FALSE(ukm_recorder()->GetEntryMetric(entry, "Timing.Disconnect"))
<< "Timing.Disconnect must not be present when Status is not "
"present";
EXPECT_FALSE(
ukm_recorder()->GetEntryMetric(entry, "Disconnect.FrameType"))
<< "Disconnect.FrameType must not be present when Status is not "
"present";
continue;
}
EXPECT_FALSE(metric_found)
<< "Found more than one entry with Status.Disconnect in "
<< entry_name;
metric_found = true;
EXPECT_EQ(static_cast<int>(status), *metric)
<< "Unexpected status recorded in " << entry_name;
metric = ukm_recorder()->GetEntryMetric(entry, "Disconnect.FrameType");
ASSERT_TRUE(metric)
<< "Disconnect.FrameType must be present when Status is present";
EXPECT_EQ(static_cast<int>(requester_frame_type), *metric)
<< "Unexpected frame type recorded in " << entry_name;
if (should_record_duration) {
EXPECT_TRUE(ukm_recorder()->GetEntryMetric(entry, "Timing.Disconnect"))
<< "Timing.Disconnect must be present in the same entry as "
"Status.Disconnect";
}
}
EXPECT_TRUE(metric_found)
<< "No Status.Disconnect entry was found in " << entry_name;
}
bool DidFetchAnyEndpoint() {
return network_manager_->has_fetched_well_known_ ||
network_manager_->has_fetched_config_ ||
network_manager_->has_fetched_disconnect_;
}
bool DidFetchAllEndpoints() {
return network_manager_->has_fetched_well_known_ &&
network_manager_->has_fetched_config_ &&
network_manager_->has_fetched_disconnect_;
}
ukm::TestAutoSetUkmRecorder* ukm_recorder() { return ukm_recorder_.get(); }
protected:
raw_ptr<TestIdpNetworkRequestManager> network_manager_;
std::unique_ptr<MockApiPermissionDelegate> api_permission_delegate_;
std::unique_ptr<TestPermissionDelegate> permission_delegate_;
std::unique_ptr<FederatedAuthDisconnectRequest> request_;
base::HistogramTester histogram_tester_;
std::unique_ptr<ukm::TestAutoSetUkmRecorder> ukm_recorder_;
};
TEST_F(FederatedAuthDisconnectRequestTest, Success) {
Config config = kValidConfig;
EXPECT_CALL(
*permission_delegate_,
HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
OriginFromString(kProviderUrl)))
.WillOnce(Return(true));
EXPECT_CALL(*permission_delegate_,
RevokeSharingPermission(OriginFromString(kRpUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl), _));
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::GRANTED));
RunDisconnectTest(config, DisconnectStatus::kSuccess);
EXPECT_TRUE(DidFetchAllEndpoints());
ExpectDisconnectMetricsAndConsoleError(DisconnectStatusForMetrics::kSuccess,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/true);
}
TEST_F(FederatedAuthDisconnectRequestTest, NotTrustworthyIdP) {
Config config = kValidConfig;
config.config_url = "http://idp.example/fedcm.json";
RunDisconnectTest(config, DisconnectStatus::kError);
EXPECT_FALSE(DidFetchAnyEndpoint());
ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics::kIdpNotPotentiallyTrustworthy,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/false);
}
TEST_F(FederatedAuthDisconnectRequestTest,
NoSharingPermissionButIdpHasThirdPartyCookiesAccessAndClaimsSignin) {
const char kAccountId[] = "account";
Config config = kValidConfig;
config.accounts = {{kAccountId,
/*idp_claimed_login_state=*/LoginState::kSignIn,
/*was_granted_sharing_permission=*/false}};
// Pretend the IdP was given third-party cookies access.
EXPECT_CALL(*api_permission_delegate_,
HasThirdPartyCookiesAccess(_, GURL(kProviderUrl),
url::Origin::Create(GURL(kRpUrl))))
.WillOnce(Return(true));
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::GRANTED));
EXPECT_CALL(*permission_delegate_,
RevokeSharingPermission(OriginFromString(kRpUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl), _));
RunDisconnectTest(config, DisconnectStatus::kSuccess);
EXPECT_TRUE(DidFetchAllEndpoints());
ExpectDisconnectMetricsAndConsoleError(DisconnectStatusForMetrics::kSuccess,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/true);
}
TEST_F(FederatedAuthDisconnectRequestTest, SameSiteIframe) {
const char kSameSiteIframeUrl[] = "https://rp.example/iframe.html";
RenderFrameHost* same_site_iframe =
NavigationSimulator::NavigateAndCommitFromDocument(
GURL(kSameSiteIframeUrl),
RenderFrameHostTester::For(web_contents()->GetPrimaryMainFrame())
->AppendChild("same_site_iframe"));
Config config = kValidConfig;
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::GRANTED));
EXPECT_CALL(*permission_delegate_,
HasSharingPermission(OriginFromString(kSameSiteIframeUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl)))
.WillOnce(Return(true));
EXPECT_CALL(*permission_delegate_,
RevokeSharingPermission(OriginFromString(kSameSiteIframeUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl), _));
RunDisconnectTest(config, DisconnectStatus::kSuccess, same_site_iframe);
EXPECT_TRUE(DidFetchAllEndpoints());
ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics::kSuccess,
webid::RequesterFrameType::kSameSiteIframe,
/*should_record_duration=*/true);
}
TEST_F(FederatedAuthDisconnectRequestTest, CrossSiteIframe) {
// FedCM works due to third party cookies since sharing permission is not set
// for the cross-site RP.
const char kCrossSiteIframeUrl[] = "https://otherrp.com";
RenderFrameHost* cross_site_iframe =
NavigationSimulator::NavigateAndCommitFromDocument(
GURL(kCrossSiteIframeUrl),
RenderFrameHostTester::For(web_contents()->GetPrimaryMainFrame())
->AppendChild("cross_site_iframe"));
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::GRANTED));
EXPECT_CALL(*permission_delegate_,
HasSharingPermission(OriginFromString(kCrossSiteIframeUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl)))
.WillOnce(Return(true));
Config config = kValidConfig;
EXPECT_CALL(*permission_delegate_,
RevokeSharingPermission(OriginFromString(kCrossSiteIframeUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl), _));
RunDisconnectTest(config, DisconnectStatus::kSuccess, cross_site_iframe);
EXPECT_TRUE(DidFetchAllEndpoints());
ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics::kSuccess,
webid::RequesterFrameType::kCrossSiteIframe,
/*should_record_duration=*/true);
}
TEST_F(FederatedAuthDisconnectRequestTest, NoAccountToDisconnect) {
Config config = kValidConfig;
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::GRANTED));
EXPECT_CALL(
*permission_delegate_,
HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
OriginFromString(kProviderUrl)))
.WillOnce(Return(false));
RunDisconnectTest(config, DisconnectStatus::kError);
EXPECT_FALSE(DidFetchAnyEndpoint());
ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics::kNoAccountToDisconnect,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/false);
}
TEST_F(FederatedAuthDisconnectRequestTest, DisabledInSettings) {
Config config = kValidConfig;
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::BLOCKED_SETTINGS));
RunDisconnectTest(config, DisconnectStatus::kError);
EXPECT_FALSE(DidFetchAnyEndpoint());
ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics::kDisabledInSettings,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/false);
}
TEST_F(FederatedAuthDisconnectRequestTest, DisabledInFlags) {
Config config = kValidConfig;
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::BLOCKED_VARIATIONS));
RunDisconnectTest(config, DisconnectStatus::kError);
EXPECT_FALSE(DidFetchAnyEndpoint());
ExpectDisconnectMetricsAndConsoleError(
DisconnectStatusForMetrics::kDisabledInFlags,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/false);
}
// Tests that disconnect() succeeds even if FedCM is under embargo (e.g.
// cooldown).
TEST_F(FederatedAuthDisconnectRequestTest, SuccessDespiteEmbargo) {
Config config = kValidConfig;
EXPECT_CALL(*api_permission_delegate_,
GetApiPermissionStatus(OriginFromString(kRpUrl)))
.WillOnce(Return(PermissionStatus::BLOCKED_EMBARGO));
EXPECT_CALL(
*permission_delegate_,
HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
OriginFromString(kProviderUrl)))
.WillOnce(Return(true));
EXPECT_CALL(*permission_delegate_,
RevokeSharingPermission(OriginFromString(kRpUrl),
OriginFromString(kRpUrl),
OriginFromString(kProviderUrl), _));
RunDisconnectTest(config, DisconnectStatus::kSuccess);
EXPECT_TRUE(DidFetchAllEndpoints());
ExpectDisconnectMetricsAndConsoleError(DisconnectStatusForMetrics::kSuccess,
webid::RequesterFrameType::kMainFrame,
/*should_record_duration=*/true);
}
} // namespace content