// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "components/signin/ios/browser/account_consistency_service.h"

#import <WebKit/WebKit.h>

#import "base/apple/foundation_util.h"
#include "base/command_line.h"
#include "base/functional/bind.h"
#include "base/logging.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/sys_string_conversions.h"
#include "components/content_settings/core/browser/cookie_settings.h"
#include "components/google/core/common/google_util.h"
#include "components/signin/core/browser/account_reconcilor.h"
#include "components/signin/core/browser/chrome_connected_header_helper.h"
#include "components/signin/core/browser/signin_header_helper.h"
#include "components/signin/ios/browser/features.h"
#include "components/signin/public/identity_manager/accounts_cookie_mutator.h"
#include "components/signin/public/identity_manager/accounts_in_cookie_jar_info.h"
#include "google_apis/gaia/gaia_constants.h"
#include "google_apis/gaia/gaia_urls.h"
#include "ios/web/common/web_view_creation_util.h"
#include "ios/web/public/browser_state.h"
#import "ios/web/public/navigation/web_state_policy_decider.h"
#import "ios/web/public/web_state.h"
#include "ios/web/public/web_state_observer.h"
#include "net/base/mac/url_conversions.h"
#include "net/base/registry_controlled_domains/registry_controlled_domain.h"
#include "net/cookies/canonical_cookie.h"
#include "url/gurl.h"

namespace {

// The validity of the Gaia cookie on the Google domain is one hour to
// ensure that Mirror account consistency is respected in light of the more
// restrictive Intelligent Tracking Prevention (ITP) guidelines in iOS 14
// that may remove or invalidate Gaia cookies on the Google domain.
constexpr base::TimeDelta kDelayThresholdToUpdateGaiaCookie = base::Hours(1);

const char* kGoogleUrl = "https://google.com";
const char* kYoutubeUrl = "https://youtube.com";
const char* kGaiaDomain = "accounts.google.com";

// Returns the registered, organization-identifying host, but no subdomains,
// from the given GURL. Returns an empty string if the GURL is invalid.
static std::string GetDomainFromUrl(const GURL& url) {
  if (gaia::HasGaiaSchemeHostPort(url)) {
    return kGaiaDomain;
  }
  return net::registry_controlled_domains::GetDomainAndRegistry(
      url, net::registry_controlled_domains::EXCLUDE_PRIVATE_REGISTRIES);
}

// The Gaia cookie state on navigation for a signed-in Chrome user.
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
enum class GaiaCookieStateOnSignedInNavigation {
  kGaiaCookiePresentOnNavigation = 0,
  kGaiaCookieAbsentOnGoogleAssociatedDomainNavigation = 1,
  kGaiaCookieAbsentOnAddSessionNavigation = 2,
  kGaiaCookieRestoredOnShowInfobar = 3,
  kMaxValue = kGaiaCookieRestoredOnShowInfobar
};

// Records the state of Gaia cookies for a navigation in UMA histogram.
void LogIOSGaiaCookiesState(GaiaCookieStateOnSignedInNavigation state) {
  base::UmaHistogramEnumeration("Signin.IOSGaiaCookieStateOnSignedInNavigation",
                                state);
}

// Allows for manual testing by reducing the polling interval for verifying the
// existence of the GAIA cookie.
base::TimeDelta GetDelayThresholdToUpdateGaiaCookie() {
  const base::CommandLine* command_line =
      base::CommandLine::ForCurrentProcess();
  if (command_line->HasSwitch(
          signin::kDelayThresholdMinutesToUpdateGaiaCookie)) {
    std::string delayString = command_line->GetSwitchValueASCII(
        signin::kDelayThresholdMinutesToUpdateGaiaCookie);
    int commandLineDelay = 0;
    if (base::StringToInt(delayString, &commandLineDelay)) {
      return base::Minutes(commandLineDelay);
    }
  }
  return kDelayThresholdToUpdateGaiaCookie;
}

}  // namespace

