diff --git a/py/selenium/webdriver/common/fedcm/account.py b/py/selenium/webdriver/common/fedcm/account.py new file mode 100644 index 0000000000000..6b8c20b12c781 --- /dev/null +++ b/py/selenium/webdriver/common/fedcm/account.py @@ -0,0 +1,71 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from enum import Enum +from typing import Optional + + +class LoginState(Enum): + SIGN_IN = "SignIn" + SIGN_UP = "SignUp" + + +class Account: + """Represents an account displayed in a FedCM account list. + + See: https://w3c-fedid.github.io/FedCM/#dictdef-identityprovideraccount + https://w3c-fedid.github.io/FedCM/#webdriver-accountlist + """ + + def __init__(self, account_data): + self._account_data = account_data + + @property + def account_id(self) -> Optional[str]: + return self._account_data.get("accountId") + + @property + def email(self) -> Optional[str]: + return self._account_data.get("email") + + @property + def name(self) -> Optional[str]: + return self._account_data.get("name") + + @property + def given_name(self) -> Optional[str]: + return self._account_data.get("givenName") + + @property + def picture_url(self) -> Optional[str]: + return self._account_data.get("pictureUrl") + + @property + def idp_config_url(self) -> Optional[str]: + return self._account_data.get("idpConfigUrl") + + @property + def terms_of_service_url(self) -> Optional[str]: + return self._account_data.get("termsOfServiceUrl") + + @property + def privacy_policy_url(self) -> Optional[str]: + return self._account_data.get("privacyPolicyUrl") + + @property + def login_state(self) -> Optional[str]: + return self._account_data.get("loginState") diff --git a/py/selenium/webdriver/common/fedcm/dialog.py b/py/selenium/webdriver/common/fedcm/dialog.py new file mode 100644 index 0000000000000..fce069d00d767 --- /dev/null +++ b/py/selenium/webdriver/common/fedcm/dialog.py @@ -0,0 +1,64 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import List +from typing import Optional + +from .account import Account + + +class Dialog: + """Represents a FedCM dialog that can be interacted with.""" + + DIALOG_TYPE_ACCOUNT_LIST = "AccountChooser" + DIALOG_TYPE_AUTO_REAUTH = "AutoReauthn" + + def __init__(self, driver) -> None: + self._driver = driver + + @property + def type(self) -> Optional[str]: + """Gets the type of the dialog currently being shown.""" + return self._driver.fedcm.dialog_type + + @property + def title(self) -> str: + """Gets the title of the dialog.""" + return self._driver.fedcm.title + + @property + def subtitle(self) -> Optional[str]: + """Gets the subtitle of the dialog.""" + result = self._driver.fedcm.subtitle + return result.get("subtitle") if result else None + + def get_accounts(self) -> List[Account]: + """Gets the list of accounts shown in the dialog.""" + accounts = self._driver.fedcm.account_list + return [Account(account) for account in accounts] + + def select_account(self, index: int) -> None: + """Selects an account from the dialog by index.""" + self._driver.fedcm.select_account(index) + + def accept(self) -> None: + """Clicks the continue button in the dialog.""" + self._driver.fedcm.accept() + + def dismiss(self) -> None: + """Cancels/dismisses the dialog.""" + self._driver.fedcm.dismiss() diff --git a/py/selenium/webdriver/common/options.py b/py/selenium/webdriver/common/options.py index 3e754d2537ba6..b31fcc348ce18 100644 --- a/py/selenium/webdriver/common/options.py +++ b/py/selenium/webdriver/common/options.py @@ -491,6 +491,8 @@ def ignore_local_proxy_environment_variables(self) -> None: class ArgOptions(BaseOptions): BINARY_LOCATION_ERROR = "Binary Location Must be a String" + # FedCM capability key + FEDCM_CAPABILITY = "fedcm:accounts" def __init__(self) -> None: super().__init__() diff --git a/py/selenium/webdriver/remote/command.py b/py/selenium/webdriver/remote/command.py index 0c104c2a46ab2..b079ed5406f53 100644 --- a/py/selenium/webdriver/remote/command.py +++ b/py/selenium/webdriver/remote/command.py @@ -122,3 +122,13 @@ class Command: GET_DOWNLOADABLE_FILES: str = "getDownloadableFiles" DOWNLOAD_FILE: str = "downloadFile" DELETE_DOWNLOADABLE_FILES: str = "deleteDownloadableFiles" + + # Federated Credential Management (FedCM) + GET_FEDCM_TITLE: str = "getFedcmTitle" + GET_FEDCM_DIALOG_TYPE: str = "getFedcmDialogType" + GET_FEDCM_ACCOUNT_LIST: str = "getFedcmAccountList" + SELECT_FEDCM_ACCOUNT: str = "selectFedcmAccount" + CLICK_FEDCM_DIALOG_BUTTON: str = "clickFedcmDialogButton" + CANCEL_FEDCM_DIALOG: str = "cancelFedcmDialog" + SET_FEDCM_DELAY: str = "setFedcmDelay" + RESET_FEDCM_COOLDOWN: str = "resetFedcmCooldown" diff --git a/py/selenium/webdriver/remote/fedcm.py b/py/selenium/webdriver/remote/fedcm.py new file mode 100644 index 0000000000000..eb2331923c2d5 --- /dev/null +++ b/py/selenium/webdriver/remote/fedcm.py @@ -0,0 +1,70 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import List +from typing import Optional + +from .command import Command + + +class FedCM: + def __init__(self, driver) -> None: + self._driver = driver + + @property + def title(self) -> str: + """Gets the title of the dialog.""" + return self._driver.execute(Command.GET_FEDCM_TITLE)["value"].get("title") + + @property + def subtitle(self) -> Optional[str]: + """Gets the subtitle of the dialog.""" + return self._driver.execute(Command.GET_FEDCM_TITLE)["value"].get("subtitle") + + @property + def dialog_type(self) -> str: + """Gets the type of the dialog currently being shown.""" + return self._driver.execute(Command.GET_FEDCM_DIALOG_TYPE).get("value") + + @property + def account_list(self) -> List[dict]: + """Gets the list of accounts shown in the dialog.""" + return self._driver.execute(Command.GET_FEDCM_ACCOUNT_LIST).get("value") + + def select_account(self, index: int) -> None: + """Selects an account from the dialog by index.""" + self._driver.execute(Command.SELECT_FEDCM_ACCOUNT, {"accountIndex": index}) + + def accept(self) -> None: + """Clicks the continue button in the dialog.""" + self._driver.execute(Command.CLICK_FEDCM_DIALOG_BUTTON, {"dialogButton": "ConfirmIdpLoginContinue"}) + + def dismiss(self) -> None: + """Cancels/dismisses the FedCM dialog.""" + self._driver.execute(Command.CANCEL_FEDCM_DIALOG) + + def enable_delay(self) -> None: + """Re-enables the promise rejection delay for FedCM.""" + self._driver.execute(Command.SET_FEDCM_DELAY, {"enabled": True}) + + def disable_delay(self) -> None: + """Disables the promise rejection delay for FedCM.""" + self._driver.execute(Command.SET_FEDCM_DELAY, {"enabled": False}) + + def reset_cooldown(self) -> None: + """Resets the FedCM dialog cooldown, allowing immediate retriggers.""" + self._driver.execute(Command.RESET_FEDCM_COOLDOWN) diff --git a/py/selenium/webdriver/remote/remote_connection.py b/py/selenium/webdriver/remote/remote_connection.py index 5404c53a4139e..1bc432084b6e2 100644 --- a/py/selenium/webdriver/remote/remote_connection.py +++ b/py/selenium/webdriver/remote/remote_connection.py @@ -126,6 +126,15 @@ Command.GET_DOWNLOADABLE_FILES: ("GET", "/session/$sessionId/se/files"), Command.DOWNLOAD_FILE: ("POST", "/session/$sessionId/se/files"), Command.DELETE_DOWNLOADABLE_FILES: ("DELETE", "/session/$sessionId/se/files"), + # Federated Credential Management (FedCM) + Command.GET_FEDCM_TITLE: ("GET", "/session/$sessionId/fedcm/gettitle"), + Command.GET_FEDCM_DIALOG_TYPE: ("GET", "/session/$sessionId/fedcm/getdialogtype"), + Command.GET_FEDCM_ACCOUNT_LIST: ("GET", "/session/$sessionId/fedcm/accountlist"), + Command.CLICK_FEDCM_DIALOG_BUTTON: ("POST", "/session/$sessionId/fedcm/clickdialogbutton"), + Command.CANCEL_FEDCM_DIALOG: ("POST", "/session/$sessionId/fedcm/canceldialog"), + Command.SELECT_FEDCM_ACCOUNT: ("POST", "/session/$sessionId/fedcm/selectaccount"), + Command.SET_FEDCM_DELAY: ("POST", "/session/$sessionId/fedcm/setdelayenabled"), + Command.RESET_FEDCM_COOLDOWN: ("POST", "/session/$sessionId/fedcm/resetcooldown"), } diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index bae7f4e8d28c1..c2dc89551d6ba 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -43,6 +43,7 @@ from selenium.common.exceptions import WebDriverException from selenium.webdriver.common.bidi.script import Script from selenium.webdriver.common.by import By +from selenium.webdriver.common.options import ArgOptions from selenium.webdriver.common.options import BaseOptions from selenium.webdriver.common.print_page_options import PrintOptions from selenium.webdriver.common.timeouts import Timeouts @@ -53,10 +54,12 @@ ) from selenium.webdriver.support.relative_locator import RelativeBy +from ..common.fedcm.dialog import Dialog from .bidi_connection import BidiConnection from .client_config import ClientConfig from .command import Command from .errorhandler import ErrorHandler +from .fedcm import FedCM from .file_detector import FileDetector from .file_detector import LocalFileDetector from .locator_converter import LocatorConverter @@ -236,6 +239,7 @@ def __init__( self._authenticator_id = None self.start_client() self.start_session(capabilities) + self._fedcm = FedCM(self) self._websocket_connection = None self._script = None @@ -1222,3 +1226,77 @@ def delete_downloadable_files(self) -> None: raise WebDriverException("You must enable downloads in order to work with downloadable files.") self.execute(Command.DELETE_DOWNLOADABLE_FILES) + + @property + def fedcm(self) -> FedCM: + """ + :Returns: + - FedCM: an object providing access to all Federated Credential Management (FedCM) dialog commands. + + :Usage: + :: + + title = driver.fedcm.title + subtitle = driver.fedcm.subtitle + dialog_type = driver.fedcm.dialog_type + accounts = driver.fedcm.account_list + driver.fedcm.select_account(0) + driver.fedcm.accept() + driver.fedcm.dismiss() + driver.fedcm.enable_delay() + driver.fedcm.disable_delay() + driver.fedcm.reset_cooldown() + """ + return self._fedcm + + @property + def supports_fedcm(self) -> bool: + """Returns whether the browser supports FedCM capabilities.""" + return self.capabilities.get(ArgOptions.FEDCM_CAPABILITY, False) + + def _require_fedcm_support(self): + """Raises an exception if FedCM is not supported.""" + if not self.supports_fedcm: + raise WebDriverException( + "This browser does not support Federated Credential Management. " + "Please ensure you're using a supported browser." + ) + + @property + def dialog(self): + """Returns the FedCM dialog object for interaction.""" + self._require_fedcm_support() + return Dialog(self) + + def fedcm_dialog(self, timeout=5, poll_frequency=0.5, ignored_exceptions=None): + """Waits for and returns the FedCM dialog. + + Args: + timeout: How long to wait for the dialog + poll_frequency: How frequently to poll + ignored_exceptions: Exceptions to ignore while waiting + + Returns: + The FedCM dialog object if found + + Raises: + TimeoutException if dialog doesn't appear + WebDriverException if FedCM not supported + """ + from selenium.common.exceptions import NoAlertPresentException + from selenium.webdriver.support.wait import WebDriverWait + + self._require_fedcm_support() + + if ignored_exceptions is None: + ignored_exceptions = (NoAlertPresentException,) + + def _check_fedcm(): + try: + dialog = Dialog(self) + return dialog if dialog.type else None + except NoAlertPresentException: + return None + + wait = WebDriverWait(self, timeout, poll_frequency=poll_frequency, ignored_exceptions=ignored_exceptions) + return wait.until(lambda _: _check_fedcm()) diff --git a/py/test/selenium/webdriver/common/fedcm_tests.py b/py/test/selenium/webdriver/common/fedcm_tests.py new file mode 100644 index 0000000000000..868fdd991e27e --- /dev/null +++ b/py/test/selenium/webdriver/common/fedcm_tests.py @@ -0,0 +1,138 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest + +from selenium.common.exceptions import NoAlertPresentException + + +@pytest.mark.xfail_safari(reason="FedCM not supported") +@pytest.mark.xfail_firefox(reason="FedCM not supported") +@pytest.mark.xfail_ie(reason="FedCM not supported") +@pytest.mark.xfail_remote(reason="FedCM not supported, since remote uses Firefox") +class TestFedCM: + @pytest.fixture(autouse=True) + def setup(self, driver, webserver): + driver.get(webserver.where_is("fedcm/fedcm.html", localhost=True)) + self.dialog = driver.dialog + + def test_no_dialog_title(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.title + + def test_no_dialog_subtitle(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.subtitle + + def test_no_dialog_type(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.type + + def test_no_dialog_get_accounts(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.get_accounts() + + def test_no_dialog_select_account(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.select_account(1) + + def test_no_dialog_cancel(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.dismiss() + + def test_no_dialog_click_continue(driver): + with pytest.raises(NoAlertPresentException): + driver.dialog.accept() + + def test_trigger_and_verify_dialog_title(self, driver): + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + assert dialog.title == "Sign in to localhost with localhost" + + def test_trigger_and_verify_dialog_subtitle(self, driver): + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + assert dialog.subtitle is None + + def test_trigger_and_verify_dialog_type(self, driver): + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + assert dialog.type == "AccountChooser" + + def test_trigger_and_verify_account_list(self, driver): + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + accounts = dialog.get_accounts() + assert len(accounts) > 0 + assert accounts[0].name == "John Doe" + + def test_select_account(self, driver): + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + dialog.select_account(1) + driver.fedcm_dialog() # Wait for dialog to become interactable + # dialog.click_continue() + + def test_dialog_cancel(self, driver): + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + dialog.dismiss() + with pytest.raises(NoAlertPresentException): + dialog.title + + def test_enable_fedcm_delay(self, driver): + driver.fedcm.enable_delay() + + def test_disable_fedcm_delay(self, driver): + driver.fedcm.disable_delay() + + def test_fedcm_cooldown_reset(self, driver): + driver.fedcm.reset_cooldown() + + def test_fedcm_no_dialog_type_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.dialog_type + + def test_fedcm_no_title_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.title + + def test_fedcm_no_subtitle_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.subtitle + + def test_fedcm_no_account_list_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.account_list() + + def test_fedcm_no_select_account_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.select_account(1) + + def test_fedcm_no_cancel_dialog_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.dismiss() + + def test_fedcm_no_click_continue_present(self, driver): + with pytest.raises(NoAlertPresentException): + driver.fedcm.accept() + + def test_verify_dialog_type_after_cooldown_reset(self, driver): + driver.fedcm.reset_cooldown() + driver.execute_script("triggerFedCm();") + dialog = driver.fedcm_dialog() + assert dialog.type == "AccountChooser" diff --git a/py/test/unit/selenium/webdriver/common/fedcm/account_tests.py b/py/test/unit/selenium/webdriver/common/fedcm/account_tests.py new file mode 100644 index 0000000000000..419a44afad949 --- /dev/null +++ b/py/test/unit/selenium/webdriver/common/fedcm/account_tests.py @@ -0,0 +1,44 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from selenium.webdriver.common.fedcm.account import Account + + +def test_account_properties(): + account_data = { + "accountId": "12341234", + "email": "test@email.com", + "name": "Real Name", + "givenName": "Test Name", + "pictureUrl": "picture-url", + "idpConfigUrl": "idp-config-url", + "loginState": "login-state", + "termsOfServiceUrl": "terms-of-service-url", + "privacyPolicyUrl": "privacy-policy-url", + } + + account = Account(account_data) + + assert account.account_id == "12341234" + assert account.email == "test@email.com" + assert account.name == "Real Name" + assert account.given_name == "Test Name" + assert account.picture_url == "picture-url" + assert account.idp_config_url == "idp-config-url" + assert account.login_state == "login-state" + assert account.terms_of_service_url == "terms-of-service-url" + assert account.privacy_policy_url == "privacy-policy-url" diff --git a/py/test/unit/selenium/webdriver/common/fedcm/dialog_tests.py b/py/test/unit/selenium/webdriver/common/fedcm/dialog_tests.py new file mode 100644 index 0000000000000..30224b723a83a --- /dev/null +++ b/py/test/unit/selenium/webdriver/common/fedcm/dialog_tests.py @@ -0,0 +1,88 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from unittest.mock import Mock +from unittest.mock import patch + +import pytest + +from selenium.webdriver.common.fedcm.dialog import Dialog + + +@pytest.fixture +def mock_driver(): + return Mock() + + +@pytest.fixture +def fedcm(mock_driver): + fedcm = Mock() + mock_driver.fedcm = fedcm + return fedcm + + +@pytest.fixture +def dialog(mock_driver, fedcm): + return Dialog(mock_driver) + + +def test_click_continue(dialog, fedcm): + dialog.accept() + fedcm.accept.assert_called_once() + + +def test_cancel(dialog, fedcm): + dialog.dismiss() + fedcm.dismiss.assert_called_once() + + +def test_select_account(dialog, fedcm): + dialog.select_account(1) + fedcm.select_account.assert_called_once_with(1) + + +def test_type(dialog, fedcm): + fedcm.dialog_type = "AccountChooser" + assert dialog.type == "AccountChooser" + + +def test_title(dialog, fedcm): + fedcm.title = "Sign in" + assert dialog.title == "Sign in" + + +def test_subtitle(dialog, fedcm): + fedcm.subtitle = {"subtitle": "Choose an account"} + assert dialog.subtitle == "Choose an account" + + +def test_get_accounts(dialog, fedcm): + accounts_data = [ + {"name": "Account1", "email": "account1@example.com"}, + {"name": "Account2", "email": "account2@example.com"}, + ] + fedcm.account_list = accounts_data + + with patch("selenium.webdriver.common.fedcm.account.Account") as MockAccount: + MockAccount.return_value = Mock() # Mock the Account instance + accounts = dialog.get_accounts() + + assert len(accounts) == 2 + assert accounts[0].name == "Account1" + assert accounts[0].email == "account1@example.com" + assert accounts[1].name == "Account2" + assert accounts[1].email == "account2@example.com"