Repository: globus/globus-sdk-python

Issue:
Add `GlobusApp` method to check login status
`GlobusApp` objects currently enable users to manually trigger a login flow via `app.run_login_flow()`, but not to check if a user already has valid tokens (i.e., logged in). I can accomplish this by interacting with the `app._validating_token_storage` object, but that is not a public interface, nor intuitive.

I propose creating an `app.is_logged_in()` method, or similar, which supports a `resource_server` argument. If the user does not specify a resource server, then the method could validate tokens for all associated resource servers.

In addition, we might consider creating an `app.ensure_logged_in()` method, or similar, which would simply run `app.run_login_flow()` if `app.is_logged_in()` returns `False`.

Discussion:
derek-globus: Rebased from main to resolve these changes against #1040 

Patch:
diff --git a/changelog.d/20240904_103850_sirosen_extensible_cli_login_manager.rst b/changelog.d/20240904_103850_sirosen_extensible_cli_login_manager.rst
new file mode 100644
index 00000000..63353061
--- /dev/null
+++ b/changelog.d/20240904_103850_sirosen_extensible_cli_login_manager.rst
@@ -0,0 +1,27 @@
+Changed
+~~~~~~~
+
+.. rubric:: Experimental
+
+-   The ``CommandLineLoginFlowManager`` now exposes ``print_authorize_url`` and
+    ``prompt_for_code`` as methods, which replace the ``login_prompt`` and
+    ``code_prompt`` parameters. Users who wish to customize prompting behavior
+    now have a greater degree of control, and can effect this by subclassing the
+    ``CommandLineLoginFlowManager``. (:pr:`1039`)
+
+    Example usage, which uses the popular ``click`` library to handle the
+    prompts:
+
+    .. code-block:: python
+
+        import click
+        from globus_sdk.experimental.login_flow_manager import CommandLineLoginFlowManager
+
+
+        class ClickLoginFlowManager(CommandLineLoginFlowManager):
+            def print_authorize_url(self, authorize_url: str) -> None:
+                click.echo(click.style("Login here for a code:", fg="yellow"))
+                click.echo(authorize_url)
+
+            def prompt_for_code(self) -> str:
+                return click.prompt("Enter the code here:")
diff --git a/changelog.d/20240904_154915_derek_login_logout.rst b/changelog.d/20240904_154915_derek_login_logout.rst
new file mode 100644
index 00000000..319af986
--- /dev/null
+++ b/changelog.d/20240904_154915_derek_login_logout.rst
@@ -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)
diff --git a/changelog.d/20240905_134232_derek_better_app_scope_inspection.rst b/changelog.d/20240905_134232_derek_better_app_scope_inspection.rst
new file mode 100644
index 00000000..a639e656
--- /dev/null
+++ b/changelog.d/20240905_134232_derek_better_app_scope_inspection.rst
@@ -0,0 +1,9 @@
+
+Changed
+~~~~~~~
+
+-   The experimental ``GlobusApp`` construct's scope exploration interface has changed
+    from ``app.get_scope_requirements(resource_server: str) -> tuple[Scope]`` to
+    ``app.scope_requirements``. The new property will return a deep copy of the internal
+    requirements dictionary mapping resource server to a list of Scopes. (:pr:`1042`)
+
diff --git a/docs/experimental/examples/oauth2/globus_app.rst b/docs/experimental/examples/oauth2/globus_app.rst
index 02c9e37e..a3aa3f2d 100644
--- a/docs/experimental/examples/oauth2/globus_app.rst
+++ b/docs/experimental/examples/oauth2/globus_app.rst
@@ -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
@@ -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,
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 7d4a0a41..f9fce49d 100644
--- a/src/globus_sdk/experimental/globus_app/_validating_token_storage.py
+++ b/src/globus_sdk/experimental/globus_app/_validating_token_storage.py
@@ -72,7 +72,7 @@ class ValidatingTokenStorage(TokenStorage):
         :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
 
