From 449a5a787e04b658777533edb437558d94bef0d3 Mon Sep 17 00:00:00 2001 From: aaschaer Date: Thu, 30 May 2024 13:50:15 -0500 Subject: [PATCH] add AuthorizerFactory and implementations (#985) * add AuthorizerFactory, AccessTokenAuthorizerFactory, RefreshTokenAuthorizerFactory, and ClientCredentialsAuthorizerFactory * Fix changelog PR number Co-authored-by: Stephen Rosen * refactors from review --------- Co-authored-by: Stephen Rosen --- ...430_164920_aaschaer_authorizer_factory.rst | 7 + .../experimental/globus_app/__init__.py | 10 + .../globus_app/authorizer_factory.py | 211 ++++++++++++++++ .../experimental/globus_app/errors.py | 4 + .../globus_app/test_authorizer_factory.py | 233 ++++++++++++++++++ 5 files changed, 465 insertions(+) create mode 100644 changelog.d/20240430_164920_aaschaer_authorizer_factory.rst create mode 100644 src/globus_sdk/experimental/globus_app/authorizer_factory.py create mode 100644 tests/unit/experimental/globus_app/test_authorizer_factory.py diff --git a/changelog.d/20240430_164920_aaschaer_authorizer_factory.rst b/changelog.d/20240430_164920_aaschaer_authorizer_factory.rst new file mode 100644 index 000000000..7cb9a4ac7 --- /dev/null +++ b/changelog.d/20240430_164920_aaschaer_authorizer_factory.rst @@ -0,0 +1,7 @@ +Added +~~~~~ + +- Added ``AuthorizerFactory``, an interface for getting a ``GlobusAuthorizer`` + from a ``ValidatingTokenStorage`` to experimental along with + ``AccessTokenAuthorizerFactory``, ``RefreshTokenAuthorizerFactory``, and + ``ClientCredentialsAuthorizerFactory`` that implement it (:pr:`985`) diff --git a/src/globus_sdk/experimental/globus_app/__init__.py b/src/globus_sdk/experimental/globus_app/__init__.py index e8444415d..c7eb79a62 100644 --- a/src/globus_sdk/experimental/globus_app/__init__.py +++ b/src/globus_sdk/experimental/globus_app/__init__.py @@ -1,5 +1,15 @@ from ._validating_token_storage import ValidatingTokenStorage +from .authorizer_factory import ( + AccessTokenAuthorizerFactory, + AuthorizerFactory, + ClientCredentialsAuthorizerFactory, + RefreshTokenAuthorizerFactory, +) __all__ = [ "ValidatingTokenStorage", + "AuthorizerFactory", + "AccessTokenAuthorizerFactory", + "RefreshTokenAuthorizerFactory", + "ClientCredentialsAuthorizerFactory", ] diff --git a/src/globus_sdk/experimental/globus_app/authorizer_factory.py b/src/globus_sdk/experimental/globus_app/authorizer_factory.py new file mode 100644 index 000000000..535739bf4 --- /dev/null +++ b/src/globus_sdk/experimental/globus_app/authorizer_factory.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import abc +import typing as t + +from globus_sdk import AuthLoginClient, ConfidentialAppAuthClient +from globus_sdk.authorizers import ( + AccessTokenAuthorizer, + ClientCredentialsAuthorizer, + GlobusAuthorizer, + RefreshTokenAuthorizer, +) +from globus_sdk.experimental.tokenstorage import TokenData +from globus_sdk.services.auth import OAuthTokenResponse + +from ._validating_token_storage import ValidatingTokenStorage +from .errors import MissingTokensError + +GA = t.TypeVar("GA", bound=GlobusAuthorizer) + + +class AuthorizerFactory( + t.Generic[GA], + metaclass=abc.ABCMeta, +): + """ + An ``AuthorizerFactory`` is an interface for getting some class of + ``GlobusAuthorizer`` from a ``ValidatingTokenStorage`` that meets the + authorization requirements used to initialize the ``ValidatingTokenStorage``. + + An ``AuthorizerFactory`` keeps a cache of authorizer objects that are + re-used until its ``store_token_response`` method is called. + """ + + def __init__(self, token_storage: ValidatingTokenStorage): + """ + :param token_storage: The ``ValidatingTokenStorage`` used + for defining and validating the set of authorization requirements that + constructed authorizers will meet and accessing underlying token storage + """ + self.token_storage = token_storage + self._authorizer_cache: dict[str, GA] = {} + + def _get_token_data_or_error(self, resource_server: str) -> TokenData: + token_data = self.token_storage.get_token_data(resource_server) + if token_data is None: + raise MissingTokensError(f"No token data for {resource_server}") + + return token_data + + def store_token_response_and_clear_cache( + self, token_res: OAuthTokenResponse + ) -> None: + """ + Store a token response in the underlying ``ValidatingTokenStorage`` + and clear the authorizer cache. + + This should not be called when a ``RenewingAuthorizer`` created by this factory + gets new tokens for itself as there is no need to clear the cache. + + :param token_res: An ``OAuthTokenResponse`` containing token data to be stored + in the underlying ``ValidatingTokenStorage``. + """ + self.token_storage.store_token_response(token_res) + self._authorizer_cache = {} + + def get_authorizer(self, resource_server: str) -> GA: + """ + Either retrieve a cached authorizer for the given resource server or construct + a new one if none is cached. + + Raises ``MissingTokensError`` if the underlying ``TokenStorage`` does not + have the needed tokens to create the authorizer. + + :param resource_server: The resource server the authorizer will produce + authentication for + """ + if resource_server in self._authorizer_cache: + return self._authorizer_cache[resource_server] + + new_authorizer = self._make_authorizer(resource_server) + self._authorizer_cache[resource_server] = new_authorizer + return new_authorizer + + @abc.abstractmethod + def _make_authorizer(self, resource_server: str) -> GA: + """ + Construct the ``GlobusAuthorizer`` class specific to this ``AuthorizerFactory`` + + :param resource_server: The resource server the authorizer will produce + authentication for + """ + + +class AccessTokenAuthorizerFactory(AuthorizerFactory[AccessTokenAuthorizer]): + """ + An ``AuthorizerFactory`` that constructs ``AccessTokenAuthorizer``. + """ + + def _make_authorizer(self, resource_server: str) -> AccessTokenAuthorizer: + """ + Construct an ``AccessTokenAuthorizer`` for the given resource server. + + Raises ``MissingTokensError`` if the underlying ``TokenStorage`` does not + have token data for the given resource server. + + :param resource_server: The resource server the authorizer will produce + authentication for + """ + token_data = self._get_token_data_or_error(resource_server) + return AccessTokenAuthorizer(token_data.access_token) + + +class RefreshTokenAuthorizerFactory(AuthorizerFactory[RefreshTokenAuthorizer]): + """ + An ``AuthorizerFactory`` that constructs ``RefreshTokenAuthorizer``. + """ + + def __init__( + self, + token_storage: ValidatingTokenStorage, + auth_login_client: AuthLoginClient, + ): + """ + :param token_storage: The ``ValidatingTokenStorage`` used + for defining and validating the set of authorization requirements that + constructed authorizers will meet and accessing underlying token storage + :auth_login_client: The ``AuthLoginCLient` used for refreshing tokens with + Globus Auth + """ + self.auth_login_client = auth_login_client + super().__init__(token_storage) + + def _make_authorizer(self, resource_server: str) -> RefreshTokenAuthorizer: + """ + Construct a ``RefreshTokenAuthorizer`` for the given resource server. + + Raises ``MissingTokensError`` if the underlying ``TokenStorage`` does not + have a refresh token for the given resource server. + + :param resource_server: The resource server the authorizer will produce + authentication for + """ + token_data = self._get_token_data_or_error(resource_server) + if token_data.refresh_token is None: + raise MissingTokensError(f"No refresh_token for {resource_server}") + + return RefreshTokenAuthorizer( + refresh_token=token_data.refresh_token, + auth_client=self.auth_login_client, + access_token=token_data.access_token, + expires_at=token_data.expires_at_seconds, + on_refresh=self.token_storage.store_token_response, + ) + + +class ClientCredentialsAuthorizerFactory( + AuthorizerFactory[ClientCredentialsAuthorizer] +): + """ + An ``AuthorizerFactory`` that constructs ``ClientCredentialsAuthorizer``. + """ + + def __init__( + self, + token_storage: ValidatingTokenStorage, + confidential_client: ConfidentialAppAuthClient, + ): + """ + :param token_storage: The ``ValidatingTokenStorage`` used + for defining and validating the set of authorization requirements that + constructed authorizers will meet and accessing underlying token storage + :param confidential_client: The ``ConfidentialAppAuthClient`` that will + get client credentials tokens from Globus Auth to act as itself + """ + self.confidential_client = confidential_client + super().__init__(token_storage) + + def _make_authorizer( + self, + resource_server: str, + ) -> ClientCredentialsAuthorizer: + """ + Construct a ``ClientCredentialsAuthorizer`` for the given resource server. + + Does not require that tokens exist in the token storage but will use them if + present. + + :param resource_server: The resource server the authorizer will produce + authentication for. The ``ValidatingTokenStorage`` used to create the + ``ClientCredentialsAuthorizerFactory`` must have scope requirements defined + for this resource server. + """ + token_data = self.token_storage.get_token_data(resource_server) + access_token = token_data.access_token if token_data else None + expires_at = token_data.expires_at_seconds if token_data else None + + scopes = self.token_storage.scope_requirements.get(resource_server) + if scopes is None: + raise ValueError( + "ValidatingTokenStorage has no scope_requirements for " + f"resource_server {resource_server}" + ) + + return ClientCredentialsAuthorizer( + confidential_client=self.confidential_client, + scopes=scopes, + access_token=access_token, + expires_at=expires_at, + on_refresh=self.token_storage.store_token_response, + ) diff --git a/src/globus_sdk/experimental/globus_app/errors.py b/src/globus_sdk/experimental/globus_app/errors.py index f450404f8..3b120344a 100644 --- a/src/globus_sdk/experimental/globus_app/errors.py +++ b/src/globus_sdk/experimental/globus_app/errors.py @@ -14,6 +14,10 @@ class TokenValidationError(Exception): pass +class MissingTokensError(Exception): + pass + + class IdentityMismatchError(TokenValidationError): def __init__(self, message: str, stored_id: UUIDLike, new_id: UUIDLike): super().__init__(message) diff --git a/tests/unit/experimental/globus_app/test_authorizer_factory.py b/tests/unit/experimental/globus_app/test_authorizer_factory.py new file mode 100644 index 000000000..b50ebf0cc --- /dev/null +++ b/tests/unit/experimental/globus_app/test_authorizer_factory.py @@ -0,0 +1,233 @@ +import time +from unittest import mock + +import pytest + +from globus_sdk.experimental.globus_app import ( + AccessTokenAuthorizerFactory, + ClientCredentialsAuthorizerFactory, + RefreshTokenAuthorizerFactory, +) +from globus_sdk.experimental.globus_app.errors import MissingTokensError +from globus_sdk.experimental.tokenstorage import TokenData + + +def make_mock_token_response(token_number=1): + ret = mock.Mock() + ret.by_resource_server = { + "rs1": { + "resource_server": "rs1", + "scope": "rs1:all", + "access_token": f"rs1_access_token_{token_number}", + "refresh_token": f"rs1_refresh_token_{token_number}", + "expires_at_seconds": int(time.time()) + 3600, + "token_type": "Bearer", + } + } + return ret + + +class MockValidatingTokenStorage: + def __init__(self): + self.token_data = {} + self.scope_requirements = {"rs1": "rs1:all"} + + def get_token_data(self, resource_server): + dict_data = self.token_data.get(resource_server) + if dict_data: + return TokenData.from_dict(dict_data) + else: + return None + + def store_token_response(self, mock_token_response): + self.token_data = mock_token_response.by_resource_server + + +def test_access_token_authorizer_factory(): + initial_response = make_mock_token_response() + mock_token_storage = MockValidatingTokenStorage() + mock_token_storage.store_token_response(initial_response) + factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) + + # cache is initially empty + assert factory._authorizer_cache == {} + + # calling get_authorizer once creates a new authorizer from underlying storage + authorizer = factory.get_authorizer("rs1") + assert authorizer.get_authorization_header() == "Bearer rs1_access_token_1" + + # calling get_authorizer again gets the same cached authorizer + authorizer2 = factory.get_authorizer("rs1") + assert authorizer is authorizer2 + + # calling store_token_response_and_clear_cache then get gets a new authorizer + new_data = make_mock_token_response(token_number=2) + factory.store_token_response_and_clear_cache(new_data) + assert factory._authorizer_cache == {} + authorizer = factory.get_authorizer("rs1") + assert authorizer.get_authorization_header() == "Bearer rs1_access_token_2" + + +def test_access_token_authorizer_factory_no_tokens(): + initial_response = make_mock_token_response() + mock_token_storage = MockValidatingTokenStorage() + mock_token_storage.store_token_response(initial_response) + factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) + + with pytest.raises(MissingTokensError) as exc: + factory.get_authorizer("rs2") + assert str(exc.value) == "No token data for rs2" + + +def test_refresh_token_authorizer_factory(): + initial_response = make_mock_token_response() + mock_token_storage = MockValidatingTokenStorage() + mock_token_storage.store_token_response(initial_response) + + refresh_data = make_mock_token_response(token_number=2) + mock_auth_login_client = mock.Mock() + mock_refresh = mock.Mock() + mock_refresh.return_value = refresh_data + mock_auth_login_client.oauth2_refresh_token = mock_refresh + + factory = RefreshTokenAuthorizerFactory( + token_storage=mock_token_storage, + auth_login_client=mock_auth_login_client, + ) + + # calling get authorizer creates a new authorizer with existing token data + authorizer1 = factory.get_authorizer("rs1") + assert authorizer1.get_authorization_header() == "Bearer rs1_access_token_1" + assert mock_auth_login_client.oauth2_refresh_token.call_count == 0 + + # standard refresh doesn't change the authorizer + authorizer1._get_new_access_token() + authorizer2 = factory.get_authorizer("rs1") + assert authorizer2 is authorizer1 + assert authorizer2.get_authorization_header() == "Bearer rs1_access_token_2" + assert mock_auth_login_client.oauth2_refresh_token.call_count == 1 + + # calling store_token_response_and_clear_cache then get gets a new authorizer + factory.store_token_response_and_clear_cache(initial_response) + authorizer3 = factory.get_authorizer("rs1") + assert authorizer3 is not authorizer1 + assert authorizer3.get_authorization_header() == "Bearer rs1_access_token_1" + + +def test_refresh_token_authorizer_factory_expired_access_token(): + initial_response = make_mock_token_response() + initial_response.by_resource_server["rs1"]["expires_at_seconds"] = int( + time.time() - 3600 + ) + + mock_token_storage = MockValidatingTokenStorage() + mock_token_storage.store_token_response(initial_response) + + refresh_data = make_mock_token_response(token_number=2) + mock_auth_login_client = mock.Mock() + mock_refresh = mock.Mock() + mock_refresh.return_value = refresh_data + mock_auth_login_client.oauth2_refresh_token = mock_refresh + + factory = RefreshTokenAuthorizerFactory( + token_storage=mock_token_storage, + auth_login_client=mock_auth_login_client, + ) + + # calling get_authorizer automatically causes a refresh call to get an access token + authorizer = factory.get_authorizer("rs1") + assert authorizer.get_authorization_header() == "Bearer rs1_access_token_2" + assert mock_refresh.call_count == 1 + + +def test_refresh_token_authorizer_factory_no_refresh_token(): + initial_response = make_mock_token_response() + initial_response.by_resource_server["rs1"]["refresh_token"] = None + + mock_token_storage = MockValidatingTokenStorage() + mock_token_storage.store_token_response(initial_response) + + factory = RefreshTokenAuthorizerFactory( + token_storage=mock_token_storage, + auth_login_client=mock.Mock(), + ) + + with pytest.raises(MissingTokensError) as exc: + factory.get_authorizer("rs1") + assert str(exc.value) == "No refresh_token for rs1" + + +def test_client_credentials_authorizer_factory(): + initial_response = make_mock_token_response() + mock_token_storage = MockValidatingTokenStorage() + mock_token_storage.store_token_response(initial_response) + + client_token_data = make_mock_token_response(token_number=2) + mock_confidential_client = mock.Mock() + mock_client_credentials_tokens = mock.Mock() + mock_client_credentials_tokens.return_value = client_token_data + mock_confidential_client.oauth2_client_credentials_tokens = ( + mock_client_credentials_tokens + ) + + factory = ClientCredentialsAuthorizerFactory( + token_storage=mock_token_storage, + confidential_client=mock_confidential_client, + ) + + # calling get_authorizer once creates a new authorizer using existing tokens + authorizer1 = factory.get_authorizer("rs1") + assert authorizer1.get_authorization_header() == "Bearer rs1_access_token_1" + assert mock_confidential_client.oauth2_client_credentials_tokens.call_count == 0 + + # renewing with existing tokens doesn't change the authorizer + authorizer1._get_new_access_token() + authorizer2 = factory.get_authorizer("rs1") + assert authorizer2 is authorizer1 + assert authorizer2.get_authorization_header() == "Bearer rs1_access_token_2" + assert mock_confidential_client.oauth2_client_credentials_tokens.call_count == 1 + + # calling store_token_response_and_clear_cache then get gets a new authorizer + factory.store_token_response_and_clear_cache(initial_response) + authorizer3 = factory.get_authorizer("rs1") + assert authorizer3 is not authorizer1 + assert authorizer3.get_authorization_header() == "Bearer rs1_access_token_1" + + +def test_client_credentials_authorizer_factory_no_tokens(): + mock_token_storage = MockValidatingTokenStorage() + + client_token_data = make_mock_token_response() + mock_confidential_client = mock.Mock() + mock_client_credentials_tokens = mock.Mock() + mock_client_credentials_tokens.return_value = client_token_data + mock_confidential_client.oauth2_client_credentials_tokens = ( + mock_client_credentials_tokens + ) + + factory = ClientCredentialsAuthorizerFactory( + token_storage=mock_token_storage, + confidential_client=mock_confidential_client, + ) + + # calling get_authorizer once creates a new authorizer automatically making + # a client credentials call to get an access token that is then stored + authorizer = factory.get_authorizer("rs1") + assert authorizer.get_authorization_header() == "Bearer rs1_access_token_1" + assert mock_client_credentials_tokens.call_count == 1 + + +def test_client_credentials_authorizer_factory_no_scopes(): + mock_token_storage = MockValidatingTokenStorage() + mock_confidential_client = mock.Mock() + factory = ClientCredentialsAuthorizerFactory( + token_storage=mock_token_storage, + confidential_client=mock_confidential_client, + ) + + with pytest.raises(ValueError) as exc: + factory.get_authorizer("rs2") + assert ( + str(exc.value) + == "ValidatingTokenStorage has no scope_requirements for resource_server rs2" + )