Skip to content

Commit

Permalink
Improved token_caching interface
Browse files Browse the repository at this point in the history
  • Loading branch information
derek-globus committed Jul 29, 2024
1 parent 0bc9254 commit 3b18642
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 54 deletions.
24 changes: 24 additions & 0 deletions changelog.d/20240728_220953_derek_client_based_token_caching.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

Changed
~~~~~~~

- Changed the experimental ``GlobusApp`` class in the following way (:pr:`NUMBER`):

- ``app_name`` is no longer required (defaults to "DEFAULT")

- Token storage now defaults to including the client id in the path.

- Old (unix) : ``~/.globus/app/{app_name}/tokens.json``

- New (unix): ``~/.globus/app/{client_id}/{app_name}/tokens.json``

- Old (win): ``~\AppData\Local\globus\app\{app_name}\tokens.json``

- New (win): ``~\AppData\Local\globus\app\{client_id}\{app_name}\tokens.json``

- ``GlobusAppConfig.token_storage`` now accepts shorthand string references:
``"json"`` to use a ``JSONTokenStorage``, ``"sqlite"`` to use a
``SQLiteTokenStorage`` and ``"memory"`` to use a ``MemoryTokenStorage``.

- ``GlobusAppConfig.token_storage`` also now accepts a ``TokenStorageProvidable``,
a class with a ``for_globus_app(...) -> TokenStorage`` class method.
126 changes: 77 additions & 49 deletions src/globus_sdk/experimental/globus_app/globus_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import abc
import dataclasses
import os
import sys
import typing as t
from dataclasses import dataclass

from globus_sdk import (
Expand All @@ -24,7 +24,12 @@
CommandLineLoginFlowManager,
LoginFlowManager,
)
from globus_sdk.experimental.tokenstorage import JSONTokenStorage, TokenStorage
from globus_sdk.experimental.tokenstorage import (
JSONTokenStorage,
MemoryTokenStorage,
SQLiteTokenStorage,
TokenStorage,
)
from globus_sdk.scopes import AuthScopes

from ._validating_token_storage import ValidatingTokenStorage
Expand All @@ -37,35 +42,17 @@
from .errors import IdentityMismatchError, TokenValidationError

if sys.version_info < (3, 8):
from typing_extensions import Protocol
from typing_extensions import Protocol, runtime_checkable
else:
from typing import Protocol


def _default_filename(app_name: str, environment: str) -> str:
r"""
construct the filename for the default JSONTokenStorage to use
from typing import Protocol, runtime_checkable

on Windows, this is typically
~\AppData\Local\globus\app\{app_name}/tokens.json

on Linux and macOS, we use
~/.globus/app/{app_name}/tokens.json
"""
environment_prefix = f"{environment}-"
if environment == "production":
environment_prefix = ""
filename = f"{environment_prefix}tokens.json"

if sys.platform == "win32":
# try to get the app data dir, preferring the local appdata
datadir = os.getenv("LOCALAPPDATA", os.getenv("APPDATA"))
if not datadir:
home = os.path.expanduser("~")
datadir = os.path.join(home, "AppData", "Local")
return os.path.join(datadir, "globus", "app", app_name, filename)
else:
return os.path.expanduser(f"~/.globus/app/{app_name}/{filename}")
@runtime_checkable
class TokenStorageProvidable(Protocol):
@classmethod
def for_globus_app(
cls, client_id: UUIDLike, app_name: str, config: GlobusAppConfig, namespace: str
) -> TokenStorage: ...


class TokenValidationErrorHandler(Protocol):
Expand All @@ -88,6 +75,14 @@ def resolve_by_login_flow(app: GlobusApp, error: TokenValidationError) -> None:
app.run_login_flow()


KnownTokenStorages = t.Literal["json", "sqlite", "memory"]
KNOWN_TOKEN_STORAGES: dict[KnownTokenStorages, t.Type[TokenStorageProvidable]] = {
"json": JSONTokenStorage,
"sqlite": SQLiteTokenStorage,
"memory": MemoryTokenStorage,
}


@dataclass(frozen=True)
class GlobusAppConfig:
"""
Expand All @@ -98,9 +93,10 @@ class GlobusAppConfig:
be initialized with the app's ``login_client`` and this config's
``request_refresh_tokens``.
If not given the default behavior will depend on the type of ``GlobusApp``.
:param token_storage: a ``TokenStorage`` instance or class that will
be used for storing token data. If not passed a ``JSONTokenStorage``
will be used.
:param token_storage: The interface for ``GlobusApp`` to store and retrieve tokens.
Must be one of (1) a TokenStorage instance, (2) a TokenStorageProvidable class,
or (3) one of the supported string values: "json", "sqlite", "memory".
Default: "json"
:param request_refresh_tokens: If True, the ``GlobusApp`` will request refresh
tokens for long-lived access.
:param token_validation_error_handler: A callable that will be called when a
Expand All @@ -111,7 +107,7 @@ class GlobusAppConfig:
"""