@@ -90,7 +90,7 @@ class ValidatingTokenStorage(TokenStorage):
             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
@@ -126,7 +126,7 @@ class ValidatingTokenStorage(TokenStorage):
                 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
         )
 
@@ -136,7 +136,7 @@ class ValidatingTokenStorage(TokenStorage):
         :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(
@@ -154,7 +154,7 @@ class ValidatingTokenStorage(TokenStorage):
         :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)
@@ -167,7 +167,7 @@ class ValidatingTokenStorage(TokenStorage):
         """
         :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]
diff --git a/src/globus_sdk/experimental/globus_app/app.py b/src/globus_sdk/experimental/globus_app/app.py
index 83032517..ee89132c 100644
--- a/src/globus_sdk/experimental/globus_app/app.py
+++ b/src/globus_sdk/experimental/globus_app/app.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import abc
+import copy
 import typing as t
 
 from globus_sdk import AuthClient, AuthLoginClient, GlobusSDKUsageError, Scope
@@ -227,16 +228,76 @@ class GlobusApp(metaclass=abc.ABCMeta):
         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(
@@ -264,18 +325,25 @@ class GlobusApp(metaclass=abc.ABCMeta):
 
         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)
@@ -297,11 +365,13 @@ class GlobusApp(metaclass=abc.ABCMeta):
 
         self._authorizer_factory.clear_cache(*scope_requirements.keys())
 
-    def get_scope_requirements(self, resource_server: str) -> tuple[Scope, ...]:
+    @property
+    def scope_requirements(self) -> dict[str, list[Scope]]:
         """
-        Get an immutable copy of the collection scope requirements currently defined
-        on a resource server.
+        Access a copy of app's aggregate scope requirements.
 
-        :param resource_server: the resource server to get scope requirements for
+        Modifying the returned dict will not affect the app's scope requirements.
+        To add scope requirements, use ``GlobusApp.add_scope_requirements()``.
         """
-        return tuple(self._scope_requirements.get(resource_server, []))
+        # Scopes are mutable objects so we return a deepcopy
+        return copy.deepcopy(self._scope_requirements)
diff --git a/src/globus_sdk/experimental/globus_app/client_app.py b/src/globus_sdk/experimental/globus_app/client_app.py
index 2d1e3953..3345c3e3 100644
--- a/src/globus_sdk/experimental/globus_app/client_app.py
+++ b/src/globus_sdk/experimental/globus_app/client_app.py
@@ -91,7 +91,7 @@ class ClientApp(GlobusApp):
             confidential_client=self._login_client,
         )
 
-    def run_login_flow(
+    def _run_login_flow(
         self, auth_params: GlobusAuthorizationParameters | None = None
     ) -> None:
         """
diff --git a/src/globus_sdk/experimental/globus_app/config.py b/src/globus_sdk/experimental/globus_app/config.py
index f589bcd8..bfee95e6 100644
--- a/src/globus_sdk/experimental/globus_app/config.py
+++ b/src/globus_sdk/experimental/globus_app/config.py
@@ -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)
diff --git a/src/globus_sdk/experimental/globus_app/user_app.py b/src/globus_sdk/experimental/globus_app/user_app.py
index de2afad2..ebfcce65 100644
--- a/src/globus_sdk/experimental/globus_app/user_app.py
+++ b/src/globus_sdk/experimental/globus_app/user_app.py
@@ -139,7 +139,7 @@ class UserApp(GlobusApp):
                 token_storage=self.token_storage
             )
 
