diff --git a/PKG-INFO b/PKG-INFO index 076e23c..163ce05 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,28 +1,30 @@ Metadata-Version: 2.1 Name: swh.auth -Version: 0.3.8 +Version: 0.4.0 Summary: Software Heritage Authentication Utilities Home-page: https://forge.softwareheritage.org/source/swh-auth/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest Project-URL: Funding, https://www.softwareheritage.org/donate Project-URL: Source, https://forge.softwareheritage.org/source/swh- Project-URL: Documentation, https://docs.softwareheritage.org/devel/swh-/ -Description: swh-auth - ========== +Description: .. _swh-auth: + + Software Heritage - Authentication + ================================== Authentication library for SWH (keycloak common utilities) Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 3 - Alpha Requires-Python: >=3.7 Description-Content-Type: text/markdown Provides-Extra: django Provides-Extra: testing diff --git a/README.md b/README.md deleted file mode 100644 index e333b45..0000000 --- a/README.md +++ /dev/null @@ -1,5 +0,0 @@ -swh-auth -========== - -Authentication library for SWH (keycloak common utilities) - diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..2ac99e1 --- /dev/null +++ b/README.rst @@ -0,0 +1,7 @@ +.. _swh-auth: + +Software Heritage - Authentication +================================== + +Authentication library for SWH (keycloak common utilities) + diff --git a/docs/README.rst b/docs/README.rst new file mode 100644 index 0000000..2ac99e1 --- /dev/null +++ b/docs/README.rst @@ -0,0 +1,7 @@ +.. _swh-auth: + +Software Heritage - Authentication +================================== + +Authentication library for SWH (keycloak common utilities) + diff --git a/docs/index.rst b/docs/index.rst index b04a56d..7466a62 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,19 +1,13 @@ -.. _swh-py-template: - -Software Heritage - Python module template -========================================== - -Python module template, used as skeleton to create new modules. - +.. include:: README.rst .. toctree:: :maxdepth: 2 :caption: Contents: Indices and tables -================== +------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` diff --git a/mypy.ini b/mypy.ini index 1d827b2..0bfb45b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,18 +1,20 @@ [mypy] namespace_packages = True warn_unused_ignores = True +# support for django magic: https://github.com/typeddjango/django-stubs +plugins = mypy_django_plugin.main, mypy_drf_plugin.main + +[mypy.plugins.django-stubs] +django_settings_module = swh.auth.tests.django.app.apptest.settings # 3rd party libraries without stubs (yet) [mypy-pkg_resources.*] ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-keycloak.*] ignore_missing_imports = True - -[mypy-django.*] -ignore_missing_imports = True diff --git a/pytest.ini b/pytest.ini index 81fe35e..0e4be09 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] norecursedirs = docs .* -DJANGO_SETTINGS_MODULE = swh.auth.tests.app.apptest.settings +DJANGO_SETTINGS_MODULE = swh.auth.tests.django.app.apptest.settings diff --git a/requirements-test.txt b/requirements-test.txt index af0b52a..e3285f3 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,6 @@ +django-stubs +djangorestframework-stubs pytest -requests_mock pytest-django +pytest-mock +requests_mock diff --git a/setup.py b/setup.py index f418e2e..e9ca471 100755 --- a/setup.py +++ b/setup.py @@ -1,75 +1,75 @@ #!/usr/bin/env python3 # Copyright (C) 2019-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from io import open from os import path from setuptools import find_packages, setup here = path.abspath(path.dirname(__file__)) # Get the long description from the README file -with open(path.join(here, "README.md"), encoding="utf-8") as f: +with open(path.join(here, "README.rst"), encoding="utf-8") as f: long_description = f.read() def parse_requirements(*names): requirements = [] for name in names: if name: reqf = "requirements-%s.txt" % name else: reqf = "requirements.txt" if not path.exists(reqf): return requirements with open(reqf) as f: for line in f.readlines(): line = line.strip() if not line or line.startswith("#"): continue requirements.append(line) return requirements setup( name="swh.auth", description="Software Heritage Authentication Utilities", long_description=long_description, long_description_content_type="text/markdown", python_requires=">=3.7", author="Software Heritage developers", author_email="swh-devel@inria.fr", url="https://forge.softwareheritage.org/source/swh-auth/", packages=find_packages(), # packages's modules install_requires=parse_requirements(None, "swh"), tests_require=parse_requirements("test"), setup_requires=["setuptools-scm"], use_scm_version=True, extras_require={ "django": parse_requirements("django"), "testing": parse_requirements("test"), }, include_package_data=True, # entry_points=""" # [swh.cli.subcommands] # =swh..cli # """, classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Development Status :: 3 - Alpha", ], project_urls={ "Bug Reports": "https://forge.softwareheritage.org/maniphest", "Funding": "https://www.softwareheritage.org/donate", "Source": "https://forge.softwareheritage.org/source/swh-", "Documentation": "https://docs.softwareheritage.org/devel/swh-/", }, ) diff --git a/swh.auth.egg-info/PKG-INFO b/swh.auth.egg-info/PKG-INFO index 076e23c..163ce05 100644 --- a/swh.auth.egg-info/PKG-INFO +++ b/swh.auth.egg-info/PKG-INFO @@ -1,28 +1,30 @@ Metadata-Version: 2.1 Name: swh.auth -Version: 0.3.8 +Version: 0.4.0 Summary: Software Heritage Authentication Utilities Home-page: https://forge.softwareheritage.org/source/swh-auth/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest Project-URL: Funding, https://www.softwareheritage.org/donate Project-URL: Source, https://forge.softwareheritage.org/source/swh- Project-URL: Documentation, https://docs.softwareheritage.org/devel/swh-/ -Description: swh-auth - ========== +Description: .. _swh-auth: + + Software Heritage - Authentication + ================================== Authentication library for SWH (keycloak common utilities) Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 3 - Alpha Requires-Python: >=3.7 Description-Content-Type: text/markdown Provides-Extra: django Provides-Extra: testing diff --git a/swh.auth.egg-info/SOURCES.txt b/swh.auth.egg-info/SOURCES.txt index f6307b7..8429db4 100644 --- a/swh.auth.egg-info/SOURCES.txt +++ b/swh.auth.egg-info/SOURCES.txt @@ -1,53 +1,53 @@ .gitignore .pre-commit-config.yaml AUTHORS CODE_OF_CONDUCT.md CONTRIBUTORS LICENSE MANIFEST.in Makefile -README.md +README.rst conftest.py mypy.ini pyproject.toml pytest.ini requirements-django.txt requirements-swh.txt requirements-test.txt requirements.txt setup.cfg setup.py tox.ini docs/.gitignore docs/Makefile +docs/README.rst docs/conf.py docs/index.rst docs/_static/.placeholder docs/_templates/.placeholder swh/__init__.py swh.auth.egg-info/PKG-INFO swh.auth.egg-info/SOURCES.txt swh.auth.egg-info/dependency_links.txt swh.auth.egg-info/requires.txt swh.auth.egg-info/top_level.txt swh/auth/__init__.py swh/auth/cli.py swh/auth/keycloak.py swh/auth/py.typed swh/auth/pytest_plugin.py swh/auth/django/__init__.py swh/auth/django/models.py swh/auth/django/utils.py swh/auth/tests/__init__.py -swh/auth/tests/conftest.py swh/auth/tests/sample_data.py swh/auth/tests/test_keycloak.py -swh/auth/tests/test_models.py -swh/auth/tests/test_utils.py -swh/auth/tests/app/__init__.py -swh/auth/tests/app/manage.py -swh/auth/tests/app/apptest/__init__.py -swh/auth/tests/app/apptest/apps.py -swh/auth/tests/app/apptest/models.py -swh/auth/tests/app/apptest/settings.py -swh/auth/tests/app/apptest/urls.py \ No newline at end of file +swh/auth/tests/django/__init__.py +swh/auth/tests/django/test_models.py +swh/auth/tests/django/test_utils.py +swh/auth/tests/django/app/__init__.py +swh/auth/tests/django/app/manage.py +swh/auth/tests/django/app/apptest/__init__.py +swh/auth/tests/django/app/apptest/apps.py +swh/auth/tests/django/app/apptest/settings.py +swh/auth/tests/django/app/apptest/urls.py \ No newline at end of file diff --git a/swh.auth.egg-info/requires.txt b/swh.auth.egg-info/requires.txt index 6cd9810..8a36ccf 100644 --- a/swh.auth.egg-info/requires.txt +++ b/swh.auth.egg-info/requires.txt @@ -1,10 +1,13 @@ python-keycloak>=0.19.0 swh.core[http]>=0.3 [django] Django<3 [testing] +django-stubs +djangorestframework-stubs pytest -requests_mock pytest-django +pytest-mock +requests_mock diff --git a/swh/auth/django/models.py b/swh/auth/django/models.py index 8ff689f..59a41a7 100644 --- a/swh/auth/django/models.py +++ b/swh/auth/django/models.py @@ -1,86 +1,108 @@ # 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 datetime import datetime -from typing import Optional, Set +from typing import Any, Dict, Optional, Set from django.contrib.auth.models import User class OIDCUser(User): """ Custom User proxy model for remote users storing OpenID Connect related data: profile containing authentication tokens. The model is also not saved to database as all users are already stored in the Keycloak one. """ # OIDC subject identifier sub: str = "" # OIDC tokens and session related data, only relevant when a user # authenticates from a web browser access_token: Optional[str] = None + expires_in: Optional[int] = None expires_at: Optional[datetime] = None id_token: Optional[str] = None refresh_token: Optional[str] = None + refresh_expires_in: Optional[int] = None refresh_expires_at: Optional[datetime] = None scope: Optional[str] = None session_state: Optional[str] = None # User permissions permissions: Set[str] class Meta: # TODO: To redefine in subclass of this class # Forced to empty otherwise, django complains about it # "Model class swh.auth.django.OIDCUser doesn't declare an explicit app_label # and isn't in an application in INSTALLED_APPS" app_label = "" proxy = True auto_created = True # prevent model to be created in database by migrations def save(self, **kwargs): """ Override django.db.models.Model.save to avoid saving the remote users to web application database. """ pass def get_group_permissions(self, obj=None) -> Set[str]: """ Override django.contrib.auth.models.PermissionsMixin.get_group_permissions to get permissions from OIDC """ return self.get_all_permissions(obj) def get_all_permissions(self, obj=None) -> Set[str]: """ Override django.contrib.auth.models.PermissionsMixin.get_all_permissions to get permissions from OIDC """ return self.permissions def has_perm(self, perm, obj=None) -> bool: """ Override django.contrib.auth.models.PermissionsMixin.has_perm to check permission from OIDC """ if self.is_active and self.is_superuser: return True return perm in self.permissions def has_module_perms(self, app_label) -> bool: """ Override django.contrib.auth.models.PermissionsMixin.has_module_perms to check permissions from OIDC. """ if self.is_active and self.is_superuser: return True return any(perm.startswith(app_label) for perm in self.permissions) + + @property + def oidc_profile(self) -> Dict[str, Any]: + """ + Returns OpenID Connect profile associated to the user as a dictionary. + """ + return { + k: getattr(self, k) + for k in ( + "access_token", + "expires_in", + "expires_at", + "id_token", + "refresh_token", + "refresh_expires_in", + "refresh_expires_at", + "scope", + "session_state", + ) + } diff --git a/swh/auth/django/utils.py b/swh/auth/django/utils.py index 93087fb..b59073e 100644 --- a/swh/auth/django/utils.py +++ b/swh/auth/django/utils.py @@ -1,97 +1,129 @@ # 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 datetime import datetime, timedelta from typing import Any, Dict, Optional +from django.conf import settings + from swh.auth.django.models import OIDCUser from swh.auth.keycloak import KeycloakOpenIDConnect def oidc_user_from_decoded_token( decoded_token: Dict[str, Any], client_id: Optional[str] = None ) -> OIDCUser: """Create an OIDCUser out of a decoded token Args: decoded_token: Decoded token Dict client_id: Optional client id of the keycloak client instance used to decode the token. If not provided, the permissions will be empty. Returns: The OIDCUser instance """ # compute an integer user identifier for Django User model # by concatenating all groups of the UUID4 user identifier # generated by Keycloak and converting it from hex to decimal user_id = int("".join(decoded_token["sub"].split("-")), 16) # create a Django user that will not be saved to database user = OIDCUser( id=user_id, username=decoded_token.get("preferred_username", ""), password="", first_name=decoded_token.get("given_name", ""), last_name=decoded_token.get("family_name", ""), email=decoded_token.get("email", ""), ) # set is_staff user property based on groups if "groups" in decoded_token: user.is_staff = "/staff" in decoded_token["groups"] if client_id: # extract user permissions if any resource_access = decoded_token.get("resource_access", {}) client_resource_access = resource_access.get(client_id, {}) permissions = client_resource_access.get("roles", []) else: permissions = [] user.permissions = set(permissions) # add user sub to custom User proxy model user.sub = decoded_token["sub"] return user def oidc_user_from_profile( oidc_client: KeycloakOpenIDConnect, oidc_profile: Dict[str, Any] ) -> OIDCUser: """Initialize an OIDCUser out of an oidc profile dict. Args: oidc_client: KeycloakOpenIDConnect used to discuss with keycloak oidc_profile: OIDC profile retrieved once connected to keycloak Returns: OIDCUser instance parsed out of the token received. """ # decode JWT token decoded_token = oidc_client.decode_token(oidc_profile["access_token"]) # create OIDCUser from decoded token user = oidc_user_from_decoded_token(decoded_token, client_id=oidc_client.client_id) # get authentication init datetime auth_datetime = datetime.fromtimestamp(decoded_token["iat"]) exp_datetime = datetime.fromtimestamp(decoded_token["exp"]) # compute OIDC tokens expiration date oidc_profile["expires_at"] = exp_datetime oidc_profile["refresh_expires_at"] = auth_datetime + timedelta( seconds=oidc_profile["refresh_expires_in"] ) # add OIDC profile data to custom User proxy model for key, val in oidc_profile.items(): if hasattr(user, key): setattr(user, key, val) return user + + +def keycloak_oidc_client() -> KeycloakOpenIDConnect: + """ + Instantiate a KeycloakOpenIDConnect class from the following django settings: + + * KEYCLOAK_SERVER_URL + * KEYCLOAK_REALM_NAME + * KEYCLOAK_CLIENT_ID + + Returns: + An object to ease the interaction with the Keycloak server + + Raises: + ValueError: at least one mandatory django setting is not set + """ + + server_url = getattr(settings, "KEYCLOAK_SERVER_URL", None) + realm_name = getattr(settings, "KEYCLOAK_REALM_NAME", None) + client_id = getattr(settings, "KEYCLOAK_CLIENT_ID", None) + + if server_url is None or realm_name is None or client_id is None: + raise ValueError( + "KEYCLOAK_SERVER_URL, KEYCLOAK_REALM_NAME and KEYCLOAK_CLIENT_ID django " + "settings are mandatory to instantiate KeycloakOpenIDConnect class" + ) + + return KeycloakOpenIDConnect( + server_url=server_url, realm_name=realm_name, client_id=client_id + ) diff --git a/swh/auth/keycloak.py b/swh/auth/keycloak.py index 042d0a8..370ba64 100644 --- a/swh/auth/keycloak.py +++ b/swh/auth/keycloak.py @@ -1,225 +1,238 @@ # 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 import json from typing import Any, Dict, Optional from urllib.parse import urlencode from keycloak import KeycloakOpenID # The next import is required to allow callers to catch on their own term the following # exception from keycloak.exceptions import KeycloakError # noqa from swh.core.config import load_from_envvar class KeycloakOpenIDConnect: """ Wrapper class around python-keycloak to ease the interaction with Keycloak for managing authentication and user permissions with OpenID Connect. """ def __init__( self, server_url: str, realm_name: str, client_id: str, realm_public_key: str = "", ): """ Args: server_url: URL of the Keycloak server realm_name: The realm name client_id: The OpenID Connect client identifier realm_public_key: The realm public key (will be dynamically retrieved if not provided) """ self._keycloak = KeycloakOpenID( server_url=server_url, client_id=client_id, realm_name=realm_name, ) - self.server_url = server_url - self.realm_name = realm_name - self.client_id = client_id self.realm_public_key = realm_public_key + @property + def realm_name(self): + return self._keycloak.realm_name + + @realm_name.setter + def realm_name(self, value): + self._keycloak.realm_name = value + + @property + def client_id(self): + return self._keycloak.client_id + + @client_id.setter + def client_id(self, value): + self._keycloak.client_id = value + def well_known(self) -> Dict[str, Any]: """ Retrieve the OpenID Connect Well-Known URI registry from Keycloak. Returns: A dictionary filled with OpenID Connect URIS. """ return self._keycloak.well_know() def authorization_url(self, redirect_uri: str, **extra_params: str) -> str: """ Get OpenID Connect authorization URL to authenticate users. Args: redirect_uri: URI to redirect to once a user is authenticated extra_params: Extra query parameters to add to the authorization URL """ auth_url = self._keycloak.auth_url(redirect_uri) if extra_params: auth_url += "&%s" % urlencode(extra_params) return auth_url def authorization_code( self, code: str, redirect_uri: str, **extra_params: str ) -> Dict[str, Any]: """ Get OpenID Connect authentication tokens using Authorization Code flow. Raises: KeycloakError in case of authentication failures Args: code: Authorization code provided by Keycloak redirect_uri: URI to redirect to once a user is authenticated (must be the same as the one provided to authorization_url): extra_params: Extra parameters to add in the authorization request payload. """ return self._keycloak.token( grant_type="authorization_code", code=code, redirect_uri=redirect_uri, **extra_params, ) def login( self, username: str, password: str, **extra_params: str ) -> Dict[str, Any]: """ Get OpenID Connect authentication tokens using Direct Access Grant flow. Raises: KeycloakError in case of authentication failures Args: username: an existing username in the realm password: password associated to username extra_params: Extra parameters to add in the authorization request payload. """ return self._keycloak.token( grant_type="password", scope="openid", username=username, password=password, **extra_params, ) def refresh_token(self, refresh_token: str) -> Dict[str, Any]: """ Request a new access token from Keycloak using a refresh token. Args: refresh_token: A refresh token provided by Keycloak Returns: A dictionary filled with tokens info """ return self._keycloak.refresh_token(refresh_token) def decode_token( self, token: str, options: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Try to decode a JWT token. Args: token: A JWT token to decode options: Options for jose.jwt.decode Returns: A dictionary filled with decoded token content """ if not self.realm_public_key: realm_public_key = self._keycloak.public_key() self.realm_public_key = "-----BEGIN PUBLIC KEY-----\n" self.realm_public_key += realm_public_key self.realm_public_key += "\n-----END PUBLIC KEY-----" return self._keycloak.decode_token( token, key=self.realm_public_key, options=options ) def logout(self, refresh_token: str) -> None: """ Logout a user by closing its authenticated session. Args: refresh_token: A refresh token provided by Keycloak """ self._keycloak.logout(refresh_token) def userinfo(self, access_token: str) -> Dict[str, Any]: """ Return user information from its access token. Args: access_token: An access token provided by Keycloak Returns: A dictionary fillled with user information """ return self._keycloak.userinfo(access_token) @classmethod def from_config(cls, **kwargs: Any) -> "KeycloakOpenIDConnect": """Instantiate a KeycloakOpenIDConnect class from a configuration dict. Args: kwargs: configuration dict for the instance, with one keycloak key, whose value is a Dict with the following keys: - server_url: URL of the Keycloak server - realm_name: The realm name - client_id: The OpenID Connect client identifier Returns: the KeycloakOpenIDConnect instance """ cfg = kwargs["keycloak"] return cls( server_url=cfg["server_url"], realm_name=cfg["realm_name"], client_id=cfg["client_id"], ) @classmethod def from_configfile(cls, **kwargs: Any) -> "KeycloakOpenIDConnect": """Instantiate a KeycloakOpenIDConnect class from the configuration loaded from the SWH_CONFIG_FILENAME envvar, with potential extra keyword arguments if their value is not None. Args: kwargs: kwargs passed to instantiation call Returns: the KeycloakOpenIDConnect instance """ config = dict(load_from_envvar()).get("keycloak", {}) config.update({k: v for k, v in kwargs.items() if v is not None}) return cls.from_config(keycloak=config) def keycloak_error_message(keycloak_error: KeycloakError) -> str: """Transform a keycloak exception into an error message. """ msg_dict = json.loads(keycloak_error.error_message.decode()) error_msg = msg_dict["error"] error_desc = msg_dict.get("error_description") if error_desc: error_msg = f"{error_msg}: {error_desc}" return error_msg diff --git a/swh/auth/pytest_plugin.py b/swh/auth/pytest_plugin.py index f956c75..71ebc67 100644 --- a/swh/auth/pytest_plugin.py +++ b/swh/auth/pytest_plugin.py @@ -1,190 +1,209 @@ # 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 import json 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 +from swh.auth.tests.sample_data import ( + CLIENT_ID, + OIDC_PROFILE, + RAW_REALM_PUBLIC_KEY, + REALM_NAME, + SERVER_URL, + 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["iat"] if self.exp is not None: decoded["exp"] = self.exp decoded["iat"] = self.exp - expire_in else: now = int(datetime.now(tz=timezone.utc).timestamp()) decoded["iat"] = now decoded["exp"] = now + 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 error = { "error": "invalid_grant", "error_description": "Invalid user credentials", } error_message = json.dumps(error).encode() exception = KeycloakError(error_message=error_message, 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( +def keycloak_oidc_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(): + def keycloak_oidc(): 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 + return keycloak_oidc + + +# for backward compatibility +# TODO: remove that alias once swh-deposit and swh-web use new function name +keycloak_mock_factory = keycloak_oidc_factory + +# generic keycloak fixture that can be used within tests +# (cf. test_keycloak.py, test_utils.py, django related tests) +# or external modules using that pytest plugin +keycloak_oidc = keycloak_oidc_factory( + server_url=SERVER_URL, realm_name=REALM_NAME, client_id=CLIENT_ID, +) diff --git a/swh/auth/tests/app/apptest/models.py b/swh/auth/tests/app/apptest/models.py deleted file mode 100644 index a0610e3..0000000 --- a/swh/auth/tests/app/apptest/models.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (C) 2021 The Software Heritage developers -# See the AUTHORS file at the top-level directory of this distribution -# License: GNU General Public License version 3, or any later version -# See top-level LICENSE file for more information - -from django.db import models - -from swh.auth.django.models import OIDCUser - - -class AppUser(OIDCUser): - """AppUser class to demonstrate the use of the OIDCUser which adds some attributes. - - """ - - url = models.TextField(null=False) - - class meta: - app_label = "app-label" diff --git a/swh/auth/tests/conftest.py b/swh/auth/tests/conftest.py deleted file mode 100644 index 9172938..0000000 --- a/swh/auth/tests/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -# 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 swh.auth.pytest_plugin import keycloak_mock_factory -from swh.auth.tests.sample_data import CLIENT_ID, REALM_NAME, SERVER_URL - -# keycloak fixture used within tests (cf. test_keycloak.py, test_utils.py) -keycloak_mock = keycloak_mock_factory( - server_url=SERVER_URL, realm_name=REALM_NAME, client_id=CLIENT_ID, -) diff --git a/swh/auth/tests/app/__init__.py b/swh/auth/tests/django/__init__.py similarity index 100% copy from swh/auth/tests/app/__init__.py copy to swh/auth/tests/django/__init__.py diff --git a/swh/auth/tests/app/apptest/__init__.py b/swh/auth/tests/django/app/__init__.py similarity index 100% rename from swh/auth/tests/app/apptest/__init__.py rename to swh/auth/tests/django/app/__init__.py diff --git a/swh/auth/tests/app/__init__.py b/swh/auth/tests/django/app/apptest/__init__.py similarity index 100% rename from swh/auth/tests/app/__init__.py rename to swh/auth/tests/django/app/apptest/__init__.py diff --git a/swh/auth/tests/app/apptest/apps.py b/swh/auth/tests/django/app/apptest/apps.py similarity index 86% rename from swh/auth/tests/app/apptest/apps.py rename to swh/auth/tests/django/app/apptest/apps.py index 9ebdc06..0be5723 100644 --- a/swh/auth/tests/app/apptest/apps.py +++ b/swh/auth/tests/django/app/apptest/apps.py @@ -1,10 +1,10 @@ # Copyright (C) 2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from django.apps import AppConfig class TestApp(AppConfig): - name = "swh.auth.tests.app" + name = "swh.auth.tests.django.app.apptest" diff --git a/swh/auth/tests/app/apptest/settings.py b/swh/auth/tests/django/app/apptest/settings.py similarity index 76% rename from swh/auth/tests/app/apptest/settings.py rename to swh/auth/tests/django/app/apptest/settings.py index 163c790..543d0ac 100644 --- a/swh/auth/tests/app/apptest/settings.py +++ b/swh/auth/tests/django/app/apptest/settings.py @@ -1,7 +1,7 @@ SECRET_KEY = "o+&ayiuk(y^wh4ijz5e=c2$$kjj7g^6r%z+8d*c0lbpfs##k#7" INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", - "swh.auth.tests.app.apptest.apps.TestApp", + "swh.auth.tests.django.app.apptest", ] diff --git a/swh/auth/tests/app/apptest/urls.py b/swh/auth/tests/django/app/apptest/urls.py similarity index 100% rename from swh/auth/tests/app/apptest/urls.py rename to swh/auth/tests/django/app/apptest/urls.py diff --git a/swh/auth/tests/app/manage.py b/swh/auth/tests/django/app/manage.py similarity index 88% rename from swh/auth/tests/app/manage.py rename to swh/auth/tests/django/app/manage.py index 57db14f..8c77d38 100755 --- a/swh/auth/tests/app/manage.py +++ b/swh/auth/tests/django/app/manage.py @@ -1,23 +1,23 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", "swh.auth.tests.app.apptest.settings" + "DJANGO_SETTINGS_MODULE", "swh.auth.tests.django.app.apptest.settings" ) try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() diff --git a/swh/auth/tests/django/test_models.py b/swh/auth/tests/django/test_models.py new file mode 100644 index 0000000..7170ab1 --- /dev/null +++ b/swh/auth/tests/django/test_models.py @@ -0,0 +1,68 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from typing import Set + +import pytest + +from swh.auth.django.models import OIDCUser + +PERMISSIONS: Set[str] = set(["api", "app-label-read"]) +NO_PERMISSION: Set[str] = set() + + +@pytest.fixture +def oidc_user(): + return OIDCUser( + id=666, + username="foo", + password="bar", + first_name="foobar", + last_name="", + email="foo@bar.org", + ) + + +@pytest.fixture +def oidc_user_admin(oidc_user): + oidc_user_admin = oidc_user + oidc_user_admin.is_active = True + oidc_user_admin.is_superuser = True + return oidc_user_admin + + +def test_django_oidc_user(oidc_user): + oidc_user.permissions = PERMISSIONS + + assert oidc_user.get_group_permissions() == PERMISSIONS + assert oidc_user.get_group_permissions(oidc_user) == PERMISSIONS + assert oidc_user.get_all_permissions() == PERMISSIONS + assert oidc_user.get_all_permissions(oidc_user) == PERMISSIONS + + assert "api" in PERMISSIONS + assert oidc_user.has_perm("api") is True + assert oidc_user.has_perm("something") is False + + assert "app-label-read" in PERMISSIONS + assert oidc_user.has_module_perms("app-label") is True + assert oidc_user.has_module_perms("app-something") is False + + +def test_django_oidc_user_admin(oidc_user_admin): + oidc_user_admin.permissions = NO_PERMISSION + + assert oidc_user_admin.get_group_permissions() == NO_PERMISSION + assert oidc_user_admin.get_group_permissions(oidc_user_admin) == NO_PERMISSION + + assert oidc_user_admin.get_all_permissions() == NO_PERMISSION + assert oidc_user_admin.get_all_permissions(oidc_user) == NO_PERMISSION + + assert "foobar" not in PERMISSIONS + assert oidc_user_admin.has_perm("foobar") is True + assert "something" not in PERMISSIONS + assert oidc_user_admin.has_perm("something") is True + + assert oidc_user_admin.has_module_perms("app-label") is True + assert oidc_user_admin.has_module_perms("really-whatever-app") is True diff --git a/swh/auth/tests/django/test_utils.py b/swh/auth/tests/django/test_utils.py new file mode 100644 index 0000000..3853851 --- /dev/null +++ b/swh/auth/tests/django/test_utils.py @@ -0,0 +1,117 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU 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 + +from django.test import override_settings +import pytest + +from swh.auth.django.utils import ( + keycloak_oidc_client, + oidc_user_from_decoded_token, + oidc_user_from_profile, +) +from swh.auth.tests.sample_data import ( + CLIENT_ID, + DECODED_TOKEN, + OIDC_PROFILE, + REALM_NAME, + SERVER_URL, +) + + +def _check_user(user, is_staff=False, permissions=set()): + assert user.id > 0 + assert user.username == DECODED_TOKEN["preferred_username"] + assert user.password == "" + assert user.first_name == DECODED_TOKEN["given_name"] + assert user.last_name == DECODED_TOKEN["family_name"] + assert user.email == DECODED_TOKEN["email"] + assert user.is_staff == is_staff + assert user.permissions == permissions + assert user.sub == DECODED_TOKEN["sub"] + + date_now = datetime.now() + if user.expires_at is not None: + assert isinstance(user.expires_at, datetime) + assert date_now <= user.expires_at + if user.refresh_expires_at is not None: + assert isinstance(user.refresh_expires_at, datetime) + assert date_now <= user.refresh_expires_at + + assert user.oidc_profile == { + k: getattr(user, k) + for k in ( + "access_token", + "expires_in", + "expires_at", + "id_token", + "refresh_token", + "refresh_expires_in", + "refresh_expires_at", + "scope", + "session_state", + ) + } + + +def test_oidc_user_from_decoded_token(): + user = oidc_user_from_decoded_token(DECODED_TOKEN) + _check_user(user) + + +def test_oidc_user_from_decoded_token2(): + decoded_token = copy(DECODED_TOKEN) + decoded_token["groups"] = ["/staff", "api"] + decoded_token["resource_access"] = {CLIENT_ID: {"roles": ["read-api"]}} + + user = oidc_user_from_decoded_token(decoded_token, client_id=CLIENT_ID) + + _check_user(user, is_staff=True, permissions={"read-api"}) + + +@pytest.mark.parametrize( + "key,mapped_key", + [ + ("preferred_username", "username"), + ("given_name", "first_name"), + ("family_name", "last_name"), + ("email", "email"), + ], +) +def test_oidc_user_from_decoded_token_empty_fields_ok(key, mapped_key): + decoded_token = copy(DECODED_TOKEN) + decoded_token.pop(key, None) + + user = oidc_user_from_decoded_token(decoded_token, client_id=CLIENT_ID) + + # Ensure the missing field is mapped to an empty value + assert getattr(user, mapped_key) == "" + + +def test_oidc_user_from_profile(keycloak_oidc): + user = oidc_user_from_profile(keycloak_oidc, OIDC_PROFILE) + _check_user(user) + + +def test_keycloak_oidc_client_missing_django_settings(): + + with pytest.raises(ValueError, match="settings are mandatory"): + keycloak_oidc_client() + + +@override_settings( + KEYCLOAK_SERVER_URL=SERVER_URL, + KEYCLOAK_REALM_NAME=REALM_NAME, + KEYCLOAK_CLIENT_ID=CLIENT_ID, +) +def test_keycloak_oidc_client_parameters_from_django_settings(): + + kc_oidc_client = keycloak_oidc_client() + + assert kc_oidc_client.server_url == SERVER_URL + assert kc_oidc_client.realm_name == REALM_NAME + assert kc_oidc_client.client_id == CLIENT_ID diff --git a/swh/auth/tests/test_keycloak.py b/swh/auth/tests/test_keycloak.py index edd1f58..33b7abe 100644 --- a/swh/auth/tests/test_keycloak.py +++ b/swh/auth/tests/test_keycloak.py @@ -1,175 +1,175 @@ # 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 json import os from urllib.parse import parse_qs, urlparse from keycloak.exceptions import KeycloakError import pytest import yaml from swh.auth.keycloak import KeycloakOpenIDConnect, keycloak_error_message 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() +def test_keycloak_oidc_well_known(keycloak_oidc): + well_known_result = keycloak_oidc.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") +def test_keycloak_oidc_authorization_url(keycloak_oidc): + actual_auth_uri = keycloak_oidc.authorization_url("http://redirect-uri", foo="bar") - expected_auth_url = keycloak_mock.well_known()["authorization_endpoint"] + expected_auth_url = keycloak_oidc.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): +def test_keycloak_oidc_authorization_code_fail(keycloak_oidc): "Authorization failure raise error" # Simulate failed authentication with Keycloak - keycloak_mock.set_auth_success(False) + keycloak_oidc.set_auth_success(False) with pytest.raises(KeycloakError): - keycloak_mock.authorization_code("auth-code", "redirect-uri") + keycloak_oidc.authorization_code("auth-code", "redirect-uri") with pytest.raises(KeycloakError): - keycloak_mock.login("username", "password") + keycloak_oidc.login("username", "password") -def test_keycloak_authorization_code(keycloak_mock): - actual_response = keycloak_mock.authorization_code("auth-code", "redirect-uri") +def test_keycloak_oidc_authorization_code(keycloak_oidc): + actual_response = keycloak_oidc.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") +def test_keycloak_oidc_refresh_token(keycloak_oidc): + actual_result = keycloak_oidc.refresh_token("refresh-token") assert actual_result == OIDC_PROFILE -def test_keycloak_userinfo(keycloak_mock): - actual_user_info = keycloak_mock.userinfo("refresh-token") +def test_keycloak_oidc_userinfo(keycloak_oidc): + actual_user_info = keycloak_oidc.userinfo("refresh-token") assert actual_user_info == USER_INFO -def test_keycloak_logout(keycloak_mock): +def test_keycloak_oidc_logout(keycloak_oidc): """Login out does not raise""" - keycloak_mock.logout("refresh-token") + keycloak_oidc.logout("refresh-token") -def test_keycloak_decode_token(keycloak_mock): - actual_decoded_data = keycloak_mock.decode_token(OIDC_PROFILE["access_token"]) +def test_keycloak_oidc_decode_token(keycloak_oidc): + actual_decoded_data = keycloak_oidc.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", "iat", "auth_time"]: actual_decoded_data2.pop(dynamic_valued_key, None) expected_decoded_token.pop(dynamic_valued_key, None) assert actual_decoded_data2 == expected_decoded_token -def test_keycloak_login(keycloak_mock): - actual_response = keycloak_mock.login("username", "password") +def test_keycloak_oidc_login(keycloak_oidc): + actual_response = keycloak_oidc.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" @pytest.mark.parametrize( "error_dict, expected_result", [ ({"error": "unknown_error"}, "unknown_error"), ( {"error": "invalid_grant", "error_description": "Invalid credentials"}, "invalid_grant: Invalid credentials", ), ], ) def test_auth_keycloak_error_message(error_dict, expected_result): """Conversion from KeycloakError to error message should work with detail or not""" error_message = json.dumps(error_dict).encode() exception = KeycloakError(error_message=error_message, response_code=401) actual_result = keycloak_error_message(exception) assert actual_result == expected_result diff --git a/swh/auth/tests/test_models.py b/swh/auth/tests/test_models.py deleted file mode 100644 index 8f61216..0000000 --- a/swh/auth/tests/test_models.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (C) 2021 The Software Heritage developers -# See the AUTHORS file at the top-level directory of this distribution -# License: GNU General Public License version 3, or any later version -# See top-level LICENSE file for more information - -from typing import Set - -import pytest - -from swh.auth.tests.app.apptest.models import AppUser - -PERMISSIONS: Set[str] = set(["api", "app-label-read"]) -NO_PERMISSION: Set[str] = set() - - -@pytest.fixture -def appuser(): - return AppUser( - id=666, - username="foo", - password="bar", - first_name="foobar", - last_name="", - email="foo@bar.org", - ) - - -@pytest.fixture -def appuser_admin(appuser): - appuser_admin = appuser - appuser_admin.is_active = True - appuser_admin.is_superuser = True - return appuser_admin - - -def test_django_appuser(appuser): - appuser.permissions = PERMISSIONS - - assert appuser.get_group_permissions() == PERMISSIONS - assert appuser.get_group_permissions(appuser) == PERMISSIONS - assert appuser.get_all_permissions() == PERMISSIONS - assert appuser.get_all_permissions(appuser) == PERMISSIONS - - assert "api" in PERMISSIONS - assert appuser.has_perm("api") is True - assert appuser.has_perm("something") is False - - assert "app-label-read" in PERMISSIONS - assert appuser.has_module_perms("app-label") is True - assert appuser.has_module_perms("app-something") is False - - -def test_django_appuser_admin(appuser_admin): - appuser_admin.permissions = NO_PERMISSION - - assert appuser_admin.get_group_permissions() == NO_PERMISSION - assert appuser_admin.get_group_permissions(appuser_admin) == NO_PERMISSION - - assert appuser_admin.get_all_permissions() == NO_PERMISSION - assert appuser_admin.get_all_permissions(appuser) == NO_PERMISSION - - assert "foobar" not in PERMISSIONS - assert appuser_admin.has_perm("foobar") is True - assert "something" not in PERMISSIONS - assert appuser_admin.has_perm("something") is True - - assert appuser_admin.has_module_perms("app-label") is True - assert appuser_admin.has_module_perms("really-whatever-app") is True diff --git a/swh/auth/tests/test_utils.py b/swh/auth/tests/test_utils.py deleted file mode 100644 index 61e345d..0000000 --- a/swh/auth/tests/test_utils.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (C) 2021 The Software Heritage developers -# See the AUTHORS file at the top-level directory of this distribution -# License: GNU 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 - -import pytest - -from swh.auth.django.utils import oidc_user_from_decoded_token, oidc_user_from_profile -from swh.auth.tests.sample_data import CLIENT_ID, DECODED_TOKEN, OIDC_PROFILE - - -def test_oidc_user_from_decoded_token(): - user = oidc_user_from_decoded_token(DECODED_TOKEN) - - assert user.id == 338521271020811424925120118444075479552 - assert user.username == "johndoe" - assert user.password == "" - assert user.first_name == "John" - assert user.last_name == "Doe" - assert user.email == "john.doe@example.com" - assert user.is_staff is False - assert user.permissions == set() - assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" - - -def test_oidc_user_from_decoded_token2(): - decoded_token = copy(DECODED_TOKEN) - decoded_token["groups"] = ["/staff", "api"] - decoded_token["resource_access"] = {CLIENT_ID: {"roles": ["read-api"]}} - - user = oidc_user_from_decoded_token(decoded_token, client_id=CLIENT_ID) - - assert user.id == 338521271020811424925120118444075479552 - assert user.username == "johndoe" - assert user.password == "" - assert user.first_name == "John" - assert user.last_name == "Doe" - assert user.email == "john.doe@example.com" - assert user.is_staff is True - assert user.permissions == {"read-api"} - assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" - - -@pytest.mark.parametrize( - "key,mapped_key", - [ - ("preferred_username", "username"), - ("given_name", "first_name"), - ("family_name", "last_name"), - ("email", "email"), - ], -) -def test_oidc_user_from_decoded_token_empty_fields_ok(key, mapped_key): - decoded_token = copy(DECODED_TOKEN) - decoded_token.pop(key, None) - - user = oidc_user_from_decoded_token(decoded_token, client_id=CLIENT_ID) - - assert user.id == 338521271020811424925120118444075479552 - assert user.password == "" - assert user.is_staff is False - assert user.permissions == set() - assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" - # Ensure the missing field is mapped to an empty value - assert getattr(user, mapped_key) == "" - - -def test_oidc_user_from_profile(keycloak_mock): - date_now = datetime.now() - - user = oidc_user_from_profile(keycloak_mock, OIDC_PROFILE) - - assert user.id == 338521271020811424925120118444075479552 - assert user.username == "johndoe" - assert user.password == "" - assert user.first_name == "John" - assert user.last_name == "Doe" - assert user.email == "john.doe@example.com" - assert user.is_staff is False - assert user.permissions == set() - assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" - - assert isinstance(user.expires_at, datetime) - assert date_now <= user.expires_at - assert isinstance(user.refresh_expires_at, datetime) - assert date_now <= user.refresh_expires_at