Skip to content

Commit

Permalink
GlobusApp login, logout, and login_required (#1041)
Browse files Browse the repository at this point in the history
  • Loading branch information
derek-globus authored Sep 6, 2024
1 parent 028e502 commit ca3bb36
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 39 deletions.
35 changes: 35 additions & 0 deletions changelog.d/20240904_154915_derek_login_logout.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

Added
~~~~~

- Added ``login(...)``, ``logout(...)``, and ``login_required(...)`` to the
experimental ``GlobusApp`` construct. (:pr:`NUMBER`)

- ``login(...)`` initiates a login flow if:

- the current entity requires a login to satisfy local scope requirements or
- ``auth_params``/``force=True`` is passed to the method.

- ``logout(...)`` remove and revokes the current entity's app-associated tokens.

- ``login_required(...)`` returns a boolean indicating whether the app believes
a login is required to satisfy local scope requirements.

Removed
~~~~~~~

- Made ``run_login_flow`` private in the experimental ``GlobusApp`` construct.
Usage sites should be replaced with either ``app.login()`` or
``app.login(force=True)``. (:pr:`NUMBER`)

- **Old Usage**

.. code-block:: python
app = UserApp("my-app", client_id="<my-client-id>")
app.run_login_flow()
- **New Usage**

.. code-block:: python
app = UserApp("my-app", client_id="<my-client-id>")
app.login(force=True)
23 changes: 14 additions & 9 deletions docs/experimental/examples/oauth2/globus_app.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,20 @@ invocation of your requested method will proceed as expected.
Manually Running Login Flows
----------------------------

While your app will automatically initiate and oversee auth flows as detected, sometimes
an author may want to explicitly control when an authorization occurs. To manually
trigger a login flow, call ``GlobusApp.run_login_flow(...)``. This will initiate an auth
flow requesting new tokens based on the app's currently defined scope requirements, and
caching the resulting tokens for future use.
While your app will automatically initiate and oversee login flows when needed,
sometimes an author may want to explicitly control when an authorization occurs. To
manually trigger a login flow, call ``GlobusApp.login(...)``. The app will evaluate the
current scope requirements against available tokens, initiating a login flow if it
determines that any requirements across any resource servers are unmet. Resulting tokens
will be cached for future use.

This method accepts a single optional parameter, ``auth_params``, where a caller
may specify additional session-based auth parameters such as requiring the use of an
MFA token or rendering with a specific message:
This method accepts two optional keyword args:

- ``auth_params``, a collection of additional auth parameters to customize the login.
This allows for specifications such as requiring that a user be logged in with an
MFA token or rendering the authorization webpage with a specific message.
- ``force``, a boolean flag instructing the app to perform a login flow regardless of
whether it is required.


.. code-block:: python
Expand All @@ -155,7 +160,7 @@ MFA token or rendering with a specific message:
...
my_app.run_login_flow(
my_app.login(
auth_params=GlobusAuthorizationParameters(
session_message="Please authenticate with MFA",
session_required_mfa=True,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def __init__(
:param consent_client: An AuthClient to be used for consent polling. If omitted,
dependent scope requirements are ignored during validation.
"""
self._token_storage = token_storage
self.token_storage = token_storage
self.scope_requirements = scope_requirements
self._consent_client = consent_client

Expand All @@ -90,7 +90,7 @@ def _lookup_stored_identity_id(self) -> UUIDLike | None:
storage, otherwise None
"""
token_data_by_resource_server = (
self._token_storage.get_token_data_by_resource_server()
self.token_storage.get_token_data_by_resource_server()
)
return _get_identity_id_from_token_data_by_resource_server(
token_data_by_resource_server
Expand Down Expand Up @@ -126,7 +126,7 @@ def store_token_data_by_resource_server(
resource_server, token_data, eval_dependent=False
)

self._token_storage.store_token_data_by_resource_server(
self.token_storage.store_token_data_by_resource_server(
token_data_by_resource_server
)

Expand All @@ -136,7 +136,7 @@ def get_token_data_by_resource_server(self) -> dict[str, TokenData]:
:raises: :exc:`UnmetScopeRequirementsError` if any token data does not meet the
attached scope requirements.
"""
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 by_resource_server.items():
self._validate_token_data_meets_scope_requirements(
Expand All @@ -154,7 +154,7 @@ def get_token_data(self, resource_server: str) -> TokenData:
: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)
token_data = self.token_storage.get_token_data(resource_server)
if token_data is None:
msg = f"No token data for {resource_server}"
raise MissingTokenError(msg, resource_server=resource_server)
Expand All @@ -167,7 +167,7 @@ def remove_token_data(self, resource_server: str) -> bool:
"""
:param resource_server: The resource server string to remove token data for
"""
return self._token_storage.remove_token_data(resource_server)
return self.token_storage.remove_token_data(resource_server)

def _validate_token_data_by_resource_server_meets_identity_requirements(
self, token_data_by_resource_server: t.Mapping[str, TokenData]
Expand Down
81 changes: 74 additions & 7 deletions src/globus_sdk/experimental/globus_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,76 @@ def _initialize_authorizer_factory(self) -> None:
authorize requests.
"""

def login(
self,
*,
auth_params: GlobusAuthorizationParameters | None = None,
force: bool = False,
) -> None:
"""
Log an auth entity into the app, if needed, storing the resulting tokens.
A login flow will be performed if any of the following are true:
* The kwarg ``auth_params`` is provided.
* The kwarg ``force`` is set to True.
* The method ``self.login_required()`` evaluates to True.
:param auth_params: An optional set of authorization parameters to establish
requirements and controls for the login flow.
:param force: If True, perform a login flow even if one does not appear to
be necessary.
"""
if auth_params or force or self.login_required():
self._run_login_flow(auth_params)

def login_required(self) -> bool:
"""
Determine if a login flow will be required to interact with resource servers
under the current scope requirements.
This will return false if any of the following are true:
* Access tokens have never been issued.
* Access tokens have been issued but have insufficient scopes.
* Access tokens have expired and wouldn't be resolved with refresh tokens.
:returns: True if a login flow appears to be required, False otherwise.
"""
for resource_server in self._scope_requirements.keys():
try:
self.get_authorizer(resource_server, skip_error_handling=True)
except TokenValidationError:
return True
return False

def logout(self) -> None:
"""
Logout an auth entity from the app.
This will remove and revoke all tokens stored for the current app user.
"""
# Revoke all tokens, removing them from the underlying token storage
inner_token_storage = self.token_storage.token_storage
for resource_server in self._scope_requirements.keys():
token_data = inner_token_storage.get_token_data(resource_server)
if token_data:
self._login_client.oauth2_revoke_token(token_data.access_token)
if token_data.refresh_token:
self._login_client.oauth2_revoke_token(token_data.refresh_token)
inner_token_storage.remove_token_data(resource_server)

# Invalidate any cached authorizers
self._authorizer_factory.clear_cache()

@abc.abstractmethod
def run_login_flow(
def _run_login_flow(
self, auth_params: GlobusAuthorizationParameters | None = None
) -> None:
"""
Run an authorization flow to get new tokens which are stored and available
for the next authorizer gotten by get_authorizer.
:param auth_params: A GlobusAuthorizationParameters to control how the user
will authenticate. If not passed
:param auth_params: An optional set of authorization parameters to establish
requirements and controls for the login flow.
"""

def _auth_params_with_required_scopes(
Expand Down Expand Up @@ -265,18 +325,25 @@ def _auth_params_with_required_scopes(

return auth_params

def get_authorizer(self, resource_server: str) -> GlobusAuthorizer:
def get_authorizer(
self,
resource_server: str,
*,
skip_error_handling: bool = False,
) -> GlobusAuthorizer:
"""
Get a ``GlobusAuthorizer`` from the app's authorizer factory for a specified
resource server. The type of authorizer is dependent on the app.
:param resource_server: the resource server the Authorizer will provide
authorization headers for
:param resource_server: The resource server for which the requested Authorizer
should provide authorization headers.
:param skip_error_handling: If True, skip the configured token validation error
handler when a ``TokenValidationError`` is raised. Default: False.
"""
try:
return self._authorizer_factory.get_authorizer(resource_server)
except TokenValidationError as e:
if self.config.token_validation_error_handler:
if not skip_error_handling and 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._authorizer_factory.get_authorizer(resource_server)
Expand Down
2 changes: 1 addition & 1 deletion src/globus_sdk/experimental/globus_app/client_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _initialize_authorizer_factory(self) -> None:
confidential_client=self._login_client,
)

def run_login_flow(
def _run_login_flow(
self, auth_params: GlobusAuthorizationParameters | None = None
) -> None:
"""
Expand Down
2 changes: 1 addition & 1 deletion src/globus_sdk/experimental/globus_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def resolve_by_login_flow(app: GlobusApp, error: TokenValidationError) -> None:
# that can be resolved by running a login flow.
raise error

app.run_login_flow()
app.login(force=True)


@dataclasses.dataclass(frozen=True)
Expand Down
2 changes: 1 addition & 1 deletion src/globus_sdk/experimental/globus_app/user_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def _initialize_authorizer_factory(self) -> None:
token_storage=self.token_storage
)

def run_login_flow(
def _run_login_flow(
self, auth_params: GlobusAuthorizationParameters | None = None
) -> None:
"""
Expand Down
2 changes: 0 additions & 2 deletions src/globus_sdk/services/transfer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ def add_app_data_access_scope(
app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID)
client = TransferClient(app=app).add_app_data_access_scope(COLLECTION_ID)
app.run_login_flow()
res = client.operation_ls(COLLECTION_ID)
Expand All @@ -160,7 +159,6 @@ def add_app_data_access_scope(
client = TransferClient(app=app).add_app_data_access_scope(
(COLLECTION_ID_1, COLLECTION_ID_2)
)
app.run_login_flow()
transfer_data = TransferData(
source_endpoint=COLLECTION_ID_1,
Expand Down
79 changes: 67 additions & 12 deletions tests/unit/experimental/globus_app/test_globus_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def test_user_app_default_token_storage():
client_id = "mock_client_id"
user_app = UserApp("test-app", client_id=client_id)

token_storage = user_app._authorizer_factory.token_storage._token_storage
token_storage = user_app._authorizer_factory.token_storage.token_storage
assert isinstance(token_storage, JSONTokenStorage)

if os.name == "nt":
Expand Down Expand Up @@ -447,27 +447,34 @@ def test_client_app_get_authorizer():


@mock.patch.object(OAuthTokenResponse, "decode_id_token", _mock_decode)
def test_user_app_run_login_flow(monkeypatch, capsys):
def test_user_app_login_logout(monkeypatch, capsys):
monkeypatch.setattr("builtins.input", _mock_input)
load_response(NativeAppAuthClient.oauth2_exchange_code_for_tokens, case="openid")
load_response(NativeAppAuthClient.oauth2_revoke_token)

client_id = "mock_client_id"
memory_storage = MemoryTokenStorage()
config = GlobusAppConfig(token_storage=memory_storage)
user_app = UserApp("test-app", client_id=client_id, config=config)

user_app.run_login_flow()
assert (
user_app._token_storage.get_token_data("auth.globus.org").access_token
== "auth_access_token"
)
assert memory_storage.get_token_data("auth.globus.org") is None
assert user_app.login_required() is True

user_app.login()
assert memory_storage.get_token_data("auth.globus.org").access_token is not None
assert user_app.login_required() is False

user_app.logout()
assert memory_storage.get_token_data("auth.globus.org") is None
assert user_app.login_required() is True


@mock.patch.object(OAuthTokenResponse, "decode_id_token", _mock_decode)
def test_client_app_run_login_flow():
def test_client_app_login_logout():
load_response(
ConfidentialAppAuthClient.oauth2_client_credentials_tokens, case="openid"
)
load_response(ConfidentialAppAuthClient.oauth2_revoke_token)

client_id = "mock_client_id"
client_secret = "mock_client_secret"
Expand All @@ -477,8 +484,56 @@ def test_client_app_run_login_flow():
"test-app", client_id=client_id, client_secret=client_secret, config=config
)

client_app.run_login_flow()
assert (
client_app._token_storage.get_token_data("auth.globus.org").access_token
== "auth_access_token"
assert memory_storage.get_token_data("auth.globus.org") is None

client_app.login()
assert memory_storage.get_token_data("auth.globus.org").access_token is not None

client_app.logout()
assert memory_storage.get_token_data("auth.globus.org") is None


@mock.patch.object(OAuthTokenResponse, "decode_id_token", _mock_decode)
@pytest.mark.parametrize(
"login_kwargs,expected_login",
(
# No params - no additional login
({}, False),
# "force" or "auth_params" - additional login
({"force": True}, True),
(
{"auth_params": GlobusAuthorizationParameters(session_required_mfa=True)},
True,
),
),
)
def test_app_login_flows_can_be_forced(login_kwargs, expected_login, monkeypatch):
monkeypatch.setattr("builtins.input", _mock_input)
load_response(NativeAppAuthClient.oauth2_exchange_code_for_tokens, case="openid")

config = GlobusAppConfig(
token_storage="memory",
login_flow_manager=CountingCommandLineLoginFlowManager,
)
user_app = UserApp("test-app", client_id="mock_client_id", config=config)

user_app.login()
assert user_app.login_required() is False
assert user_app._login_flow_manager.counter == 1

user_app.login(**login_kwargs)
expected_count = 2 if expected_login else 1
assert user_app._login_flow_manager.counter == expected_count


class CountingCommandLineLoginFlowManager(CommandLineLoginFlowManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.counter = 0

def run_login_flow(
self,
auth_parameters: GlobusAuthorizationParameters,
) -> OAuthTokenResponse:
self.counter += 1
return super().run_login_flow(auth_parameters)

0 comments on commit ca3bb36

Please sign in to comment.