-    def run_login_flow(
+    def _run_login_flow(
         self, auth_params: GlobusAuthorizationParameters | None = None
     ) -> None:
         """
diff --git a/src/globus_sdk/experimental/login_flow_manager/command_line_login_flow_manager.py b/src/globus_sdk/experimental/login_flow_manager/command_line_login_flow_manager.py
index cd58e128..434f7a09 100644
--- a/src/globus_sdk/experimental/login_flow_manager/command_line_login_flow_manager.py
+++ b/src/globus_sdk/experimental/login_flow_manager/command_line_login_flow_manager.py
@@ -41,8 +41,6 @@ class CommandLineLoginFlowManager(LoginFlowManager):
         redirect_uri: str | None = None,
         request_refresh_tokens: bool = False,
         native_prefill_named_grant: str | None = None,
-        login_prompt: str = "Please authenticate with Globus here:",
-        code_prompt: str = "Enter the resulting Authorization Code here:",
     ) -> None:
         """
         :param login_client: The ``AuthLoginClient`` that will be making the Globus
@@ -55,18 +53,12 @@ class CommandLineLoginFlowManager(LoginFlowManager):
         :param request_refresh_tokens: Control whether refresh tokens will be requested.
         :param native_prefill_named_grant: The named grant label to prefill on the
             consent page when using a NativeAppAuthClient.
-        :param login_prompt: The string that will be output to the command line
-            prompting the user to authenticate.
-        :param code_prompt: The string that will be output to the command line
-            prompting the user to enter their authorization code.
         """
         super().__init__(
             login_client,
             request_refresh_tokens=request_refresh_tokens,
             native_prefill_named_grant=native_prefill_named_grant,
         )
-        self.login_prompt = login_prompt
-        self.code_prompt = code_prompt
 
         if redirect_uri is None:
             # Confidential clients must always define their own custom redirect URI.
@@ -111,22 +103,33 @@ class CommandLineLoginFlowManager(LoginFlowManager):
             to the authentication flow to control how the user will authenticate.
         """
         authorize_url = self._get_authorize_url(auth_parameters, self.redirect_uri)
-        auth_code = self._print_and_prompt(authorize_url)
+        self.print_authorize_url(authorize_url)
+        auth_code = self.prompt_for_code()
 
         return self.login_client.oauth2_exchange_code_for_tokens(auth_code)
 
-    def _print_and_prompt(self, authorize_url: str) -> str:
-        # Prompt the user to authenticate
+    def print_authorize_url(self, authorize_url: str) -> None:
+        """
+        Prompt the user to authenticate using the provided ``authorize_url``.
+
+        :param authorize_url: The URL at which the user will login and consent to
+            application accesses.
+        """
+        login_prompt = "Please authenticate with Globus here:"
         print(
             textwrap.dedent(
                 f"""
-                {self.login_prompt}
-                {"-" * len(self.login_prompt)}
+                {login_prompt}
+                {"-" * len(login_prompt)}
                 {authorize_url}
-                {"-" * len(self.login_prompt)}
+                {"-" * len(login_prompt)}
                 """
             )
         )
 
-        # Request they to enter the auth code
-        return input(f"{self.code_prompt}\n").strip()
+    def prompt_for_code(self) -> str:
+        """
+        Prompt the user to enter an authorization code.
+        """
+        code_prompt = "Enter the resulting Authorization Code here: "
+        return input(code_prompt).strip()
diff --git a/src/globus_sdk/services/transfer/client.py b/src/globus_sdk/services/transfer/client.py
index 8aa287cd..7355c24f 100644
--- a/src/globus_sdk/services/transfer/client.py
+++ b/src/globus_sdk/services/transfer/client.py
@@ -147,7 +147,6 @@ class TransferClient(client.BaseClient):
 
                     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)
 
@@ -160,7 +159,6 @@ class TransferClient(client.BaseClient):
                     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,


Test patch:
diff --git a/tests/functional/test_login_manager.py b/tests/functional/test_login_manager.py
index ebb68cb4..4c87fe8a 100644
--- a/tests/functional/test_login_manager.py
+++ b/tests/functional/test_login_manager.py
@@ -24,13 +24,7 @@ def test_command_line_login_flower_manager_native(monkeypatch, capsys):
     load_response(login_client.oauth2_exchange_code_for_tokens)
     monkeypatch.setattr("builtins.input", _mock_input)
 