// WebStatePolicyDecider that monitors the HTTP headers on Gaia responses,
// reacting on the X-Chrome-Manage-Accounts header and notifying its delegate.
// It also notifies the AccountConsistencyService of domains it should add the
// CHROME_CONNECTED cookie to.
class AccountConsistencyService::AccountConsistencyHandler
    : public web::WebStatePolicyDecider,
      public web::WebStateObserver {
 public:
  AccountConsistencyHandler(web::WebState* web_state,
                            AccountConsistencyService* service,
                            AccountReconcilor* account_reconcilor,
                            signin::IdentityManager* identity_manager,
                            ManageAccountsDelegate* delegate);

  void WebStateDestroyed(web::WebState* web_state) override;

  AccountConsistencyHandler(const AccountConsistencyHandler&) = delete;
  AccountConsistencyHandler& operator=(const AccountConsistencyHandler&) =
      delete;

 private:
  // web::WebStateObserver override.
  void PageLoaded(
      web::WebState* web_state,
      web::PageLoadCompletionStatus load_completion_status) override;

  // web::WebStatePolicyDecider override.
  void ShouldAllowRequest(
      NSURLRequest* request,
      web::WebStatePolicyDecider::RequestInfo request_info,
      web::WebStatePolicyDecider::PolicyDecisionCallback callback) override;
  // Decides on navigation corresponding to |response| whether the navigation
  // should continue and updates authentication cookies on Google domains.
  void ShouldAllowResponse(
      NSURLResponse* response,
      web::WebStatePolicyDecider::ResponseInfo response_info,
      web::WebStatePolicyDecider::PolicyDecisionCallback callback) override;
  void WebStateDestroyed() override;

  // Handles the AddAccount request depending on |has_cookie_changed|.
  void HandleAddAccountRequest(GURL url, BOOL has_cookie_changed);

  // The consistency web sign-in needs to be shown once the page is loaded.
  // It is required to avoid having the keyboard showing up on top of the web
  // sign-in dialog.
  bool show_consistency_web_signin_ = false;
  AccountConsistencyService* account_consistency_service_;  // Weak.
  AccountReconcilor* account_reconcilor_;                   // Weak.
  signin::IdentityManager* identity_manager_;
  web::WebState* web_state_;
  ManageAccountsDelegate* delegate_;  // Weak.
  base::WeakPtrFactory<AccountConsistencyHandler> weak_ptr_factory_;
};

AccountConsistencyService::AccountConsistencyHandler::AccountConsistencyHandler(
    web::WebState* web_state,
    AccountConsistencyService* service,
    AccountReconcilor* account_reconcilor,
    signin::IdentityManager* identity_manager,
    ManageAccountsDelegate* delegate)
    : web::WebStatePolicyDecider(web_state),
      account_consistency_service_(service),
      account_reconcilor_(account_reconcilor),
      identity_manager_(identity_manager),
      web_state_(web_state),
      delegate_(delegate),
      weak_ptr_factory_(this) {
  web_state->AddObserver(this);
}

void AccountConsistencyService::AccountConsistencyHandler::ShouldAllowRequest(
    NSURLRequest* request,
    web::WebStatePolicyDecider::RequestInfo request_info,
    web::WebStatePolicyDecider::PolicyDecisionCallback callback) {
  GURL url = net::GURLWithNSURL(request.URL);
  if (signin::IsUrlEligibleForMirrorCookie(url) &&
      identity_manager_->HasPrimaryAccount(signin::ConsentLevel::kSignin)) {
    // CHROME_CONNECTED cookies are added asynchronously on google.com and
    // youtube.com domains when Chrome detects that the user is signed-in. By
    // continuing to fulfill the navigation once the cookie request is sent,
    // Chrome adopts a best-effort strategy for signing the user into the web if
    // necessary.
    account_consistency_service_->AddChromeConnectedCookies();
  }
  std::move(callback).Run(PolicyDecision::Allow());
}

