From 63b28edb1f40ddcfe16859156ee5c61ad9bc834b Mon Sep 17 00:00:00 2001
From: "Antoine R. Dumont (@ardumont)" <antoine.romain.dumont@gmail.com>
Date: Wed, 3 Mar 2021 18:47:31 +0100
Subject: [PATCH] Expose a pytest plugin for swh.auth.keycloak

This will be required for modules depending on it (swh.web, swh.web.client, swh.deposit)

Related to T3079
---
 MANIFEST.in                                   |  1 +
 conftest.py                                   |  6 ++
 .../{tests/conftest.py => pytest_plugin.py}   | 77 ++++++++++++-------
 swh/auth/tests/sample_data.py                 | 12 +--
 swh/auth/tests/test_keycloak.py               | 19 +++--
 5 files changed, 72 insertions(+), 43 deletions(-)
 create mode 100644 conftest.py
 rename swh/auth/{tests/conftest.py => pytest_plugin.py} (65%)

diff --git a/MANIFEST.in b/MANIFEST.in
index 807e2e9..e1818b6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,3 +3,4 @@ include requirements*.txt
 include version.txt
 include README.md
 recursive-include swh py.typed
+include conftest.py
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..99ecebb
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,6 @@
+# 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
+
+pytest_plugins = ["swh.auth.pytest_plugin"]
diff --git a/swh/auth/tests/conftest.py b/swh/auth/pytest_plugin.py
similarity index 65%
rename from swh/auth/tests/conftest.py
rename to swh/auth/pytest_plugin.py
index f5cd5b9..f585fdf 100644
--- a/swh/auth/tests/conftest.py
+++ b/swh/auth/pytest_plugin.py
@@ -5,31 +5,34 @@
 
 from copy import copy
 from datetime import datetime, timezone
-from typing import Optional
+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 .sample_data import (
-    OIDC_PROFILE,
-    RAW_REALM_PUBLIC_KEY,
-    REALM,
-    SERVER_URL,
-    USER_INFO,
-)
+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
+        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`)
 
     """
 
@@ -40,8 +43,11 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
         client_id: str,
         auth_success: bool = True,
         exp: Optional[int] = None,
-        user_groups=[],
-        user_permissions=[],
+        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
@@ -49,7 +55,7 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
         self.exp = exp
         self.user_groups = user_groups
         self.user_permissions = user_permissions
-        self._keycloak.public_key = lambda: RAW_REALM_PUBLIC_KEY
+        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": (
@@ -81,14 +87,15 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
                 "protocol/openid-connect/certs"
             ),
         }
-        self.set_auth_success(auth_success)
+        self.set_auth_success(auth_success, oidc_profile, user_info)
 
     def decode_token(self, token):
         options = {}
         if self.auth_success:
-            # skip signature expiration check as we use a static oidc_profile
-            # for the tests with expired tokens in it
+            # 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)
         # tweak auth and exp time for tests
         expire_in = decoded["exp"] - decoded["auth_time"]
@@ -99,13 +106,20 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
             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) -> None:
+    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
@@ -114,9 +128,9 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
         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.userinfo.return_value = copy(USER_INFO)
+            self.authorization_code.return_value = copy(oidc_profile)
+            self.refresh_token.return_value = copy(oidc_profile)
+            self.userinfo.return_value = copy(user_info)
         else:
             self.authorization_url = Mock()  # type: ignore
             exception = KeycloakError(
@@ -130,15 +144,19 @@ class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
 
 
 def keycloak_mock_factory(
-    server_url=SERVER_URL,
-    realm_name=REALM,
-    client_id="swh-client-id",
-    auth_success=True,
-    exp=None,
-    user_groups=[],
-    user_permissions=[],
+    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
+    """Keycloak mock fixture factory. Report to
+       :py:class:`swh.auth.pytest_plugin.KeycloackOpenIDConnectMock` docstring.
 
     """
 
@@ -152,6 +170,9 @@ def keycloak_mock_factory(
             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/sample_data.py b/swh/auth/tests/sample_data.py
index 3e20e14..060985f 100644
--- a/swh/auth/tests/sample_data.py
+++ b/swh/auth/tests/sample_data.py
@@ -4,8 +4,8 @@
 # See top-level LICENSE file for more information
 
 SERVER_URL = "http://keycloak:8080/keycloak/auth/"
-REALM = "SoftwareHeritage"
-CLIENT_ID = "swh-web"
+REALM_NAME = "SoftwareHeritage"
+CLIENT_ID = "client-id"
 
 # Decoded token (out of the access token)
 DECODED_TOKEN = {
@@ -132,11 +132,3 @@ RAW_REALM_PUBLIC_KEY = (
     "drX/q4E+Nzj8Tr8p7Z5CimInls40QuOTIhs6C2SwFHUgQgXl9hB9umiZJlwYEpDv0/LO2zYie"
     "Hl5Lv7Iig4FOIXIVCaDGQIDAQAB"
 )
-
-REALM_PUBLIC_KEY = {
-    "realm": REALM,
-    "public_key": RAW_REALM_PUBLIC_KEY,
-    "token-service": f"{SERVER_URL}realms/{REALM}/protocol/openid-connect",
-    "account-service": f"{SERVER_URL}realms/{REALM}/account",
-    "tokens-not-before": 0,
-}
diff --git a/swh/auth/tests/test_keycloak.py b/swh/auth/tests/test_keycloak.py
index 19226f3..15d5caf 100644
--- a/swh/auth/tests/test_keycloak.py
+++ b/swh/auth/tests/test_keycloak.py
@@ -9,11 +9,20 @@ from urllib.parse import parse_qs, urlparse
 from keycloak.exceptions import KeycloakError
 import pytest
 
-from swh.auth.tests.conftest import keycloak_mock_factory
-from swh.auth.tests.sample_data import CLIENT_ID, DECODED_TOKEN, OIDC_PROFILE, USER_INFO
-
-# dataset we have here is bound to swh-web
-keycloak_mock = keycloak_mock_factory(client_id=CLIENT_ID)
+from swh.auth.pytest_plugin import keycloak_mock_factory
+from swh.auth.tests.sample_data import (
+    CLIENT_ID,
+    DECODED_TOKEN,
+    OIDC_PROFILE,
+    REALM_NAME,
+    SERVER_URL,
+    USER_INFO,
+)
+
+# Make keycloak fixture to use for tests below.
+keycloak_mock = keycloak_mock_factory(
+    server_url=SERVER_URL, realm_name=REALM_NAME, client_id=CLIENT_ID,
+)
 
 
 def test_keycloak_well_known(keycloak_mock):
-- 
GitLab