-    custom_login_prompt = "Login:"
-    custom_code_prompt = "Code:"
-    login_flow_manager = CommandLineLoginFlowManager(
-        login_client,
-        login_prompt=custom_login_prompt,
-        code_prompt=custom_code_prompt,
-    )
+    login_flow_manager = CommandLineLoginFlowManager(login_client)
     auth_params = GlobusAuthorizationParameters(
         required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"],
         session_required_identities=["user@org.edu"],
@@ -42,13 +36,36 @@ def test_command_line_login_flower_manager_native(monkeypatch, capsys):
     )
 
     captured_output = capsys.readouterr().out
-    assert custom_login_prompt in captured_output
-    assert custom_code_prompt in captured_output
     assert "https://auth.globus.org/v2/oauth2/authorize" in captured_output
     assert "client_id=mock_client_id" in captured_output
     assert "&session_required_identities=user%40org.edu" in captured_output
 
 
+def test_command_line_login_flower_manager_with_custom_prompts(capsys):
+    """
+    test CommandLineLoginFlowManager with a NativeAppAuthClient
+    """
+    login_client = NativeAppAuthClient("mock_client_id")
+    load_response(login_client.oauth2_exchange_code_for_tokens)
+
+    class CustomizedLoginFlowManager(CommandLineLoginFlowManager):
+        def print_authorize_url(self, authorize_url):
+            print("MyCustomLoginPrompt:", authorize_url)
+
+        def prompt_for_code(self):
+            return "mock_code_input"
+
+    login_flow_manager = CustomizedLoginFlowManager(login_client)
+    auth_params = GlobusAuthorizationParameters(
+        required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"]
+    )
+    login_flow_manager.run_login_flow(auth_params)
+
+    captured_output = capsys.readouterr().out
+    assert "MyCustomLoginPrompt" in captured_output
+    assert "https://auth.globus.org/v2/oauth2/authorize" in captured_output
+
+
 def test_command_line_login_flower_manager_confidential(monkeypatch, capsys):
     """
     test CommandLineLoginFlowManager with a ConfidentialAppAuthClient
diff --git a/tests/unit/experimental/globus_app/test_globus_app.py b/tests/unit/experimental/globus_app/test_globus_app.py
index f9ab5ccf..574c59fc 100644
--- a/tests/unit/experimental/globus_app/test_globus_app.py
+++ b/tests/unit/experimental/globus_app/test_globus_app.py
@@ -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":
@@ -313,8 +313,30 @@ def test_constructor_scope_requirements_accepts_different_scope_types(scope_coll
     assert _sorted_auth_scope_str(user_app) == "email openid"
 
 
+def test_scope_requirements_returns_copies_scopes():
+    user_app = UserApp("test-app", client_id="mock_client_id")
+    foo_scope = Scope("foo:all").add_dependency(Scope("bar:all"))
+    user_app.add_scope_requirements({"foo": [foo_scope]})
+
+    real_requirements = user_app._scope_requirements
+    real_openid = real_requirements["auth.globus.org"][0]
+    real_foo = real_requirements["foo"][0]
+
+    copied_requirements = user_app.scope_requirements
+    copied_openid = copied_requirements["auth.globus.org"][0]
+    copied_foo = copied_requirements["foo"][0]
+
+    assert real_requirements is not copied_requirements
+
+    # Copied requirements mirror the originals but are distinct objects.
+    assert real_openid is not copied_openid
+    assert real_foo is not copied_foo
+    assert str(real_openid) == str(copied_openid)
+    assert str(real_foo) == str(copied_foo)
+
+
 def _sorted_auth_scope_str(user_app: UserApp) -> str:
-    scope_list = user_app.get_scope_requirements("auth.globus.org")
+    scope_list = user_app.scope_requirements["auth.globus.org"]
     return " ".join(sorted(str(scope) for scope in scope_list))
 
 
@@ -425,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"
@@ -455,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)
diff --git a/tests/unit/experimental/test_client_integration.py b/tests/unit/experimental/test_client_integration.py
index 092396b9..96507215 100644
--- a/tests/unit/experimental/test_client_integration.py
+++ b/tests/unit/experimental/test_client_integration.py
@@ -38,7 +38,7 @@ def test_client_environment_does_not_match_the_globus_app_environment():
 def test_transfer_client_default_scopes(app):
     globus_sdk.TransferClient(app=app)
 
-    assert [str(s) for s in app.get_scope_requirements("transfer.api.globus.org")] == [
+    assert [str(s) for s in app.scope_requirements["transfer.api.globus.org"]] == [
         "urn:globus:auth:scope:transfer.api.globus.org:all"
     ]
 
@@ -48,7 +48,7 @@ def test_transfer_client_add_app_data_access_scope(app):
 
     collection_id = str(uuid.UUID(int=0))
     client.add_app_data_access_scope(collection_id)
-    str_list = [str(s) for s in app.get_scope_requirements("transfer.api.globus.org")]
+    str_list = [str(s) for s in app.scope_requirements["transfer.api.globus.org"]]
     expected = f"urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id}/data_access]"  # noqa: E501
     assert expected in str_list
 
@@ -62,7 +62,7 @@ def test_transfer_client_add_app_data_access_scope_chaining(app):
         .add_app_data_access_scope(collection_id_2)
     )
 
-    str_list = [str(s) for s in app.get_scope_requirements("transfer.api.globus.org")]
+    str_list = [str(s) for s in app.scope_requirements["transfer.api.globus.org"]]
     expected_1 = f"urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id_1}/data_access]"  # noqa: E501
     expected_2 = f"urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id_2}/data_access]"  # noqa: E501
     assert expected_1 in str_list
@@ -80,7 +80,7 @@ def test_transfer_client_add_app_data_access_scope_in_iterable(app):
     expected_2 = f"https://auth.globus.org/scopes/{collection_id_2}/data_access"
 
     transfer_dependencies = []
-    for scope in app.get_scope_requirements("transfer.api.globus.org"):
+    for scope in app.scope_requirements["transfer.api.globus.org"]:
         if scope.scope_string != globus_sdk.TransferClient.scopes.all:
             continue
         for dep in scope.dependencies:
@@ -106,7 +106,7 @@ def test_transfer_client_add_app_data_access_scope_catches_bad_uuid_in_iterable(
 def test_auth_client_default_scopes(app):
     globus_sdk.AuthClient(app=app)
 
-    str_list = [str(s) for s in app.get_scope_requirements("auth.globus.org")]
+    str_list = [str(s) for s in app.scope_requirements["auth.globus.org"]]
     assert "openid" in str_list
     assert "profile" in str_list
     assert "email" in str_list
@@ -115,7 +115,7 @@ def test_auth_client_default_scopes(app):
 def test_groups_client_default_scopes(app):
     globus_sdk.GroupsClient(app=app)
 
-    assert [str(s) for s in app.get_scope_requirements("groups.api.globus.org")] == [
+    assert [str(s) for s in app.scope_requirements["groups.api.globus.org"]] == [
         "urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships"
     ]
 
@@ -123,7 +123,7 @@ def test_groups_client_default_scopes(app):
 def test_search_client_default_scopes(app):
     globus_sdk.SearchClient(app=app)
 
-    assert [str(s) for s in app.get_scope_requirements("search.api.globus.org")] == [
+    assert [str(s) for s in app.scope_requirements["search.api.globus.org"]] == [
         "urn:globus:auth:scope:search.api.globus.org:search"
     ]
 
@@ -132,7 +132,7 @@ def test_timer_client_default_scopes(app):
     globus_sdk.TimersClient(app=app)
 
     timer_client_id = "524230d7-ea86-4a52-8312-86065a9e0417"
-    str_list = [str(s) for s in app.get_scope_requirements(timer_client_id)]
+    str_list = [str(s) for s in app.scope_requirements[timer_client_id]]
     assert str_list == [f"https://auth.globus.org/scopes/{timer_client_id}/timer"]
 
 
@@ -140,7 +140,7 @@ def test_flows_client_default_scopes(app):
     globus_sdk.FlowsClient(app=app)
 
     flows_client_id = "eec9b274-0c81-4334-bdc2-54e90e689b9a"
-    str_list = [str(s) for s in app.get_scope_requirements("flows.globus.org")]
+    str_list = [str(s) for s in app.scope_requirements["flows.globus.org"]]
     assert len(str_list) == 1
     assert str_list == [f"https://auth.globus.org/scopes/{flows_client_id}/all"]
 
@@ -148,7 +148,7 @@ def test_flows_client_default_scopes(app):
 def test_specific_flow_client_default_scopes(app):
     globus_sdk.SpecificFlowClient("flow_id", app=app)
 
-    assert [str(s) for s in app.get_scope_requirements("flow_id")] == [
+    assert [str(s) for s in app.scope_requirements["flow_id"]] == [
         "https://auth.globus.org/scopes/flow_id/flow_flow_id_user"
     ]
 
@@ -160,6 +160,6 @@ def test_gcs_client_default_scopes(app):
 
     globus_sdk.GCSClient(domain_name, app=app)
 
-    assert [str(s) for s in app.get_scope_requirements(endpoint_client_id)] == [
+    assert [str(s) for s in app.scope_requirements[endpoint_client_id]] == [
         f"urn:globus:auth:scope:{endpoint_client_id}:manage_collections"
     ]
diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py
index 8d673e94..40b31960 100644
--- a/tests/unit/test_base_client.py
+++ b/tests/unit/test_base_client.py
@@ -226,7 +226,7 @@ def test_app_integration(base_client_class):
     assert c.app_name == "SDK Test"
 
     # confirm default_required_scopes were automatically added
-    assert [str(s) for s in app.get_scope_requirements(c.resource_server)] == [
+    assert [str(s) for s in app.scope_requirements[c.resource_server]] == [
         TransferScopes.all
     ]
 
@@ -244,7 +244,7 @@ def test_app_scopes(base_client_class):
     c = base_client_class(app=app, app_scopes=[Scope("foo")])
 
     # confirm app_scopes were added and default_required_scopes were not
-    assert [str(s) for s in app.get_scope_requirements(c.resource_server)] == ["foo"]
+    assert [str(s) for s in app.scope_requirements[c.resource_server]] == ["foo"]
 
 
 def test_add_app_scope(base_client_class):
@@ -252,7 +252,7 @@ def test_add_app_scope(base_client_class):
     c = base_client_class(app=app)
 
     c.add_app_scope("foo")
-    str_list = [str(s) for s in app.get_scope_requirements(c.resource_server)]
+    str_list = [str(s) for s in app.scope_requirements[c.resource_server]]
     assert len(str_list) == 2
     assert TransferScopes.all in str_list
     assert "foo" in str_list
@@ -261,7 +261,7 @@ def test_add_app_scope(base_client_class):
 def test_add_app_scope_chaining(base_client_class):
     app = UserApp("SDK Test", client_id="client_id")
     c = base_client_class(app=app).add_app_scope("foo").add_app_scope("bar")
-    str_list = [str(s) for s in app.get_scope_requirements(c.resource_server)]
+    str_list = [str(s) for s in app.scope_requirements[c.resource_server]]
     assert len(str_list) == 3
     assert TransferScopes.all in str_list
     assert "foo" in str_list