void AccountConsistencyService::AccountConsistencyHandler::ShouldAllowResponse(
    NSURLResponse* response,
    web::WebStatePolicyDecider::ResponseInfo response_info,
    web::WebStatePolicyDecider::PolicyDecisionCallback callback) {
  NSHTTPURLResponse* http_response =
      base::apple::ObjCCast<NSHTTPURLResponse>(response);
  if (!http_response) {
    std::move(callback).Run(PolicyDecision::Allow());
    return;
  }

  GURL url = net::GURLWithNSURL(http_response.URL);
  // User is showing intent to navigate to a Google-owned domain. Set GAIA and
  // CHROME_CONNECTED cookies if the user is signed in (this is filtered in
  // ChromeConnectedHelper).
  if (signin::IsUrlEligibleForMirrorCookie(url)) {
    account_consistency_service_->SetChromeConnectedCookieWithUrls(
        {url, GURL(kGoogleUrl)});
  }

  if (!gaia::HasGaiaSchemeHostPort(url)) {
    std::move(callback).Run(PolicyDecision::Allow());
    return;
  }

  NSString* manage_accounts_header = [[http_response allHeaderFields]
      objectForKey:
          [NSString stringWithUTF8String:signin::kChromeManageAccountsHeader]];
  if (!manage_accounts_header) {
    // Header that detects whether a user has been prompted to enter their
    // credentials on a Gaia sign-on page.
    NSString* x_autologin_header = [[http_response allHeaderFields]
        objectForKey:[NSString stringWithUTF8String:signin::kAutoLoginHeader]];
    if (x_autologin_header) {
      show_consistency_web_signin_ = true;
    }
    std::move(callback).Run(PolicyDecision::Allow());
    return;
  }

  signin::ManageAccountsParams params = signin::BuildManageAccountsParams(
      base::SysNSStringToUTF8(manage_accounts_header));

  account_reconcilor_->OnReceivedManageAccountsResponse(params.service_type);

  switch (params.service_type) {
    case signin::GAIA_SERVICE_TYPE_INCOGNITO: {
      GURL continue_url = GURL(params.continue_url);
      DLOG_IF(ERROR, !params.continue_url.empty() && !continue_url.is_valid())
          << "Invalid continuation URL: \"" << continue_url << "\"";
      if (delegate_)
        delegate_->OnGoIncognito(continue_url);
      break;
    }
    case signin::GAIA_SERVICE_TYPE_SIGNUP:
    case signin::GAIA_SERVICE_TYPE_ADDSESSION:
      // This situation is only possible if the all cookies have been deleted by
      // ITP restrictions and Chrome has not triggered a cookie refresh.
      if (identity_manager_->HasPrimaryAccount(signin::ConsentLevel::kSignin)) {
        LogIOSGaiaCookiesState(GaiaCookieStateOnSignedInNavigation::
                                   kGaiaCookieAbsentOnAddSessionNavigation);
        GURL continue_url = GURL(params.continue_url);
        DLOG_IF(ERROR, !params.continue_url.empty() && !continue_url.is_valid())
            << "Invalid continuation URL: \"" << continue_url << "\"";
        if (account_consistency_service_->RestoreGaiaCookies(base::BindOnce(
                &AccountConsistencyHandler::HandleAddAccountRequest,
                weak_ptr_factory_.GetWeakPtr(), continue_url))) {
          // Continue URL will be processed in a callback once Gaia cookies
          // have been restored.
          return;
        }
      }
      if (delegate_)
        delegate_->OnAddAccount();
      break;
    case signin::GAIA_SERVICE_TYPE_SIGNOUT:
    case signin::GAIA_SERVICE_TYPE_DEFAULT:
      if (delegate_)
        delegate_->OnManageAccounts();
      break;
    case signin::GAIA_SERVICE_TYPE_NONE:
      NOTREACHED();
      break;
  }

  // WKWebView loads a blank page even if the response code is 204
  // ("No Content"). http://crbug.com/368717
  //
  // Manage accounts responses are handled via native UI. Abort this request
  // for the following reasons:
  // * Avoid loading a blank page in WKWebView.
  // * Avoid adding this request to history.
  std::move(callback).Run(PolicyDecision::Cancel());
}

