From 15fe363f364976255dbcd981c41328d70a7d9a5d Mon Sep 17 00:00:00 2001
From: "Antoine R. Dumont (@ardumont)" <antoine.romain.dumont@gmail.com>
Date: Wed, 3 Mar 2021 17:41:29 +0100
Subject: [PATCH] auth: Rework tests logic and add coverage to decode_token
 endpoint

This reworks the tests logic as well to reuse the way web tests are written.

This is a first step to actually use the mock class defined here as fixture for future
modules which will depend on swh-auth (swh-web-client, swh-web and swh-deposit).

Related to T3079
---
 swh/auth/tests/conftest.py    | 160 ++++++++++++++++++++---
 swh/auth/tests/sample_data.py | 232 +++++++---------------------------
 swh/auth/tests/test_auth.py   |  89 ++++++-------
 3 files changed, 230 insertions(+), 251 deletions(-)

diff --git a/swh/auth/tests/conftest.py b/swh/auth/tests/conftest.py
index 2a622cd..151c53f 100644
--- a/swh/auth/tests/conftest.py
+++ b/swh/auth/tests/conftest.py
@@ -1,39 +1,157 @@
-# Copyright (C) 2021 The Software Heritage developers
+# 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 Optional
+from unittest.mock import Mock
+
+from keycloak.exceptions import KeycloakError
 import pytest
 
 from swh.auth import KeycloakOpenIDConnect
 
-from .sample_data import OIDC_PROFILE, REALM, SERVER_URL, USER_INFO, WELL_KNOWN
-
+from .sample_data import (
+    OIDC_PROFILE,
+    RAW_REALM_PUBLIC_KEY,
+    REALM,
+    SERVER_URL,
+    USER_INFO,
+)
 
-@pytest.fixture
-def keycloak_open_id_connect():
-    return KeycloakOpenIDConnect(
-        server_url=SERVER_URL, realm_name=REALM, client_id="client-id",
-    )
 
+class KeycloackOpenIDConnectMock(KeycloakOpenIDConnect):
+    """Mock KeycloakOpenIDConnect class to allow testing
 
-@pytest.fixture
-def mock_keycloak(requests_mock):
-    """Keycloak with most endpoints available.
+    Args:
+        auth_success: boolean flag to simulate authentication success or failure
+        exp: expiration
+        user_groups: user groups configuration (if any)
+        user_permissions: user permissions configuration (if any)
 
     """
-    requests_mock.get(WELL_KNOWN["well-known"], json=WELL_KNOWN)
-    requests_mock.post(WELL_KNOWN["token_endpoint"], json=OIDC_PROFILE)
-    requests_mock.get(WELL_KNOWN["userinfo_endpoint"], json=USER_INFO)
-    requests_mock.post(WELL_KNOWN["end_session_endpoint"], status_code=204)
 
-    return requests_mock
+    def __init__(
+        self,
+        server_url: str,
+        realm_name: str,
+        client_id: str,
+        auth_success: bool = True,
+        exp: Optional[int] = None,
+        user_groups=[],
+        user_permissions=[],
+    ):
+        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)
+
+    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
+            options["verify_exp"] = False
+        decoded = super().decode_token(token, options)
+        # 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
+        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:
+        # 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.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.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
 
-@pytest.fixture
-def mock_keycloak_refused_auth(requests_mock):
-    """Keycloak with token endpoint refusing authentication.
+
+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=[],
+):
+    """Keycloak mock fixture factory
 
     """
-    requests_mock.post(WELL_KNOWN["token_endpoint"], status_code=401)
-    return requests_mock
+
+    @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,
+        )
+
+    return keycloak_open_id_connect
diff --git a/swh/auth/tests/sample_data.py b/swh/auth/tests/sample_data.py
index 77e4602..3e20e14 100644
--- a/swh/auth/tests/sample_data.py
+++ b/swh/auth/tests/sample_data.py
@@ -5,198 +5,40 @@
 
 SERVER_URL = "http://keycloak:8080/keycloak/auth/"
 REALM = "SoftwareHeritage"
+CLIENT_ID = "swh-web"
 
