Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added overridable auto-login to GlobusApp #994

Merged
merged 7 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/20240628_152312_derek_auto_run_login_flow.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

Added
~~~~~

- Auto-login (overridable in config) GlobusApp login retry on token validation error.(:pr:`NUMBER`)
9 changes: 8 additions & 1 deletion src/globus_sdk/experimental/globus_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -17,4 +23,5 @@
"UserApp",
"ClientApp",
"GlobusAppConfig",
"TokenValidationErrorHandler",
]
105 changes: 48 additions & 57 deletions src/globus_sdk/experimental/globus_app/_validating_token_storage.py
Original file line number Diff line number Diff line change
@@ -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,
MissingTokenError,
UnmetScopeRequirementsError,
)

Expand Down Expand Up @@ -99,40 +97,59 @@ 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:`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
)

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:`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():
self._validate_token_meets_scope_requirements(resource_server, token_data)
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 | 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:`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:
return None
msg = f"No token data for {resource_server}"
raise MissingTokenError(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

Expand All @@ -142,22 +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(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:
Expand Down Expand Up @@ -197,13 +198,7 @@ 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(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:
"""
Expand All @@ -214,6 +209,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)

Expand All @@ -225,29 +221,24 @@ 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.
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",
scope_requirements=self.scope_requirements,
Expand Down
89 changes: 64 additions & 25 deletions src/globus_sdk/experimental/globus_app/authorizer_factory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import abc
import time
import typing as t

from globus_sdk import AuthLoginClient, ConfidentialAppAuthClient
Expand All @@ -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, MissingTokenError

GA = t.TypeVar("GA", bound=GlobusAuthorizer)

Expand All @@ -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:
Expand All @@ -69,11 +62,14 @@ 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:`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]
Expand All @@ -97,17 +93,53 @@ 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) -> 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]
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)


Expand Down Expand Up @@ -135,15 +167,15 @@ 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._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 MissingTokenError(msg, resource_server=resource_server)

return RefreshTokenAuthorizer(
refresh_token=token_data.refresh_token,
Expand All @@ -159,6 +191,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__(
Expand Down Expand Up @@ -191,17 +227,20 @@ 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(
"ValidatingTokenStorage has no scope_requirements for "
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 MissingTokenError:
access_token, expires_at = None, None

return ClientCredentialsAuthorizer(
confidential_client=self.confidential_client,
scopes=scopes,
Expand Down
Loading