void AccountConsistencyService::AccountConsistencyHandler::
    HandleAddAccountRequest(GURL url, BOOL has_cookie_changed) {
  if (!has_cookie_changed) {
    // If the cookies on the device did not need to be updated then the user
    // is not in an inconsistent state (where the identities on the device
    // are different than those on the web). Fallback to asking the user to
    // add an account.
    if (delegate_)
      delegate_->OnAddAccount();
    return;
  }
  web_state_->OpenURL(web::WebState::OpenURLParams(
      url, web::Referrer(), WindowOpenDisposition::CURRENT_TAB,
      ui::PAGE_TRANSITION_AUTO_TOPLEVEL, false));
  if (delegate_)
    delegate_->OnRestoreGaiaCookies();
  LogIOSGaiaCookiesState(
      GaiaCookieStateOnSignedInNavigation::kGaiaCookieRestoredOnShowInfobar);
}

void AccountConsistencyService::AccountConsistencyHandler::PageLoaded(
    web::WebState* web_state,
    web::PageLoadCompletionStatus load_completion_status) {
  const GURL& url = web_state->GetLastCommittedURL();
  if (load_completion_status == web::PageLoadCompletionStatus::FAILURE ||
      !google_util::IsGoogleDomainUrl(
          url, google_util::ALLOW_SUBDOMAIN,
          google_util::DISALLOW_NON_STANDARD_PORTS)) {
    return;
  }

  if (delegate_ && show_consistency_web_signin_ &&
      gaia::HasGaiaSchemeHostPort(url)) {
    delegate_->OnShowConsistencyPromo(url, web_state);
  }
  show_consistency_web_signin_ = false;
}

void AccountConsistencyService::AccountConsistencyHandler::WebStateDestroyed(
    web::WebState* web_state) {}

void AccountConsistencyService::AccountConsistencyHandler::WebStateDestroyed() {
  account_consistency_service_->RemoveWebStateHandler(web_state());
}

AccountConsistencyService::AccountConsistencyService(
    web::BrowserState* browser_state,
    AccountReconcilor* account_reconcilor,
    scoped_refptr<content_settings::CookieSettings> cookie_settings,
    signin::IdentityManager* identity_manager)
    : browser_state_(browser_state),
      account_reconcilor_(account_reconcilor),
      cookie_settings_(cookie_settings),
      identity_manager_(identity_manager),
      active_cookie_manager_requests_for_testing_(0) {
  identity_manager_->AddObserver(this);
  if (identity_manager_->HasPrimaryAccount(signin::ConsentLevel::kSignin)) {
    AddChromeConnectedCookies();
  } else {
    RemoveAllChromeConnectedCookies(base::OnceClosure());
  }
}

AccountConsistencyService::~AccountConsistencyService() {}

BOOL AccountConsistencyService::RestoreGaiaCookies(
    base::OnceCallback<void(BOOL)> cookies_restored_callback) {
  // We currently enforce a time threshold to update the Gaia cookie
  // for signed-in users to prevent calling the expensive method
  // |GetAllCookies| in the cookie manager.
  if (last_gaia_cookie_update_time_.is_null() ||
      base::Time::Now() - last_gaia_cookie_update_time_ >
          GetDelayThresholdToUpdateGaiaCookie()) {
    network::mojom::CookieManager* cookie_manager =
        browser_state_->GetCookieManager();
    cookie_manager->GetCookieList(
        GaiaUrls::GetInstance()->secure_google_url(),
        net::CookieOptions::MakeAllInclusive(),
        net::CookiePartitionKeyCollection::Todo(),
        base::BindOnce(
            &AccountConsistencyService::TriggerGaiaCookieChangeIfDeleted,
            base::Unretained(this), std::move(cookies_restored_callback)));
    last_gaia_cookie_update_time_ = base::Time::Now();
    return YES;
  }
  return NO;
}

