diff --git a/swh/web/auth/backends.py b/swh/web/auth/backends.py --- a/swh/web/auth/backends.py +++ b/swh/web/auth/backends.py @@ -4,6 +4,7 @@ # See top-level LICENSE file for more information from datetime import datetime, timedelta +import hashlib from typing import Any, Dict, Optional from django.core.cache import cache @@ -11,7 +12,7 @@ from django.utils import timezone from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.exceptions import AuthenticationFailed, ValidationError import sentry_sdk @@ -130,7 +131,7 @@ return None try: - auth_type, token = auth_header.split(" ", 1) + auth_type, refresh_token = auth_header.split(" ", 1) except ValueError: raise AuthenticationFailed("Invalid HTTP authorization header format") @@ -139,10 +140,39 @@ (f"Invalid or unsupported HTTP authorization" f" type ({auth_type}).") ) try: - # attempt to decode token - decoded_token = _oidc_client.decode_token(token) + + # compute a cache key from the token that does not exceed + # memcached key size limit + hasher = hashlib.sha1() + hasher.update(refresh_token.encode("ascii")) + cache_key = f"api_token_{hasher.hexdigest()}" + + # check if an access token is cached + access_token = cache.get(cache_key) + + # attempt to decode access token + try: + decoded_token = _oidc_client.decode_token(access_token) + except Exception: + # access token is None or it has expired + decoded_token = None + + if access_token is None or decoded_token is None: + # get a new access token from authentication provider + access_token = _oidc_client.refresh_token(refresh_token)["access_token"] + # decode access token + decoded_token = _oidc_client.decode_token(access_token) + # compute access token expiration + exp = datetime.fromtimestamp(decoded_token["exp"]) + ttl = int(exp.timestamp() - timezone.now().timestamp()) + # save access token in cache while it is valid + cache.set(cache_key, access_token, timeout=max(0, ttl)) + # create Django user user = _oidc_user_from_decoded_token(decoded_token) + except UnicodeEncodeError as e: + sentry_sdk.capture_exception(e) + raise ValidationError("Invalid bearer token") except Exception as e: sentry_sdk.capture_exception(e) raise AuthenticationFailed(str(e)) diff --git a/swh/web/tests/auth/keycloak_mock.py b/swh/web/tests/auth/keycloak_mock.py --- a/swh/web/tests/auth/keycloak_mock.py +++ b/swh/web/tests/auth/keycloak_mock.py @@ -62,16 +62,19 @@ ), } self.authorization_code = Mock() + self.refresh_token = Mock() self.userinfo = Mock() self.logout = Mock() if auth_success: self.authorization_code.return_value = copy(oidc_profile) + self.refresh_token.return_value = copy(oidc_profile) self.userinfo.return_value = copy(userinfo) else: self.authorization_url = Mock() exception = Exception("Authentication failed") 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 diff --git a/swh/web/tests/auth/test_api_auth.py b/swh/web/tests/auth/test_api_auth.py --- a/swh/web/tests/auth/test_api_auth.py +++ b/swh/web/tests/auth/test_api_auth.py @@ -46,10 +46,10 @@ """ url = reverse("api-1-stat-counters") - access_token = sample_data.oidc_profile["access_token"] + refresh_token = sample_data.oidc_profile["refresh_token"] mock_keycloak(mocker) - api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}") response = api_client.get(url) request = response.wsgi_request @@ -68,11 +68,11 @@ def test_drf_oidc_bearer_token_auth_failure(mocker, api_client): url = reverse("api-1-stat-counters") - access_token = sample_data.oidc_profile["access_token"] + refresh_token = sample_data.oidc_profile["refresh_token"] # check for failed authentication but with expected token format mock_keycloak(mocker, auth_success=False) - api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}") response = api_client.get(url) request = response.wsgi_request @@ -81,23 +81,22 @@ assert isinstance(request.user, AnonymousUser) # check for failed authentication when token format is invalid - mock_keycloak(mocker) - api_client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-format") + api_client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-format-ééàà") response = api_client.get(url) request = response.wsgi_request - assert response.status_code == 403 + assert response.status_code == 400 assert isinstance(request.user, AnonymousUser) def test_drf_oidc_auth_invalid_or_missing_authorization_type(api_client): url = reverse("api-1-stat-counters") - access_token = sample_data.oidc_profile["access_token"] + refresh_token = sample_data.oidc_profile["refresh_token"] # missing authorization type - api_client.credentials(HTTP_AUTHORIZATION=f"{access_token}") + api_client.credentials(HTTP_AUTHORIZATION=f"{refresh_token}") response = api_client.get(url) request = response.wsgi_request diff --git a/swh/web/tests/auth/test_backends.py b/swh/web/tests/auth/test_backends.py --- a/swh/web/tests/auth/test_backends.py +++ b/swh/web/tests/auth/test_backends.py @@ -121,10 +121,12 @@ kc_oidc_mock = mock_keycloak(mocker) + refresh_token = sample_data.oidc_profile["refresh_token"] access_token = sample_data.oidc_profile["access_token"] + decoded_token = kc_oidc_mock.decode_token(access_token) - request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {access_token}") + request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {refresh_token}") user, _ = drf_auth_backend.authenticate(request) _check_authenticated_user(user, decoded_token, kc_oidc_mock) @@ -144,16 +146,14 @@ # simulate a failed authentication with a bearer token in expected format mock_keycloak(mocker, auth_success=False) - access_token = sample_data.oidc_profile["access_token"] + refresh_token = sample_data.oidc_profile["refresh_token"] - request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {access_token}") + request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {refresh_token}") with pytest.raises(AuthenticationFailed): drf_auth_backend.authenticate(request) # simulate a failed authentication with an invalid bearer token format - mock_keycloak(mocker) - request = api_request_factory.get( url, HTTP_AUTHORIZATION="Bearer invalid-token-format" ) @@ -171,7 +171,7 @@ url = reverse("api-1-stat-counters") drf_auth_backend = OIDCBearerTokenAuthentication() - access_token = sample_data.oidc_profile["access_token"] + refresh_token = sample_data.oidc_profile["refresh_token"] # Invalid authorization type request = api_request_factory.get(url, HTTP_AUTHORIZATION="Foo token") @@ -180,7 +180,7 @@ drf_auth_backend.authenticate(request) # Missing authorization type - request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"{access_token}") + request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"{refresh_token}") with pytest.raises(AuthenticationFailed): drf_auth_backend.authenticate(request) @@ -196,9 +196,9 @@ mock_keycloak(mocker, user_permissions=[permission]) drf_auth_backend = OIDCBearerTokenAuthentication() - access_token = sample_data.oidc_profile["access_token"] + refresh_token = sample_data.oidc_profile["refresh_token"] url = reverse("api-1-stat-counters") - request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {access_token}") + request = api_request_factory.get(url, HTTP_AUTHORIZATION=f"Bearer {refresh_token}") user, _ = drf_auth_backend.authenticate(request) assert user.has_perm(permission)