diff --git a/swh/auth/pytest_plugin.py b/swh/auth/pytest_plugin.py index 4112add..3842c6f 100644 --- a/swh/auth/pytest_plugin.py +++ b/swh/auth/pytest_plugin.py @@ -1,184 +1,185 @@ # Copyright (C) 2020-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from copy import copy from datetime import datetime, timezone from typing import Dict, List, Optional from unittest.mock import Mock from keycloak.exceptions import KeycloakError import pytest from swh.auth.keycloak import KeycloakOpenIDConnect from swh.auth.tests.sample_data import OIDC_PROFILE, RAW_REALM_PUBLIC_KEY, USER_INFO class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect): """Mock KeycloakOpenIDConnect class to allow testing Args: server_url: Server main auth url (cf. :py:data:`swh.auth.tests.sample_data.SERVER_URL`) realm_name: Realm (cf. :py:data:`swh.auth.tests.sample_data.REALM_NAME`) client_id: Client id (cf. :py:data:`swh.auth.tests.sample_data.CLIENT_ID`) auth_success: boolean flag to simulate authentication success or failure exp: expiration delay user_groups: user groups configuration (if any) user_permissions: user permissions configuration (if any) oidc_profile: Dict response from a call to a token authentication query (cf. :py:data:`swh.auth.tests.sample_data.OIDC_PROFILE`) user_info: Dict response from a call to userinfo query (cf. :py:data:`swh.auth.tests.sample_data.USER_INFO`) raw_realm_public_key: A raw ascii text representing the realm public key (cf. :py:data:`swh.auth.tests.sample_data.RAW_REALM_PUBLIC_KEY`) """ def __init__( self, server_url: str, realm_name: str, client_id: str, auth_success: bool = True, exp: Optional[int] = None, user_groups: List[str] = [], user_permissions: List[str] = [], oidc_profile: Dict = OIDC_PROFILE, user_info: Dict = USER_INFO, raw_realm_public_key: str = RAW_REALM_PUBLIC_KEY, ): super().__init__( server_url=server_url, realm_name=realm_name, client_id=client_id ) self.exp = exp self.user_groups = user_groups self.user_permissions = user_permissions self._keycloak.public_key = lambda: raw_realm_public_key self._keycloak.well_know = lambda: { "issuer": f"{self.server_url}realms/{self.realm_name}", "authorization_endpoint": ( f"{self.server_url}realms/" f"{self.realm_name}/protocol/" "openid-connect/auth" ), "token_endpoint": ( f"{self.server_url}realms/{self.realm_name}/" "protocol/openid-connect/token" ), "token_introspection_endpoint": ( f"{self.server_url}realms/" f"{self.realm_name}/protocol/" "openid-connect/token/" "introspect" ), "userinfo_endpoint": ( f"{self.server_url}realms/{self.realm_name}/" "protocol/openid-connect/userinfo" ), "end_session_endpoint": ( f"{self.server_url}realms/" f"{self.realm_name}/protocol/" "openid-connect/logout" ), "jwks_uri": ( f"{self.server_url}realms/{self.realm_name}/" "protocol/openid-connect/certs" ), } self.set_auth_success(auth_success, oidc_profile, user_info) def decode_token(self, token): options = {} if self.auth_success: # skip signature expiration and audience checks as we use a static # oidc_profile for the tests with expired tokens in it options["verify_exp"] = False options["verify_aud"] = False decoded = super().decode_token(token, options) # Merge the user info configured to be part of the decode token userinfo = self.userinfo() if userinfo is not None: decoded = {**decoded, **userinfo} # tweak auth and exp time for tests expire_in = decoded["exp"] - decoded["auth_time"] if self.exp is not None: decoded["exp"] = self.exp decoded["auth_time"] = self.exp - expire_in else: decoded["auth_time"] = int(datetime.now(tz=timezone.utc).timestamp()) decoded["exp"] = decoded["auth_time"] + expire_in decoded["groups"] = self.user_groups decoded["aud"] = [self.client_id, "account"] decoded["azp"] = self.client_id if self.user_permissions: decoded["resource_access"][self.client_id] = { "roles": self.user_permissions } return decoded def set_auth_success( self, auth_success: bool, oidc_profile: Optional[Dict] = None, user_info: Optional[Dict] = None, ) -> None: # following type ignore because mypy is not too happy about affecting mock to # method "Cannot assign to a method affecting mock". Ignore for now. self.authorization_code = Mock() # type: ignore self.refresh_token = Mock() # type: ignore self.login = Mock() # type: ignore self.userinfo = Mock() # type: ignore self.logout = Mock() # type: ignore self.auth_success = auth_success if auth_success: self.authorization_code.return_value = copy(oidc_profile) self.refresh_token.return_value = copy(oidc_profile) self.login.return_value = copy(oidc_profile) self.userinfo.return_value = copy(user_info) else: self.authorization_url = Mock() # type: ignore exception = KeycloakError( error_message="Authentication failed", response_code=401 ) self.authorization_code.side_effect = exception self.authorization_url.side_effect = exception self.refresh_token.side_effect = exception self.userinfo.side_effect = exception self.logout.side_effect = exception + self.login.side_effect = exception def keycloak_mock_factory( server_url: str, realm_name: str, client_id: str, auth_success: bool = True, exp: Optional[int] = None, user_groups: List[str] = [], user_permissions: List[str] = [], oidc_profile: Dict = OIDC_PROFILE, user_info: Dict = USER_INFO, raw_realm_public_key: str = RAW_REALM_PUBLIC_KEY, ): """Keycloak mock fixture factory. Report to :py:class:`swh.auth.pytest_plugin.KeycloackOpenIDConnectMock` docstring. """ @pytest.fixture def keycloak_open_id_connect(): return KeycloackOpenIDConnectMock( server_url=server_url, realm_name=realm_name, client_id=client_id, auth_success=auth_success, exp=exp, user_groups=user_groups, user_permissions=user_permissions, oidc_profile=oidc_profile, user_info=user_info, raw_realm_public_key=raw_realm_public_key, ) return keycloak_open_id_connect diff --git a/swh/auth/tests/test_keycloak.py b/swh/auth/tests/test_keycloak.py index 2546b5f..98e9c57 100644 --- a/swh/auth/tests/test_keycloak.py +++ b/swh/auth/tests/test_keycloak.py @@ -1,151 +1,154 @@ # Copyright (C) 2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from copy import copy import os from urllib.parse import parse_qs, urlparse from keycloak.exceptions import KeycloakError import pytest import yaml from swh.auth.keycloak import KeycloakOpenIDConnect from swh.auth.tests.sample_data import CLIENT_ID, DECODED_TOKEN, OIDC_PROFILE, USER_INFO from swh.core.config import read def test_keycloak_well_known(keycloak_mock): well_known_result = keycloak_mock.well_known() assert set(well_known_result.keys()) == { "issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint", "end_session_endpoint", "jwks_uri", "token_introspection_endpoint", } def test_keycloak_authorization_url(keycloak_mock): actual_auth_uri = keycloak_mock.authorization_url("http://redirect-uri", foo="bar") expected_auth_url = keycloak_mock.well_known()["authorization_endpoint"] parsed_result = urlparse(actual_auth_uri) assert expected_auth_url.endswith(parsed_result.path) parsed_query = parse_qs(parsed_result.query) assert parsed_query == { "client_id": [CLIENT_ID], "response_type": ["code"], "redirect_uri": ["http://redirect-uri"], "foo": ["bar"], } def test_keycloak_authorization_code_fail(keycloak_mock): "Authorization failure raise error" # Simulate failed authentication with Keycloak keycloak_mock.set_auth_success(False) with pytest.raises(KeycloakError): keycloak_mock.authorization_code("auth-code", "redirect-uri") + with pytest.raises(KeycloakError): + keycloak_mock.login("username", "password") + def test_keycloak_authorization_code(keycloak_mock): actual_response = keycloak_mock.authorization_code("auth-code", "redirect-uri") assert actual_response == OIDC_PROFILE def test_keycloak_refresh_token(keycloak_mock): actual_result = keycloak_mock.refresh_token("refresh-token") assert actual_result == OIDC_PROFILE def test_keycloak_userinfo(keycloak_mock): actual_user_info = keycloak_mock.userinfo("refresh-token") assert actual_user_info == USER_INFO def test_keycloak_logout(keycloak_mock): """Login out does not raise""" keycloak_mock.logout("refresh-token") def test_keycloak_decode_token(keycloak_mock): actual_decoded_data = keycloak_mock.decode_token(OIDC_PROFILE["access_token"]) actual_decoded_data2 = copy(actual_decoded_data) expected_decoded_token = copy(DECODED_TOKEN) for dynamic_valued_key in ["exp", "auth_time"]: actual_decoded_data2.pop(dynamic_valued_key) expected_decoded_token.pop(dynamic_valued_key) assert actual_decoded_data2 == expected_decoded_token def test_keycloak_login(keycloak_mock): actual_response = keycloak_mock.login("username", "password") assert actual_response == OIDC_PROFILE @pytest.fixture def auth_config(): return { "keycloak": { "server_url": "https://auth.swh.org/SWHTest", "realm_name": "SWHTest", "client_id": "client_id", } } @pytest.fixture def auth_config_path(tmp_path, monkeypatch, auth_config): conf_path = os.path.join(tmp_path, "auth.yml") with open(conf_path, "w") as f: f.write(yaml.dump(auth_config)) monkeypatch.setenv("SWH_CONFIG_FILENAME", conf_path) return conf_path def test_auth_KeycloakOpenIDConnect_from_config(auth_config): """Instantiating keycloak client out of configuration dict is possible """ client = KeycloakOpenIDConnect.from_config(**auth_config) assert client.server_url == auth_config["keycloak"]["server_url"] assert client.realm_name == auth_config["keycloak"]["realm_name"] assert client.client_id == auth_config["keycloak"]["client_id"] def test_auth_KeycloakOpenIDConnect_from_configfile(auth_config_path, monkeypatch): """Instantiating keycloak client out of environment variable is possible """ client = KeycloakOpenIDConnect.from_configfile() auth_config = read(auth_config_path) assert client.server_url == auth_config["keycloak"]["server_url"] assert client.realm_name == auth_config["keycloak"]["realm_name"] assert client.client_id == auth_config["keycloak"]["client_id"] def test_auth_KeycloakOpenIDConnect_from_configfile_override( auth_config_path, monkeypatch ): """Instantiating keycloak client out of environment variable is possible And caller can override the configuration at calling """ client = KeycloakOpenIDConnect.from_configfile(client_id="foobar") auth_config = read(auth_config_path) assert client.server_url == auth_config["keycloak"]["server_url"] assert client.realm_name == auth_config["keycloak"]["realm_name"] assert client.client_id == "foobar"