void AccountConsistencyService::TriggerGaiaCookieChangeIfDeleted(
    base::OnceCallback<void(BOOL)> cookies_restored_callback,
    const net::CookieAccessResultList& cookie_list,
    const net::CookieAccessResultList& unused_excluded_cookies) {
  gaia_cookies_restored_callbacks_.push_back(
      std::move(cookies_restored_callback));

  for (const auto& cookie : cookie_list) {
    // There may be other cookies besides `kGaiaSigninCookieName` that are
    // required. However these cookies are not specified by the server, and thus
    // Chrome cannot monitor them. We assume here that ITP will remove all the
    // cookies fom the domain at once, and so it is sufficient to monitor only
    // one cookie.
    if (cookie.cookie.Name() == GaiaConstants::kGaiaSigninCookieName) {
      LogIOSGaiaCookiesState(
          GaiaCookieStateOnSignedInNavigation::kGaiaCookiePresentOnNavigation);
      RunGaiaCookiesRestoredCallbacks(/*has_cookie_changed=*/NO);
      return;
    }
  }

  // The SAPISID cookie may have been deleted previous to this update due to
  // ITP restrictions marking Google domains as potential trackers.
  LogIOSGaiaCookiesState(
      GaiaCookieStateOnSignedInNavigation::
          kGaiaCookieAbsentOnGoogleAssociatedDomainNavigation);

  // Re-generate cookie to ensure that the user is properly signed in.
  identity_manager_->GetAccountsCookieMutator()->ForceTriggerOnCookieChange();
}

void AccountConsistencyService::RunGaiaCookiesRestoredCallbacks(
    BOOL has_cookie_changed) {
  std::vector<base::OnceCallback<void(BOOL)>> callbacks;
  std::swap(gaia_cookies_restored_callbacks_, callbacks);
  for (base::OnceCallback<void(BOOL)>& callback : callbacks) {
    std::move(callback).Run(has_cookie_changed);
  }
}

void AccountConsistencyService::SetWebStateHandler(
    web::WebState* web_state,
    ManageAccountsDelegate* delegate) {
  DCHECK(!is_shutdown_) << "SetWebStateHandler called after Shutdown";
  DCHECK(handlers_map_.find(web_state) == handlers_map_.end());
  handlers_map_.insert(std::make_pair(
      web_state,
      std::make_unique<AccountConsistencyHandler>(
          web_state, this, account_reconcilor_, identity_manager_, delegate)));
}

void AccountConsistencyService::RemoveWebStateHandler(
    web::WebState* web_state) {
  DCHECK(!is_shutdown_) << "RemoveWebStateHandler called after Shutdown";
  auto iter = handlers_map_.find(web_state);
  DCHECK(iter != handlers_map_.end());

  std::unique_ptr<AccountConsistencyHandler> handler = std::move(iter->second);
  handlers_map_.erase(iter);

  web_state->RemoveObserver(handler.get());
}

void AccountConsistencyService::RemoveAllChromeConnectedCookies(
    base::OnceClosure callback) {
  DCHECK(!browser_state_->IsOffTheRecord());

  network::mojom::CookieManager* cookie_manager =
      browser_state_->GetCookieManager();

  network::mojom::CookieDeletionFilterPtr filter =
      network::mojom::CookieDeletionFilter::New();
  filter->cookie_name = signin::kChromeConnectedCookieName;

  ++active_cookie_manager_requests_for_testing_;
  cookie_manager->DeleteCookies(
      std::move(filter),
      base::BindOnce(&AccountConsistencyService::OnDeleteCookiesFinished,
                     base::Unretained(this), std::move(callback)));
}

void AccountConsistencyService::OnDeleteCookiesFinished(
    base::OnceClosure callback,
    uint32_t unused_num_cookies_deleted) {
  --active_cookie_manager_requests_for_testing_;
  if (!callback.is_null()) {
    std::move(callback).Run();
  }
}

void AccountConsistencyService::SetChromeConnectedCookieWithUrls(
    const std::vector<const GURL>& urls) {
  for (const GURL& url : urls) {
    SetChromeConnectedCookieWithUrl(url);
  }
}

void AccountConsistencyService::Shutdown() {
  DCHECK(handlers_map_.empty()) << "Handlers not unregistered at shutdown";
  identity_manager_->RemoveObserver(this);
  is_shutdown_ = true;
}

