From a34e0e7d4d4a996092180c7590f2c46140817f51 Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Fri, 28 Jun 2024 15:22:33 -0500 Subject: [PATCH 1/7] Added overridable auto-login to GlobusApp --- ...40628_152312_derek_auto_run_login_flow.rst | 5 ++ .../experimental/globus_app/__init__.py | 9 ++- .../globus_app/_validating_token_storage.py | 69 ++++++++-------- .../globus_app/authorizer_factory.py | 64 ++++++++++----- .../experimental/globus_app/errors.py | 33 ++++++-- .../experimental/globus_app/globus_app.py | 39 ++++++++- .../globus_app/test_authorizer_factory.py | 29 +++++-- .../globus_app/test_globus_app.py | 80 +++++++++++++++---- 8 files changed, 239 insertions(+), 89 deletions(-) create mode 100644 changelog.d/20240628_152312_derek_auto_run_login_flow.rst diff --git a/changelog.d/20240628_152312_derek_auto_run_login_flow.rst b/changelog.d/20240628_152312_derek_auto_run_login_flow.rst new file mode 100644 index 000000000..60ced1ac0 --- /dev/null +++ b/changelog.d/20240628_152312_derek_auto_run_login_flow.rst @@ -0,0 +1,5 @@ + +Added +~~~~~ + +- Auto-login (overridable in config) GlobusApp login retry on token validation error.(:pr:`NUMBER`) diff --git a/src/globus_sdk/experimental/globus_app/__init__.py b/src/globus_sdk/experimental/globus_app/__init__.py index f270ad5f2..dd6779d0b 100644 --- a/src/globus_sdk/experimental/globus_app/__init__.py +++ b/src/globus_sdk/experimental/globus_app/__init__.py @@ -5,7 +5,13 @@ ClientCredentialsAuthorizerFactory, RefreshTokenAuthorizerFactory, ) -from .globus_app import ClientApp, GlobusApp, GlobusAppConfig, UserApp +from .globus_app import ( + ClientApp, + GlobusApp, + GlobusAppConfig, + TokenValidationErrorHandler, + UserApp, +) __all__ = [ "ValidatingTokenStorage", @@ -17,4 +23,5 @@ "UserApp", "ClientApp", "GlobusAppConfig", + "TokenValidationErrorHandler", ] diff --git a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py index 651f792cd..ee1618474 100644 --- a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py +++ b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py @@ -1,16 +1,14 @@ from __future__ import annotations -import time - from globus_sdk import AuthClient, Scope from globus_sdk.experimental.consents import ConsentForest from globus_sdk.experimental.tokenstorage import TokenData, TokenStorage from ..._types import UUIDLike from .errors import ( - ExpiredTokenError, IdentityMismatchError, MissingIdentityError, + MissingTokensError, UnmetScopeRequirementsError, ) @@ -99,6 +97,14 @@ def _lookup_stored_identity_id(self) -> UUIDLike | None: def store_token_data_by_resource_server( self, token_data_by_resource_server: dict[str, TokenData] ) -> None: + """ + :param token_data_by_resource_server: A dict of TokenData objects indexed by + their resource server + + :raises: :exc:`IdentityValidationError` to signal a problem with the identity + info aspect of the token data. + :raises :exc:`TokenValidationError` to signal a problem with token data. + """ self._validate_token_data_by_resource_server(token_data_by_resource_server) self._token_storage.store_token_data_by_resource_server( @@ -108,31 +114,31 @@ def store_token_data_by_resource_server( def get_token_data_by_resource_server(self) -> dict[str, TokenData]: """ :returns: A dict of TokenData objects indexed by their resource server - :raises: :exc:`TokenValidationError` if any of the token data have expired or - do not meet the attached scope requirements. + :raises: :exc:`TokenValidationError` to signal a problem with stored token data. """ token_data_by_resource_server = ( self._token_storage.get_token_data_by_resource_server() ) for resource_server, token_data in token_data_by_resource_server.items(): - self._validate_token_meets_scope_requirements(resource_server, token_data) + self._validate_token_data_meets_scope_requirements( + resource_server, token_data + ) return token_data_by_resource_server - def get_token_data(self, resource_server: str) -> TokenData | None: + def get_token_data(self, resource_server: str) -> TokenData: """ :param resource_server: A resource server with cached token data. - :returns: The token data for the given resource server, or None if no token data - is present in the attached storage adapter. - :raises: :exc:`TokenValidationError` if the token has expired or does not meet - the attached scope requirements. + :returns: The token data for the given resource server. + :raises: :exc:`TokenValidationError` to signal a problem with stored token data. """ token_data = self._token_storage.get_token_data(resource_server) if token_data is None: - return None + msg = f"No token data for {resource_server}" + raise MissingTokensError(msg, resource_server=resource_server) - self._validate_token_meets_scope_requirements(resource_server, token_data) + self._validate_token_data_meets_scope_requirements(resource_server, token_data) return token_data @@ -152,12 +158,6 @@ def _validate_token_data_by_resource_server( token_data_by_resource_server ) - def _validate_token_data(self, resource_server: str, token_data: TokenData) -> None: - if token_data.expires_at_seconds < time.time(): - raise ExpiredTokenError(token_data.expires_at_seconds) - - self._validate_token_meets_scope_requirements(resource_server, token_data) - def _validate_token_data_by_resource_server_meets_identity_requirements( self, token_data_by_resource_server: dict[str, TokenData] ) -> None: @@ -201,9 +201,11 @@ def _validate_token_data_by_resource_server_meets_scope_requirements( self, token_data_by_resource_server: dict[str, TokenData] ) -> None: for resource_server, token_data in token_data_by_resource_server.items(): - self._validate_token_data(resource_server, token_data) + self._validate_token_data_meets_scope_requirements( + resource_server, token_data + ) - def _validate_token_meets_scope_requirements( + def _validate_token_data_meets_scope_requirements( self, resource_server: str, token_data: TokenData ) -> None: """ @@ -214,6 +216,7 @@ def _validate_token_meets_scope_requirements( :raises: :exc:`UnmetScopeRequirements` if token/consent data does not meet the attached root or dependent scope requirements for the resource server. + :returns: None if all scope requirements are met (or indeterminable). """ required_scopes = self.scope_requirements.get(resource_server) @@ -225,29 +228,23 @@ def _validate_token_meets_scope_requirements( root_scopes = token_data.scope.split(" ") if not all(scope.scope_string in root_scopes for scope in required_scopes): raise UnmetScopeRequirementsError( - "Unmet root scope requirements", + "Unmet scope requirements", scope_requirements=self.scope_requirements, ) - # Short circuit - No dependent scopes or ability to poll consents, don't - # validate them. - if self._consent_client is None or not any( - scope.dependencies for scope in required_scopes - ): + # Short circuit - No dependent scopes; don't validate them. + if not any(scope.dependencies for scope in required_scopes): return # 2. Does the consent forest meet all dependent scope requirements? # 2a. Try with the cached consent forest first. forest = self._cached_consent_forest - if forest is None or not forest.meets_scope_requirements(required_scopes): - # 2b. Poll for fresh consents and try again. - forest = self._poll_and_cache_consents() - if forest is None: - raise UnmetScopeRequirementsError( - "Failed to poll for consents", - scope_requirements=self.scope_requirements, - ) - elif not forest.meets_scope_requirements(required_scopes): + if forest is not None and forest.meets_scope_requirements(required_scopes): + return + + # 2b. Poll for fresh consents and try again. + if (forest := self._poll_and_cache_consents()) is not None: + if not forest.meets_scope_requirements(required_scopes): raise UnmetScopeRequirementsError( "Unmet dependent scope requirements", scope_requirements=self.scope_requirements, diff --git a/src/globus_sdk/experimental/globus_app/authorizer_factory.py b/src/globus_sdk/experimental/globus_app/authorizer_factory.py index 535739bf4..a5be8bc22 100644 --- a/src/globus_sdk/experimental/globus_app/authorizer_factory.py +++ b/src/globus_sdk/experimental/globus_app/authorizer_factory.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import time import typing as t from globus_sdk import AuthLoginClient, ConfidentialAppAuthClient @@ -10,11 +11,10 @@ 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 +from .errors import ExpiredTokenError, MissingTokensError GA = t.TypeVar("GA", bound=GlobusAuthorizer) @@ -41,13 +41,6 @@ def __init__(self, token_storage: ValidatingTokenStorage): 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: @@ -69,11 +62,10 @@ 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 + :raises: :exc:`TokenValidationError` if the underlying ``TokenStorage`` does + not have sufficient valid token data to create a new authorizer. """ if resource_server in self._authorizer_cache: return self._authorizer_cache[resource_server] @@ -97,17 +89,37 @@ class AccessTokenAuthorizerFactory(AuthorizerFactory[AccessTokenAuthorizer]): An ``AuthorizerFactory`` that constructs ``AccessTokenAuthorizer``. """ + def __init__(self, token_storage: ValidatingTokenStorage): + super().__init__(token_storage) + self._cached_authorizer_expiration: dict[str, int] = {} + + def store_token_response_and_clear_cache( + self, token_res: OAuthTokenResponse + ) -> None: + super().store_token_response_and_clear_cache(token_res) + self._cached_authorizer_expiration = {} + + def get_authorizer(self, resource_server: str) -> GA: + if resource_server in self._cached_authorizer_expiration: + if self._cached_authorizer_expiration[resource_server] < time.time(): + del self._cached_authorizer_expiration[resource_server] + del self._authorizer_cache[resource_server] + + return super().get_authorizer(resource_server) + 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 + :raises: :exc:`ExpiredTokenError` if the stored access token for the given + resource server has expired """ - token_data = self._get_token_data_or_error(resource_server) + token_data = self.token_storage.get_token_data(resource_server) + if token_data.expires_at_seconds < time.time(): + raise ExpiredTokenError(token_data.expires_at_seconds) + return AccessTokenAuthorizer(token_data.access_token) @@ -141,9 +153,10 @@ def _make_authorizer(self, resource_server: str) -> RefreshTokenAuthorizer: :param resource_server: The resource server the authorizer will produce authentication for """ - token_data = self._get_token_data_or_error(resource_server) + token_data = self.token_storage.get_token_data(resource_server) if token_data.refresh_token is None: - raise MissingTokensError(f"No refresh_token for {resource_server}") + msg = f"No refresh_token for {resource_server}" + raise MissingTokensError(msg, resource_server=resource_server) return RefreshTokenAuthorizer( refresh_token=token_data.refresh_token, @@ -159,6 +172,10 @@ class ClientCredentialsAuthorizerFactory( ): """ An ``AuthorizerFactory`` that constructs ``ClientCredentialsAuthorizer``. + + ClientCredentialAuthorizers are a special flavor of RenewingAuthorizers which + use the client credentials grant type and a refresh token to keep up-to-date + access tokens for a resource server. """ def __init__( @@ -191,10 +208,6 @@ def _make_authorizer( ``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( @@ -202,6 +215,13 @@ def _make_authorizer( f"resource_server {resource_server}" ) + try: + token_data = self.token_storage.get_token_data(resource_server) + access_token = token_data.access_token + expires_at = token_data.expires_at_seconds + except MissingTokensError: + access_token, expires_at = None, None + return ClientCredentialsAuthorizer( confidential_client=self.confidential_client, scopes=scopes, diff --git a/src/globus_sdk/experimental/globus_app/errors.py b/src/globus_sdk/experimental/globus_app/errors.py index 3b120344a..592bf53e5 100644 --- a/src/globus_sdk/experimental/globus_app/errors.py +++ b/src/globus_sdk/experimental/globus_app/errors.py @@ -6,26 +6,41 @@ from globus_sdk._types import UUIDLike -class MissingIdentityError(ValueError): - pass +class TokenValidationError(Exception): + """A class of errors raised when a token fails validation.""" -class TokenValidationError(Exception): - pass +class IdentityValidationError(TokenValidationError): + """ + A class of errors raised when a token response's identity is indeterminate or + incorrect. + """ + +class MissingIdentityError(IdentityValidationError): + """No identity info contained in a token response.""" -class MissingTokensError(Exception): - pass +class IdentityMismatchError(IdentityValidationError): + """The identity in a token response did not match the expected identity.""" -class IdentityMismatchError(TokenValidationError): def __init__(self, message: str, stored_id: UUIDLike, new_id: UUIDLike): super().__init__(message) self.stored_id = stored_id self.new_id = new_id +class MissingTokensError(TokenValidationError): + """No token stored for a given resource server.""" + + def __init__(self, message: str, resource_server: str): + super().__init__(message) + self.resource_server = resource_server + + class ExpiredTokenError(TokenValidationError): + """The token stored for a given resource server has expired.""" + def __init__(self, expires_at_seconds: int): expiration = datetime.utcfromtimestamp(expires_at_seconds) super().__init__(f"Token expired at {expiration.isoformat()}") @@ -33,6 +48,10 @@ def __init__(self, expires_at_seconds: int): class UnmetScopeRequirementsError(TokenValidationError): + """The token stored for a given resource server is missing required scopes.""" + def __init__(self, message: str, scope_requirements: dict[str, list[Scope]]): super().__init__(message) + # The full set of scope requirements which were evaluated. + # Notably this is not exclusively the unmet scope requirements. self.scope_requirements = scope_requirements diff --git a/src/globus_sdk/experimental/globus_app/globus_app.py b/src/globus_sdk/experimental/globus_app/globus_app.py index 616a56205..2a75df4fc 100644 --- a/src/globus_sdk/experimental/globus_app/globus_app.py +++ b/src/globus_sdk/experimental/globus_app/globus_app.py @@ -3,6 +3,7 @@ import abc import os import sys +import typing as t from dataclasses import dataclass from globus_sdk import ( @@ -32,6 +33,7 @@ ClientCredentialsAuthorizerFactory, RefreshTokenAuthorizerFactory, ) +from .errors import IdentityMismatchError, TokenValidationError def _default_filename(app_name: str) -> str: @@ -55,6 +57,26 @@ def _default_filename(app_name: str) -> str: return os.path.expanduser(f"~/.globus/app/{app_name}/tokens.json") +class TokenValidationErrorHandler(t.Protocol): + def __call__(self, app: GlobusApp, error: TokenValidationError) -> None: ... + + +def resolve_by_login_flow(app: GlobusApp, error: TokenValidationError) -> None: + """ + An error handler for GlobusApp token access errors that will retry the + login flow if the error is a TokenValidationError. + + :param app: The GlobusApp instance which encountered an error. + :param error: The encountered token validation error. + """ + if isinstance(error, IdentityMismatchError): + # An identity mismatch error indicates incorrect use of the app. Not something + # that can be resolved by running a login flow. + raise error + + app.run_login_flow() + + @dataclass(frozen=True) class GlobusAppConfig: """ @@ -69,12 +91,18 @@ class GlobusAppConfig: be used for storing token data. If not passed a ``JSONTokenStorage`` will be used. :param request_refresh_tokens: If True, the ``GlobusApp`` will request refresh - tokens for long lived access. + tokens for long-lived access. + :param token_validation_error_handler: A callable that will be called when a + token validation error is encountered. The default behavior is to retry the + login flow automatically. """ login_flow_manager: LoginFlowManager | type[LoginFlowManager] | None = None token_storage: TokenStorage | None = None request_refresh_tokens: bool = False + token_validation_error_handler: TokenValidationErrorHandler | None = ( + resolve_by_login_flow + ) _DEFAULT_CONFIG = GlobusAppConfig() @@ -230,7 +258,14 @@ def get_authorizer(self, resource_server: str) -> GlobusAuthorizer: :param resource_server: the resource server the Authorizer will provide authorization headers for """ - return self._authorizer_factory.get_authorizer(resource_server) + try: + return self._authorizer_factory.get_authorizer(resource_server) + except TokenValidationError as e: + if self.config.token_validation_error_handler: + # Dispatch to the configured error handler if one is set then retry. + self.config.token_validation_error_handler(self, e) + return self.get_authorizer(resource_server) + raise e def add_scope_requirements( self, scope_requirements: dict[str, list[Scope]] diff --git a/tests/unit/experimental/globus_app/test_authorizer_factory.py b/tests/unit/experimental/globus_app/test_authorizer_factory.py index b50ebf0cc..1fede0ec7 100644 --- a/tests/unit/experimental/globus_app/test_authorizer_factory.py +++ b/tests/unit/experimental/globus_app/test_authorizer_factory.py @@ -8,7 +8,10 @@ ClientCredentialsAuthorizerFactory, RefreshTokenAuthorizerFactory, ) -from globus_sdk.experimental.globus_app.errors import MissingTokensError +from globus_sdk.experimental.globus_app.errors import ( + ExpiredTokenError, + MissingTokensError, +) from globus_sdk.experimental.tokenstorage import TokenData @@ -33,11 +36,11 @@ def __init__(self): 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 + if resource_server not in self.token_data: + msg = f"No token data for {resource_server}" + raise MissingTokensError(msg, resource_server=resource_server) + + return TokenData.from_dict(self.token_data[resource_server]) def store_token_response(self, mock_token_response): self.token_data = mock_token_response.by_resource_server @@ -79,6 +82,20 @@ def test_access_token_authorizer_factory_no_tokens(): assert str(exc.value) == "No token data for rs2" +def test_access_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) + factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) + + with pytest.raises(ExpiredTokenError): + factory.get_authorizer("rs1") + + def test_refresh_token_authorizer_factory(): initial_response = make_mock_token_response() mock_token_storage = MockValidatingTokenStorage() diff --git a/tests/unit/experimental/globus_app/test_globus_app.py b/tests/unit/experimental/globus_app/test_globus_app.py index 34a50c430..b1bd97099 100644 --- a/tests/unit/experimental/globus_app/test_globus_app.py +++ b/tests/unit/experimental/globus_app/test_globus_app.py @@ -13,6 +13,9 @@ ) from globus_sdk._testing import load_response from globus_sdk.exc import GlobusSDKUsageError +from globus_sdk.experimental.auth_requirements_error import ( + GlobusAuthorizationParameters, +) from globus_sdk.experimental.globus_app import ( AccessTokenAuthorizerFactory, ClientApp, @@ -21,7 +24,10 @@ RefreshTokenAuthorizerFactory, UserApp, ) -from globus_sdk.experimental.login_flow_manager import CommandLineLoginFlowManager +from globus_sdk.experimental.login_flow_manager import ( + CommandLineLoginFlowManager, + LoginFlowManager, +) from globus_sdk.experimental.tokenstorage import ( JSONTokenStorage, MemoryTokenStorage, @@ -30,17 +36,19 @@ from globus_sdk.scopes import Scope from globus_sdk.services.auth import OAuthTokenResponse -_mock_token_data_by_rs = { - "auth.globus.org": TokenData( - resource_server="auth.globus.org", - identity_id="mock_identity_id", - scope="openid", - access_token="mock_access_token", - refresh_token="mock_refresh_token", - expires_at_seconds=int(time.time() + 300), - token_type="Bearer", - ) -} + +def _mock_token_data_by_rs(): + return { + "auth.globus.org": TokenData( + resource_server="auth.globus.org", + identity_id="mock_identity_id", + scope="openid", + access_token="mock_access_token", + refresh_token="mock_refresh_token", + expires_at_seconds=int(time.time() + 300), + token_type="Bearer", + ) + } def _mock_input(s): @@ -194,7 +202,7 @@ def test_add_scope_requirements_and_auth_params_with_required_scopes(): def test_user_app_get_authorizer(): client_id = "mock_client_id" memory_storage = MemoryTokenStorage() - memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs) + memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage) user_app = UserApp("test-app", client_id=client_id, config=config) @@ -206,7 +214,7 @@ def test_user_app_get_authorizer(): def test_user_app_get_authorizer_refresh(): client_id = "mock_client_id" memory_storage = MemoryTokenStorage() - memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs) + memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage, request_refresh_tokens=True) user_app = UserApp("test-app", client_id=client_id, config=config) @@ -215,11 +223,53 @@ def test_user_app_get_authorizer_refresh(): assert authorizer.refresh_token == "mock_refresh_token" +class CustomExitException(Exception): + pass + + +class RaisingLoginFlowManagerCounter(LoginFlowManager): + """ + A login flow manager which increments a public counter and raises an exception on + each login attempt. + """ + + def __init__(self): + super().__init__(None) + self.counter = 0 + + def run_login_flow( + self, auth_parameters: GlobusAuthorizationParameters + ) -> OAuthTokenResponse: + self.counter += 1 + raise CustomExitException("mock login attempt") + + +def test_user_app_expired_authorizer_triggers_login(): + # Set up token data with an expired access token and no refresh token + client_id = "mock_client_id" + memory_storage = MemoryTokenStorage() + token_data = _mock_token_data_by_rs() + token_data["auth.globus.org"].expires_at_seconds = int(time.time() - 3600) + token_data["auth.globus.org"].refresh_token = None + memory_storage.store_token_data_by_resource_server(token_data) + + login_flow_manager = RaisingLoginFlowManagerCounter() + config = GlobusAppConfig( + token_storage=memory_storage, login_flow_manager=login_flow_manager + ) + user_app = UserApp("test-app", client_id=client_id, config=config) + + with pytest.raises(CustomExitException): + user_app.get_authorizer("auth.globus.org") + + assert login_flow_manager.counter == 1 + + def test_client_app_get_authorizer(): client_id = "mock_client_id" client_secret = "mock_client_secret" memory_storage = MemoryTokenStorage() - memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs) + memory_storage.store_token_data_by_resource_server(_mock_token_data_by_rs()) config = GlobusAppConfig(token_storage=memory_storage) client_app = ClientApp( "test-app", client_id=client_id, client_secret=client_secret, config=config From 31791c1d5d04b7434e7411e7f942be3d690242e9 Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Fri, 28 Jun 2024 15:26:41 -0500 Subject: [PATCH 2/7] minor typing fix --- src/globus_sdk/experimental/globus_app/authorizer_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/globus_sdk/experimental/globus_app/authorizer_factory.py b/src/globus_sdk/experimental/globus_app/authorizer_factory.py index a5be8bc22..22054bb98 100644 --- a/src/globus_sdk/experimental/globus_app/authorizer_factory.py +++ b/src/globus_sdk/experimental/globus_app/authorizer_factory.py @@ -99,7 +99,7 @@ def store_token_response_and_clear_cache( super().store_token_response_and_clear_cache(token_res) self._cached_authorizer_expiration = {} - def get_authorizer(self, resource_server: str) -> GA: + def get_authorizer(self, resource_server: str) -> AccessTokenAuthorizer: if resource_server in self._cached_authorizer_expiration: if self._cached_authorizer_expiration[resource_server] < time.time(): del self._cached_authorizer_expiration[resource_server] From 583352925032cefa2fb69b318cfafae9a246cf5c Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Sun, 30 Jun 2024 16:48:06 -0400 Subject: [PATCH 3/7] Describe errors raised in authorizer factories and ValidatingTokenAdapater more concretely --- .../globus_app/_validating_token_storage.py | 55 ++++++++----------- .../globus_app/authorizer_factory.py | 30 ++++++++-- .../experimental/globus_app/errors.py | 4 +- .../globus_app/test_authorizer_factory.py | 8 +-- .../test_validating_token_storage.py | 6 +- 5 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py index ee1618474..43fc3be95 100644 --- a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py +++ b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py @@ -8,7 +8,7 @@ from .errors import ( IdentityMismatchError, MissingIdentityError, - MissingTokensError, + MissingTokenError, UnmetScopeRequirementsError, ) @@ -101,12 +101,21 @@ def store_token_data_by_resource_server( :param token_data_by_resource_server: A dict of TokenData objects indexed by their resource server - :raises: :exc:`IdentityValidationError` to signal a problem with the identity - info aspect of the token data. - :raises :exc:`TokenValidationError` to signal a problem with token data. + :raises: :exc:`MissingIdentityError` if the token data does not contain + identity information. + :raises: :exc:`IdentityMismatchError` if the identity info in the token data + does not match the stored identity info. + :raises: :exc:`UnmetScopeRequirementsError` if the token data does not meet the + attached scope requirements. """ + self._validate_token_data_by_resource_server_meets_identity_requirements( + token_data_by_resource_server + ) + for resource_server, token_data in token_data_by_resource_server.items(): + self._validate_token_data_meets_scope_requirements( + resource_server, token_data + ) - self._validate_token_data_by_resource_server(token_data_by_resource_server) self._token_storage.store_token_data_by_resource_server( token_data_by_resource_server ) @@ -114,29 +123,31 @@ def store_token_data_by_resource_server( def get_token_data_by_resource_server(self) -> dict[str, TokenData]: """ :returns: A dict of TokenData objects indexed by their resource server - :raises: :exc:`TokenValidationError` to signal a problem with stored token data. + :raises: :exc:`UnmetScopeRequirementsError` if any token data does not meet the + attached scope requirements. """ - token_data_by_resource_server = ( - self._token_storage.get_token_data_by_resource_server() - ) + by_resource_server = self._token_storage.get_token_data_by_resource_server() - for resource_server, token_data in token_data_by_resource_server.items(): + for resource_server, token_data in by_resource_server.items(): self._validate_token_data_meets_scope_requirements( resource_server, token_data ) - return token_data_by_resource_server + return by_resource_server def get_token_data(self, resource_server: str) -> TokenData: """ :param resource_server: A resource server with cached token data. :returns: The token data for the given resource server. - :raises: :exc:`TokenValidationError` to signal a problem with stored token data. + :raises: :exc:`MissingTokenError` if the underlying ``TokenStorage`` does not + have any token data for the given resource server. + :raises: :exc:`UnmetScopeRequirementsError` if the stored token data does not + meet the scope requirements for the given resource server. """ token_data = self._token_storage.get_token_data(resource_server) if token_data is None: msg = f"No token data for {resource_server}" - raise MissingTokensError(msg, resource_server=resource_server) + raise MissingTokenError(msg, resource_server=resource_server) self._validate_token_data_meets_scope_requirements(resource_server, token_data) @@ -148,16 +159,6 @@ def remove_token_data(self, resource_server: str) -> bool: """ return self._token_storage.remove_token_data(resource_server) - def _validate_token_data_by_resource_server( - self, token_data_by_resource_server: dict[str, TokenData] - ) -> None: - self._validate_token_data_by_resource_server_meets_identity_requirements( - token_data_by_resource_server - ) - self._validate_token_data_by_resource_server_meets_scope_requirements( - token_data_by_resource_server - ) - def _validate_token_data_by_resource_server_meets_identity_requirements( self, token_data_by_resource_server: dict[str, TokenData] ) -> None: @@ -197,14 +198,6 @@ def _validate_token_data_by_resource_server_meets_identity_requirements( new_id=token_data_identity_id, ) - def _validate_token_data_by_resource_server_meets_scope_requirements( - self, token_data_by_resource_server: dict[str, TokenData] - ) -> None: - for resource_server, token_data in token_data_by_resource_server.items(): - self._validate_token_data_meets_scope_requirements( - resource_server, token_data - ) - def _validate_token_data_meets_scope_requirements( self, resource_server: str, token_data: TokenData ) -> None: diff --git a/src/globus_sdk/experimental/globus_app/authorizer_factory.py b/src/globus_sdk/experimental/globus_app/authorizer_factory.py index 22054bb98..a684bbd1c 100644 --- a/src/globus_sdk/experimental/globus_app/authorizer_factory.py +++ b/src/globus_sdk/experimental/globus_app/authorizer_factory.py @@ -14,7 +14,7 @@ from globus_sdk.services.auth import OAuthTokenResponse from ._validating_token_storage import ValidatingTokenStorage -from .errors import ExpiredTokenError, MissingTokensError +from .errors import ExpiredTokenError, MissingTokenError GA = t.TypeVar("GA", bound=GlobusAuthorizer) @@ -64,8 +64,12 @@ def get_authorizer(self, resource_server: str) -> GA: :param resource_server: The resource server the authorizer will produce authentication for - :raises: :exc:`TokenValidationError` if the underlying ``TokenStorage`` does - not have sufficient valid token data to create a new authorizer. + + :raises: :exc:`MissingTokenError` if the underlying ``TokenStorage`` does not + have any token data for the given resource server. + :raises: :exc:`UnmetScopeRequirementsError` if the stored token data does not + meet the scope requirements for the given resource server. + :returns: A ``GlobusAuthorizer`` for the given resource server """ if resource_server in self._authorizer_cache: return self._authorizer_cache[resource_server] @@ -100,6 +104,22 @@ def store_token_response_and_clear_cache( self._cached_authorizer_expiration = {} def get_authorizer(self, resource_server: str) -> AccessTokenAuthorizer: + """ + Either retrieve a cached authorizer for the given resource server or construct + a new one if none is cached. + + :param resource_server: The resource server the authorizer will produce + authentication for + + :raises: :exc:`MissingTokenError` if the underlying ``TokenStorage`` does not + have any token data for the given resource server. + :raises: :exc:`UnmetScopeRequirementsError` if the stored token data does not + meet the scope requirements for the given resource server. + :raises: :exc:`ExpiredTokenError` if the stored access token for the given + resource server has expired. + :returns: An ``AccessTokenAuthorizer`` for the given resource server + """ + if resource_server in self._cached_authorizer_expiration: if self._cached_authorizer_expiration[resource_server] < time.time(): del self._cached_authorizer_expiration[resource_server] @@ -156,7 +176,7 @@ def _make_authorizer(self, resource_server: str) -> RefreshTokenAuthorizer: token_data = self.token_storage.get_token_data(resource_server) if token_data.refresh_token is None: msg = f"No refresh_token for {resource_server}" - raise MissingTokensError(msg, resource_server=resource_server) + raise MissingTokenError(msg, resource_server=resource_server) return RefreshTokenAuthorizer( refresh_token=token_data.refresh_token, @@ -219,7 +239,7 @@ def _make_authorizer( token_data = self.token_storage.get_token_data(resource_server) access_token = token_data.access_token expires_at = token_data.expires_at_seconds - except MissingTokensError: + except MissingTokenError: access_token, expires_at = None, None return ClientCredentialsAuthorizer( diff --git a/src/globus_sdk/experimental/globus_app/errors.py b/src/globus_sdk/experimental/globus_app/errors.py index 592bf53e5..19f913aea 100644 --- a/src/globus_sdk/experimental/globus_app/errors.py +++ b/src/globus_sdk/experimental/globus_app/errors.py @@ -30,7 +30,7 @@ def __init__(self, message: str, stored_id: UUIDLike, new_id: UUIDLike): self.new_id = new_id -class MissingTokensError(TokenValidationError): +class MissingTokenError(TokenValidationError): """No token stored for a given resource server.""" def __init__(self, message: str, resource_server: str): @@ -42,7 +42,7 @@ class ExpiredTokenError(TokenValidationError): """The token stored for a given resource server has expired.""" def __init__(self, expires_at_seconds: int): - expiration = datetime.utcfromtimestamp(expires_at_seconds) + expiration = datetime.fromtimestamp(expires_at_seconds) super().__init__(f"Token expired at {expiration.isoformat()}") self.expiration = expiration diff --git a/tests/unit/experimental/globus_app/test_authorizer_factory.py b/tests/unit/experimental/globus_app/test_authorizer_factory.py index 1fede0ec7..956b310f5 100644 --- a/tests/unit/experimental/globus_app/test_authorizer_factory.py +++ b/tests/unit/experimental/globus_app/test_authorizer_factory.py @@ -10,7 +10,7 @@ ) from globus_sdk.experimental.globus_app.errors import ( ExpiredTokenError, - MissingTokensError, + MissingTokenError, ) from globus_sdk.experimental.tokenstorage import TokenData @@ -38,7 +38,7 @@ def __init__(self): def get_token_data(self, resource_server): if resource_server not in self.token_data: msg = f"No token data for {resource_server}" - raise MissingTokensError(msg, resource_server=resource_server) + raise MissingTokenError(msg, resource_server=resource_server) return TokenData.from_dict(self.token_data[resource_server]) @@ -77,7 +77,7 @@ def test_access_token_authorizer_factory_no_tokens(): mock_token_storage.store_token_response(initial_response) factory = AccessTokenAuthorizerFactory(token_storage=mock_token_storage) - with pytest.raises(MissingTokensError) as exc: + with pytest.raises(MissingTokenError) as exc: factory.get_authorizer("rs2") assert str(exc.value) == "No token data for rs2" @@ -169,7 +169,7 @@ def test_refresh_token_authorizer_factory_no_refresh_token(): auth_login_client=mock.Mock(), ) - with pytest.raises(MissingTokensError) as exc: + with pytest.raises(MissingTokenError) as exc: factory.get_authorizer("rs1") assert str(exc.value) == "No refresh_token for rs1" diff --git a/tests/unit/experimental/globus_app/test_validating_token_storage.py b/tests/unit/experimental/globus_app/test_validating_token_storage.py index 9c62704f4..54867b3a5 100644 --- a/tests/unit/experimental/globus_app/test_validating_token_storage.py +++ b/tests/unit/experimental/globus_app/test_validating_token_storage.py @@ -14,6 +14,7 @@ from globus_sdk.experimental.globus_app.errors import ( IdentityMismatchError, MissingIdentityError, + MissingTokenError, UnmetScopeRequirementsError, ) from globus_sdk.experimental.tokenstorage import MemoryTokenStorage @@ -115,10 +116,11 @@ def test_validating_token_storage_loads_identity_info_from_storage( assert new_adapter.identity_id == identity_id -def test_validating_token_storage_returns_none_when_no_token_data(): +def test_validating_token_storage_raises_error_when_no_token_data(): adapter = ValidatingTokenStorage(MemoryTokenStorage(), {}) - assert adapter.get_token_data("rs1") is None + with pytest.raises(MissingTokenError): + adapter.get_token_data("rs1") @pytest.fixture From 1a18e5f856fe3898aee412d79a1c56f948f8df27 Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Sun, 30 Jun 2024 16:52:06 -0400 Subject: [PATCH 4/7] Revert usage of assignment expression --- .../experimental/globus_app/_validating_token_storage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py index 43fc3be95..ca520f99f 100644 --- a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py +++ b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py @@ -236,7 +236,8 @@ def _validate_token_data_meets_scope_requirements( return # 2b. Poll for fresh consents and try again. - if (forest := self._poll_and_cache_consents()) is not None: + forest = self._poll_and_cache_consents() + if forest is not None: if not forest.meets_scope_requirements(required_scopes): raise UnmetScopeRequirementsError( "Unmet dependent scope requirements", From 62a02ac64911d78e603b14f7d0c91922bbf29083 Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Sun, 30 Jun 2024 16:56:43 -0400 Subject: [PATCH 5/7] Fix incosistent docstring --- src/globus_sdk/experimental/globus_app/authorizer_factory.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/globus_sdk/experimental/globus_app/authorizer_factory.py b/src/globus_sdk/experimental/globus_app/authorizer_factory.py index a684bbd1c..d7fafe79c 100644 --- a/src/globus_sdk/experimental/globus_app/authorizer_factory.py +++ b/src/globus_sdk/experimental/globus_app/authorizer_factory.py @@ -167,11 +167,10 @@ 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 + :raises: :exc:`MissingTokenError` if the stored token data for the given + resource server does not have a refresh token """ token_data = self.token_storage.get_token_data(resource_server) if token_data.refresh_token is None: From 045f8a4cc66248addc5c15d5aac327561f0c9ce9 Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Sun, 30 Jun 2024 17:03:50 -0400 Subject: [PATCH 6/7] Rebase from main --- tests/unit/test_base_client.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index 2f49b7101..40b31960b 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -8,8 +8,11 @@ import globus_sdk from globus_sdk._testing import RegisteredResponse, get_last_request from globus_sdk.authorizers import NullAuthorizer -from globus_sdk.experimental.globus_app import UserApp -from globus_sdk.experimental.globus_app.errors import MissingTokensError +from globus_sdk.experimental.globus_app import GlobusApp, GlobusAppConfig, UserApp +from globus_sdk.experimental.globus_app.errors import ( + MissingTokenError, + TokenValidationError, +) from globus_sdk.scopes import Scope, TransferScopes @@ -211,7 +214,12 @@ def test_base_path_matching_prefix( def test_app_integration(base_client_class): - app = UserApp("SDK Test", client_id="client_id") + def _reraise_token_error(_: GlobusApp, error: TokenValidationError): + raise error + + config = GlobusAppConfig(token_validation_error_handler=_reraise_token_error) + app = UserApp("SDK Test", client_id="client_id", config=config) + c = base_client_class(app=app) # confirm app_name set @@ -226,7 +234,7 @@ def test_app_integration(base_client_class): RegisteredResponse( service="transfer", path="foo", method="get", json={"x": "y"} ).add() - with pytest.raises(MissingTokensError) as ex: + with pytest.raises(MissingTokenError) as ex: c.get("foo") assert str(ex.value) == "No token data for transfer.api.globus.org" From b5e6c697f6ab79282a2807bd3eb807df72eeb62f Mon Sep 17 00:00:00 2001 From: Derek Schlabach Date: Sun, 30 Jun 2024 17:06:50 -0400 Subject: [PATCH 7/7] Import Protocol from TypingExtentions in older python versions --- src/globus_sdk/experimental/globus_app/globus_app.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/globus_sdk/experimental/globus_app/globus_app.py b/src/globus_sdk/experimental/globus_app/globus_app.py index 2a75df4fc..3f4d164e8 100644 --- a/src/globus_sdk/experimental/globus_app/globus_app.py +++ b/src/globus_sdk/experimental/globus_app/globus_app.py @@ -3,7 +3,6 @@ import abc import os import sys -import typing as t from dataclasses import dataclass from globus_sdk import ( @@ -35,6 +34,11 @@ ) from .errors import IdentityMismatchError, TokenValidationError +if sys.version_info < (3, 8): + from typing_extensions import Protocol +else: + from typing import Protocol + def _default_filename(app_name: str) -> str: r""" @@ -57,7 +61,7 @@ def _default_filename(app_name: str) -> str: return os.path.expanduser(f"~/.globus/app/{app_name}/tokens.json") -class TokenValidationErrorHandler(t.Protocol): +class TokenValidationErrorHandler(Protocol): def __call__(self, app: GlobusApp, error: TokenValidationError) -> None: ...