login_flow_manager: LoginFlowManager | type[LoginFlowManager] | None = None
token_storage: TokenStorage | None = None
token_storage: KnownTokenStorages | TokenStorageProvidable | TokenStorage = "json"
request_refresh_tokens: bool = False
token_validation_error_handler: TokenValidationErrorHandler | None = (
resolve_by_login_flow
Expand Down Expand Up @@ -144,7 +140,7 @@ class GlobusApp(metaclass=abc.ABCMeta):

def __init__(
self,
app_name: str,
app_name: str = "DEFAULT",
*,
login_client: AuthLoginClient | None = None,
client_id: UUIDLike | None = None,
Expand Down Expand Up @@ -172,23 +168,20 @@ def __init__(
self.app_name = app_name
self.config = config

self.client_id, self._login_client = self._determine_client_info(
app_name=app_name,
config=config,
self.client_id, self._login_client = self._resolve_client_info(
app_name=self.app_name,
config=self.config,
client_id=client_id,
client_secret=client_secret,
login_client=login_client,
)

self._scope_requirements = scope_requirements or {}

# either get config's TokenStorage, or make the default JSONTokenStorage
if self.config.token_storage:
self._token_storage = self.config.token_storage
else:
self._token_storage = JSONTokenStorage(
filename=_default_filename(self.app_name, self.config.environment)
)
self._token_storage = self._resolve_token_storage(
app_name=self.app_name,
client_id=self.client_id,
config=self.config,
)

# construct ValidatingTokenStorage around the TokenStorage and
# our initial scope requirements
Expand All @@ -207,7 +200,7 @@ def __init__(
consent_client = AuthClient(app=self, app_scopes=[Scope(AuthScopes.openid)])
self._validating_token_storage.set_consent_client(consent_client)

def _determine_client_info(
def _resolve_client_info(
self,
app_name: str,
config: GlobusAppConfig,
Expand All @@ -216,7 +209,8 @@ def _determine_client_info(
client_secret: str | None,
) -> tuple[UUIDLike, AuthLoginClient]:
"""
Extracts a client_id and login_client from GlobusApp initialization parameters.
Extracts a client_id and login_client from GlobusApp initialization parameters,
validating that the parameters were provided correctly.
Depending on which parameters were provided, this method will either:
1. Create a new login client from the supplied credentials.
Expand All @@ -225,6 +219,8 @@ def _determine_client_info(
2. Extract the client_id from a supplied login_client.
:returns: tuple of client_id and login_client
:raises: GlobusSDKUsageError if a single client ID or login client could not be
definitively resolved.
"""
if login_client and client_id:
msg = "Mutually exclusive parameters: client_id and login_client."
Expand All @@ -233,7 +229,7 @@ def _determine_client_info(
if login_client:
# User provided an explicit login client, extract the client_id.
if login_client.client_id is None:
msg = "An explicit login_client must have a discoverable client_id."
msg = "A GlobusApp login_client must have a discoverable client_id."
raise GlobusSDKUsageError(msg)
if login_client.environment != config.environment:
raise GlobusSDKUsageError(
Expand Down Expand Up @@ -269,6 +265,38 @@ def _initialize_login_client(
Initializes and returns an AuthLoginClient to be used in authorization requests.
"""

def _resolve_token_storage(
self, app_name: str, client_id: UUIDLike, config: GlobusAppConfig
) -> TokenStorage:
"""
Resolve the raw token storage to be used by the app.
This may be:
1. A TokenStorage instance provided by the user, which we use directly.
2. A TokenStorageProvidable, which we use to get a TokenStorage.
3. A string value, which we map onto supported TokenStorage types.
:returns: TokenStorage instance to be used by the app.
:raises: GlobusSDKUsageError if the provided token_storage value is unsupported.
"""
token_storage = config.token_storage
# TODO - make namespace configurable
namespace = "DEFAULT"
if isinstance(token_storage, TokenStorage):
return token_storage

elif isinstance(token_storage, TokenStorageProvidable):
return token_storage.for_globus_app(client_id, app_name, config, namespace)

elif token_storage in KNOWN_TOKEN_STORAGES:
providable = KNOWN_TOKEN_STORAGES[token_storage]
return providable.for_globus_app(client_id, app_name, config, namespace)

raise GlobusSDKUsageError(
f"Unsupported token_storage value: {token_storage}. Must be a "
f"TokenStorage, TokenStorageProvidable, or a supported string value."
)

@abc.abstractmethod
def _initialize_authorizer_factory(self) -> None:
"""
Expand Down Expand Up @@ -392,7 +420,7 @@ class UserApp(GlobusApp):

def __init__(
self,
app_name: str,
app_name: str = "DEFAULT",
*,
login_client: AuthLoginClient | None = None,
client_id: UUIDLike | None = None,
Expand Down Expand Up @@ -515,7 +543,7 @@ class ClientApp(GlobusApp):

def __init__(
self,
app_name: str,
app_name: str = "DEFAULT",
*,
login_client: ConfidentialAppAuthClient | None = None,
client_id: UUIDLike | None = None,
Expand Down
76 changes: 76 additions & 0 deletions src/globus_sdk/experimental/tokenstorage/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
import contextlib
import os
import pathlib
import sys
import typing as t

from globus_sdk.services.auth import OAuthTokenResponse

from ..._types import UUIDLike
from .token_data import TokenData

if t.TYPE_CHECKING:
from globus_sdk.experimental.globus_app import GlobusAppConfig


class TokenStorage(metaclass=abc.ABCMeta):
"""
Expand Down Expand Up @@ -108,6 +113,9 @@ class FileTokenStorage(TokenStorage, metaclass=abc.ABCMeta):
files.
"""

# File suffix associated with files of this type (e.g., "csv")
file_format: str = "_UNSET_" # must be overridden by subclasses

def __init__(self, filename: pathlib.Path | str, *, namespace: str = "DEFAULT"):
"""
:param filename: the name of the file to write to and read from
Expand All @@ -117,6 +125,30 @@ def __init__(self, filename: pathlib.Path | str, *, namespace: str = "DEFAULT"):
self._ensure_containing_dir_exists()
super().__init__(namespace=namespace)

def __init_subclass__(cls, **kwargs: t.Any):
if cls.file_format == "_UNSET_":
raise TypeError(f"{cls.__name__} must set a 'file_format' class attribute")

@classmethod
def for_globus_app(
cls,
client_id: UUIDLike,
app_name: str,
config: GlobusAppConfig,
namespace: str,
) -> TokenStorage:
"""
Initialize a TokenStorage instance for a GlobusApp, using the supplied
info to determine the file location.
:param client_id: The client ID of the Globus App.
:param app_name: The name of the Globus App.
:param config: The GlobusAppConfig object for the Globus App.
:param namespace: A user-supplied namespace for partitioning token data.
"""
filename = _default_globus_app_filename(client_id, app_name, config.environment)
return cls(filename=f"{filename}.{cls.file_format}", namespace=namespace)

def _ensure_containing_dir_exists(self) -> None:
"""
Ensure that the directory containing the given filename exists.
Expand Down Expand Up @@ -148,3 +180,47 @@ def user_only_umask(self) -> t.Iterator[None]:
yield
finally:
os.umask(old_umask)


def _default_globus_app_filename(
client_id: UUIDLike, app_name: str, environment: str
) -> str:
r"""
Construct a default TokenStorage filename for a globus app.
The filename will have no file format suffix.
On Windows, this will be:
``~\AppData\Local\globus\app\{client_id}\{app_name}\tokens``
On Linux and macOS, we use:
``~/.globus/app/{client_id}/{app_name}/tokens``
"""
environment_prefix = f"{environment}-"
if environment == "production":
environment_prefix = ""
filename = f"{environment_prefix}tokens"
app_name = _slugify_app_name(app_name)

if sys.platform == "win32":
# try to get the app data dir, preferring the local appdata
datadir = os.getenv("LOCALAPPDATA", os.getenv("APPDATA"))
if not datadir:
home = os.path.expanduser("~")
datadir = os.path.join(home, "AppData", "Local")
return os.path.join(
datadir, "globus", "app", str(client_id), app_name, filename
)
else:
return os.path.expanduser(
f"~/.globus/app/{str(client_id)}/{app_name}/{filename}"
)


def _slugify_app_name(app_name: str) -> str:
"""
Slugify a globus app name for use in a file path.
This "slugification" will replace spaces with hyphens and lowercase the string so:
"My App" -> "my-app"
"""
return app_name.replace(" ", "-").lower()
2 changes: 2 additions & 0 deletions src/globus_sdk/experimental/tokenstorage/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class JSONTokenStorage(FileTokenStorage):
# the supported versions (data not in these versions causes an error)
supported_versions = ("1.0", "2.0")

file_format = "json"

def _invalid(self, msg: str) -> t.NoReturn:
raise ValueError(
f"{msg} while loading from '{self.filename}' for JSON Token Storage"
Expand Down
Loading

0 comments on commit 3b18642

Please sign in to comment.