-WELL_KNOWN = {
-    "issuer": f"{SERVER_URL}realms/SoftwareHeritage",
-    "well-known": f"{SERVER_URL}realms/{REALM}/.well-known/openid-configuration",
-    "authorization_endpoint": f"{SERVER_URL}realms/{REALM}/protocol/openid-connect/auth",  # noqa
-    "token_endpoint": f"{SERVER_URL}realms/{REALM}/protocol/openid-connect/token",
-    "introspection_endpoint": f"{SERVER_URL}realms/{REALM}/protocol/openid-connect/token/introspect",  # noqa
-    "userinfo_endpoint": f"{SERVER_URL}realms/{REALM}/protocol/openid-connect/userinfo",
-    "end_session_endpoint": f"{SERVER_URL}realms/{REALM}/protocol/openid-connect/logout",  # noqa
-    "jwks_uri": "{SERVER_URL}realms/{REALM}/protocol/openid-connect/certs",
-    "check_session_iframe": "{SERVER_URL}realms/{REALM}/protocol/openid-connect/login-status-iframe.html",  # noqa
-    "grant_types_supported": [
-        "authorization_code",
-        "implicit",
-        "refresh_token",
-        "password",
-        "client_credentials",
-    ],
-    "response_types_supported": [
-        "code",
-        "none",
-        "id_token",
-        "token",
-        "id_token token",
-        "code id_token",
-        "code token",
-        "code id_token token",
-    ],
-    "subject_types_supported": ["public", "pairwise"],
-    "id_token_signing_alg_values_supported": [
-        "PS384",
-        "ES384",
-        "RS384",
-        "HS256",
-        "HS512",
-        "ES256",
-        "RS256",
-        "HS384",
-        "ES512",
-        "PS256",
-        "PS512",
-        "RS512",
-    ],
-    "id_token_encryption_alg_values_supported": ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"],
-    "id_token_encryption_enc_values_supported": [
-        "A256GCM",
-        "A192GCM",
-        "A128GCM",
-        "A128CBC-HS256",
-        "A192CBC-HS384",
-        "A256CBC-HS512",
-    ],
-    "userinfo_signing_alg_values_supported": [
-        "PS384",
-        "ES384",
-        "RS384",
-        "HS256",
-        "HS512",
-        "ES256",
-        "RS256",
-        "HS384",
-        "ES512",
-        "PS256",
-        "PS512",
-        "RS512",
-        "none",
-    ],
-    "request_object_signing_alg_values_supported": [
-        "PS384",
-        "ES384",
-        "RS384",
-        "HS256",
-        "HS512",
-        "ES256",
-        "RS256",
-        "HS384",
-        "ES512",
-        "PS256",
-        "PS512",
-        "RS512",
-        "none",
-    ],
-    "response_modes_supported": ["query", "fragment", "form_post"],
-    "registration_endpoint": "{SERVER_URL}realms/{REALM}/clients-registrations/openid-connect",  # noqa
-    "token_endpoint_auth_methods_supported": [
-        "private_key_jwt",
-        "client_secret_basic",
-        "client_secret_post",
-        "tls_client_auth",
-        "client_secret_jwt",
-    ],
-    "token_endpoint_auth_signing_alg_values_supported": [
-        "PS384",
-        "ES384",
-        "RS384",
-        "HS256",
-        "HS512",
-        "ES256",
-        "RS256",
-        "HS384",
-        "ES512",
-        "PS256",
-        "PS512",
-        "RS512",
-    ],
-    "claims_supported": [
-        "aud",
-        "sub",
-        "iss",
-        "auth_time",
-        "name",
-        "given_name",
-        "family_name",
-        "preferred_username",
-        "email",
-        "acr",
-    ],
-    "claim_types_supported": ["normal"],
-    "claims_parameter_supported": True,
-    "scopes_supported": [
-        "openid",
-        "microprofile-jwt",
-        "web-origins",
-        "roles",
-        "phone",
-        "address",
-        "email",
-        "profile",
-        "offline_access",
-    ],
-    "request_parameter_supported": True,
-    "request_uri_parameter_supported": True,
-    "require_request_uri_registration": True,
-    "code_challenge_methods_supported": ["plain", "S256"],
-    "tls_client_certificate_bound_access_tokens": True,
-    "revocation_endpoint": "{SERVER_URL}realms/{REALM}/protocol/openid-connect/revoke",
-    "revocation_endpoint_auth_methods_supported": [
-        "private_key_jwt",
-        "client_secret_basic",
-        "client_secret_post",
-        "tls_client_auth",
-        "client_secret_jwt",
-    ],
-    "revocation_endpoint_auth_signing_alg_values_supported": [
-        "PS384",
-        "ES384",
-        "RS384",
-        "HS256",
-        "HS512",
-        "ES256",
-        "RS256",
-        "HS384",
-        "ES512",
-        "PS256",
-        "PS512",
-        "RS512",
-    ],
-    "backchannel_logout_supported": True,
-    "backchannel_logout_session_supported": True,
+# Decoded token (out of the access token)
+DECODED_TOKEN = {
+    "jti": "31fc50b7-bbe5-4f51-91ef-8e3eec51331e",
+    "exp": 1614787019,
+    "nbf": 0,
+    "iat": 1582723101,
+    "iss": "http://localhost:8080/auth/realms/SoftwareHeritage",
+    "aud": [CLIENT_ID, "account"],
+    "sub": "feacd344-b468-4a65-a236-14f61e6b7200",
+    "typ": "Bearer",
+    "azp": CLIENT_ID,
+    "auth_time": 1614786418,
+    "session_state": "d82b90d1-0a94-4e74-ad66-dd95341c7b6d",
+    "acr": "1",
+    "allowed-origins": ["*"],
+    "realm_access": {"roles": ["offline_access", "uma_authorization"]},
+    "resource_access": {
+        "account": {"roles": ["manage-account", "manage-account-links", "view-profile"]}
+    },
+    "scope": "openid email profile",
+    "email_verified": False,
+    "name": "John Doe",
+    "groups": [],
+    "preferred_username": "johndoe",
+    "given_name": "John",
+    "family_name": "Doe",
+    "email": "john.doe@example.com",
 }
 
-
 # Authentication response is an oidc profile dict
 OIDC_PROFILE = {
     "access_token": (
-        # decoded token:
-        # {'acr': '1',
-        #  'allowed-origins': ['*'],
-        #  'aud': ['swh-web', 'account'],
-        #  'auth_time': 1592395601,
-        #  'azp': 'swh-web',
-        #  'email': 'john.doe@example.com',
-        #  'email_verified': False,
-        #  'exp': 1592396202,
-        #  'family_name': 'Doe',
-        #  'given_name': 'John',
-        #  'groups': ['/staff'],
-        #  'iat': 1582723101,
-        #  'iss': 'http://localhost:8080/auth/realms/SoftwareHeritage',
-        #  'jti': '31fc50b7-bbe5-4f51-91ef-8e3eec51331e',
-        #  'name': 'John Doe',
-        #  'nbf': 0,
-        #  'preferred_username': 'johndoe',
-        #  'realm_access': {'roles': ['offline_access', 'uma_authorization']},
-        #  'resource_access': {'account': {'roles': ['manage-account',
-        #                                            'manage-account-links',
-        #                                            'view-profile']}},
-        #  'scope': 'openid email profile',
-        #  'session_state': 'd82b90d1-0a94-4e74-ad66-dd95341c7b6d',
-        #  'sub': 'feacd344-b468-4a65-a236-14f61e6b7200',
-        #  'typ': 'Bearer'
-        #  }
         "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPSnhV"
         "Q0p0TmJQT0NOUGFNNmc3ZU1zY2pqTXhoem9vNGxZaFhsa1c2TWhBIn0."
         "eyJqdGkiOiIzMWZjNTBiNy1iYmU1LTRmNTEtOTFlZi04ZTNlZWM1MTMz"
@@ -271,7 +113,6 @@ OIDC_PROFILE = {
     "token_type": "bearer",
 }
 
-
 USER_INFO = {
     "email": "john.doe@example.com",
     "email_verified": False,
@@ -282,3 +123,20 @@ USER_INFO = {
     "preferred_username": "johndoe",
     "sub": "feacd344-b468-4a65-a236-14f61e6b7200",
 }
+
+RAW_REALM_PUBLIC_KEY = (
+    "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqF4xvGjaI54P6WtJvyGayxP8A93u"
+    "NcA3TH6jitwmyAalj8dN8/NzK9vrdlSA3Ibvp/XQujPSOP7a35YiYFscEJnogTXQpE/FhZrUY"
+    "y21U6ezruVUv4z/ER1cYLb+q5ZI86nXSTNCAbH+lw7rQjlvcJ9KvgHEeA5ALXJ1r55zUmNvuy"
+    "5o6ke1G3fXbNSXwF4qlWAzo1o7Ms8qNrNyOG8FPx24dvm9xMH7/08IPvh9KUqlnP8h6olpxHr"
+    "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_auth.py b/swh/auth/tests/test_auth.py
index 54d56d9..442c916 100644
--- a/swh/auth/tests/test_auth.py
+++ b/swh/auth/tests/test_auth.py
@@ -3,81 +3,84 @@
 # 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 urllib.parse import parse_qs, urlparse
 
-from keycloak.exceptions import KeycloakAuthenticationError, KeycloakConnectionError
+from keycloak.exceptions import KeycloakError
 import pytest
 
-from .sample_data import OIDC_PROFILE, USER_INFO, WELL_KNOWN
+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)
 
-def test_auth_connection_failure(keycloak_open_id_connect):
-    with pytest.raises(KeycloakConnectionError):
-        keycloak_open_id_connect.well_known()
 
-
-def test_auth_well_known(mock_keycloak, keycloak_open_id_connect):
-    well_known_result = keycloak_open_id_connect.well_known()
-    assert well_known_result is not None
-    assert well_known_result == WELL_KNOWN
-
-    assert mock_keycloak.called
+def test_auth_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_auth_authorization_url(mock_keycloak, keycloak_open_id_connect):
-    actual_auth_uri = keycloak_open_id_connect.authorization_url(
-        "http://redirect-uri", foo="bar"
-    )
+def test_auth_authorization_url(keycloak_mock):
+    actual_auth_uri = keycloak_mock.authorization_url("http://redirect-uri", foo="bar")
 
-    expected_auth_url = WELL_KNOWN["authorization_endpoint"]
+    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"],
+        "client_id": [CLIENT_ID],
         "response_type": ["code"],
         "redirect_uri": ["http://redirect-uri"],
         "foo": ["bar"],
     }
 
-    assert mock_keycloak.called
-
 
-def test_auth_authorization_code_fail(
-    mock_keycloak_refused_auth, keycloak_open_id_connect
-):
-    with pytest.raises(KeycloakAuthenticationError):
-        keycloak_open_id_connect.authorization_code("auth-code", "redirect-uri")
+def test_auth_authorization_code_fail(keycloak_mock):
+    "Authorization failure raise error"
+    # Simulate failed authentication with Keycloak
+    keycloak_mock.set_auth_success(False)
 
-    assert mock_keycloak_refused_auth.called
+    with pytest.raises(KeycloakError):
+        keycloak_mock.authorization_code("auth-code", "redirect-uri")
 
 
-def test_auth_authorization_code(mock_keycloak, keycloak_open_id_connect):
-    actual_response = keycloak_open_id_connect.authorization_code(
-        "auth-code", "redirect-uri"
-    )
-
+def test_auth_authorization_code(keycloak_mock):
+    actual_response = keycloak_mock.authorization_code("auth-code", "redirect-uri")
     assert actual_response == OIDC_PROFILE
 
-    assert mock_keycloak.called
 
+def test_auth_refresh_token(keycloak_mock):
+    actual_result = keycloak_mock.refresh_token("refresh-token")
+    assert actual_result == OIDC_PROFILE
 
-def test_auth_refresh_token(mock_keycloak, keycloak_open_id_connect):
-    actual_result = keycloak_open_id_connect.refresh_token("refresh-token")
-    assert actual_result is not None
 
-    assert mock_keycloak.called
+def test_auth_userinfo(keycloak_mock):
+    actual_user_info = keycloak_mock.userinfo("refresh-token")
+    assert actual_user_info == USER_INFO
 
 
-def test_auth_userinfo(mock_keycloak, keycloak_open_id_connect):
-    actual_user_info = keycloak_open_id_connect.userinfo("refresh-token")
-    assert actual_user_info == USER_INFO
+def test_auth_logout(keycloak_mock):
+    """Login out does not raise"""
+    keycloak_mock.logout("refresh-token")
 
-    assert mock_keycloak.called
 
+def test_auth_decode_token(keycloak_mock):
+    actual_decoded_data = keycloak_mock.decode_token(OIDC_PROFILE["access_token"])
 
-def test_auth_logout(mock_keycloak, keycloak_open_id_connect):
-    keycloak_open_id_connect.logout("refresh-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 mock_keycloak.called
+    assert actual_decoded_data2 == expected_decoded_token
-- 
GitLab