void AccountConsistencyService::SetChromeConnectedCookieWithUrl(
    const GURL& url) {
  const std::string domain = GetDomainFromUrl(url);
  std::string cookie_value = signin::BuildMirrorRequestCookieIfPossible(
      url,
      identity_manager_->GetPrimaryAccountInfo(signin::ConsentLevel::kSignin)
          .gaia,
      signin::AccountConsistencyMethod::kMirror, cookie_settings_.get(),
      signin::PROFILE_MODE_DEFAULT);
  if (cookie_value.empty()) {
    return;
  }

  std::unique_ptr<net::CanonicalCookie> cookie =
      net::CanonicalCookie::CreateSanitizedCookie(
          url,
          /*name=*/signin::kChromeConnectedCookieName, cookie_value,
          /*domain=*/domain,
          /*path=*/std::string(),
          /*creation_time=*/base::Time::Now(),
          // Create expiration date of Now+2y to roughly follow the SAPISID
          // cookie.
          /*expiration_time=*/base::Time::Now() + base::Days(730),
          /*last_access_time=*/base::Time(),
          /*secure=*/true,
          /*httponly=*/false, net::CookieSameSite::LAX_MODE,
          net::COOKIE_PRIORITY_DEFAULT, /*same_party=*/false,
          /*partition_key=*/absl::nullopt);
  net::CookieOptions options;
  options.set_include_httponly();
  options.set_same_site_cookie_context(
      net::CookieOptions::SameSiteCookieContext::MakeInclusive());

  ++active_cookie_manager_requests_for_testing_;

  network::mojom::CookieManager* cookie_manager =
      browser_state_->GetCookieManager();
  cookie_manager->SetCanonicalCookie(
      *cookie, url, options,
      base::BindOnce(
          &AccountConsistencyService::OnChromeConnectedCookieFinished,
          base::Unretained(this)));
}

void AccountConsistencyService::OnChromeConnectedCookieFinished(
    net::CookieAccessResult cookie_access_result) {
  DCHECK(cookie_access_result.status.IsInclude());
  --active_cookie_manager_requests_for_testing_;
}

void AccountConsistencyService::AddChromeConnectedCookies() {
  DCHECK(!browser_state_->IsOffTheRecord());
  // These cookie requests are preventive. Chrome cannot be sure that
  // CHROME_CONNECTED cookies are set on google.com and youtube.com domains due
  // to ITP restrictions.
  SetChromeConnectedCookieWithUrls({GURL(kGoogleUrl), GURL(kYoutubeUrl)});
}

void AccountConsistencyService::OnBrowsingDataRemoved() {
  // SAPISID cookie has been removed, notify the GCMS.
  // TODO(https://crbug.com/930582) : Remove the need to expose this method
  // or move it to the network::CookieManager.
  identity_manager_->GetAccountsCookieMutator()->ForceTriggerOnCookieChange();
}

void AccountConsistencyService::OnPrimaryAccountChanged(
    const signin::PrimaryAccountChangeEvent& event) {
  switch (event.GetEventTypeFor(signin::ConsentLevel::kSignin)) {
    case signin::PrimaryAccountChangeEvent::Type::kSet:
    case signin::PrimaryAccountChangeEvent::Type::kNone:
      AddChromeConnectedCookies();
      break;
    case signin::PrimaryAccountChangeEvent::Type::kCleared:
      RemoveAllChromeConnectedCookies(base::OnceClosure());
      break;
  }
}

void AccountConsistencyService::OnAccountsInCookieUpdated(
    const signin::AccountsInCookieJarInfo& accounts_in_cookie_jar_info,
    const GoogleServiceAuthError& error) {
  AddChromeConnectedCookies();

  // If signed-in accounts have been recently restored through GAIA cookie
  // restoration then run the relevant callback to finish the update process.
  if (accounts_in_cookie_jar_info.signed_in_accounts.size() > 0) {
    RunGaiaCookiesRestoredCallbacks(/*has_cookie_changed=*/YES);
  }
}
