From 79f6b84df31d2fcae1f23d9648ce4bd5f0b8c25f Mon Sep 17 00:00:00 2001 From: derek-globus <113056046+derek-globus@users.noreply.github.com> Date: Fri, 2 Aug 2024 13:19:07 -0500 Subject: [PATCH] Refactor LoginFlowManager interactions with GlobusApp (#1018) --- MANIFEST.in | 2 +- ...factor_login_flow_manager_interactions.rst | 29 +++++ .../experimental/globus_app/globus_app.py | 97 ++++++++++------- .../command_line_login_flow_manager.py | 101 ++++++++++++------ .../__init__.py | 5 + .../_local_server.py | 2 +- .../html_files/__init__.py | 0 .../html_files/local_server_landing_page.html | 0 .../local_server_login_flow_manager.py | 74 ++++++++----- .../login_flow_manager/login_flow_manager.py | 69 +++++++++++- tests/functional/test_login_manager.py | 29 +++-- .../globus_app/test_globus_app.py | 52 ++++++++- tests/unit/experimental/test_local_server.py | 2 +- 13 files changed, 344 insertions(+), 118 deletions(-) create mode 100644 changelog.d/20240730_020618_derek_refactor_login_flow_manager_interactions.rst create mode 100644 src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/__init__.py rename src/globus_sdk/experimental/login_flow_manager/{ => local_server_login_flow_manager}/_local_server.py (96%) rename src/globus_sdk/experimental/{ => login_flow_manager/local_server_login_flow_manager}/html_files/__init__.py (100%) rename src/globus_sdk/experimental/{ => login_flow_manager/local_server_login_flow_manager}/html_files/local_server_landing_page.html (100%) rename src/globus_sdk/experimental/login_flow_manager/{ => local_server_login_flow_manager}/local_server_login_flow_manager.py (71%) diff --git a/MANIFEST.in b/MANIFEST.in index ecfc0e820..9ce00ca21 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include src/globus_sdk/experimental/html_files/* \ No newline at end of file +include src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/html_files/* diff --git a/changelog.d/20240730_020618_derek_refactor_login_flow_manager_interactions.rst b/changelog.d/20240730_020618_derek_refactor_login_flow_manager_interactions.rst new file mode 100644 index 000000000..f7ccb37b9 --- /dev/null +++ b/changelog.d/20240730_020618_derek_refactor_login_flow_manager_interactions.rst @@ -0,0 +1,29 @@ + +Changed +~~~~~~~ + +- Changed the experimental ``GlobusApp`` class in the following ways (:pr:`NUMBER`): + + - ``LoginFlowManagers`` now insert ``GlobusApp.app_name`` into any native + client login flows as the ``prefill_named_grant``. + + - ``GlobusAppConfig`` now accepts a ``login_redirect_uri`` parameter to specify + the redirect URI for a login flow. + + - Invalid when used with a ``LocalServerLoginFlowManager``. + + - Defaults to ``"https://auth.globus.org/v2/web/auth-code"`` for native + client flows. Raises an error if not set for confidential ones. + + - ``UserApp`` now allows for the use of confidential client flows with the use of + either a ``LocalServerLoginFlowManager`` or a configured ``login_redirect_uri``. + + - ``GlobusAppConfig.login_flow_manager`` now accepts shorthand string references + ``"command-line"`` to use a ``CommandLineLoginFlowManager`` and + ``"local-server"`` to use a ``LocalServerLoginFlowManager``. + + - ``GlobusAppConfig.login_flow_manager`` also now accepts a + ``LoginFlowManagerProvider``, a class with a + ``for_globus_app(...) -> LoginFlowManager`` class method. + + diff --git a/src/globus_sdk/experimental/globus_app/globus_app.py b/src/globus_sdk/experimental/globus_app/globus_app.py index 5571572e1..22f5f9796 100644 --- a/src/globus_sdk/experimental/globus_app/globus_app.py +++ b/src/globus_sdk/experimental/globus_app/globus_app.py @@ -4,6 +4,7 @@ import dataclasses import os import sys +import typing as t from dataclasses import dataclass from globus_sdk import ( @@ -22,6 +23,7 @@ ) from globus_sdk.experimental.login_flow_manager import ( CommandLineLoginFlowManager, + LocalServerLoginFlowManager, LoginFlowManager, ) from globus_sdk.experimental.tokenstorage import JSONTokenStorage, TokenStorage @@ -37,9 +39,9 @@ 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 + from typing import Protocol, runtime_checkable def _default_filename(app_name: str, environment: str) -> str: @@ -68,6 +70,14 @@ def _default_filename(app_name: str, environment: str) -> str: return os.path.expanduser(f"~/.globus/app/{app_name}/{filename}") +@runtime_checkable +class LoginFlowManagerProvider(Protocol): + @classmethod + def for_globus_app( + cls, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig + ) -> LoginFlowManager: ... + + class TokenValidationErrorHandler(Protocol): def __call__(self, app: GlobusApp, error: TokenValidationError) -> None: ... @@ -88,21 +98,31 @@ def resolve_by_login_flow(app: GlobusApp, error: TokenValidationError) -> None: app.run_login_flow() +KnownLoginFlowManager = t.Literal["command-line", "local-server"] +KNOWN_LOGIN_FLOW_MANAGERS: dict[KnownLoginFlowManager, LoginFlowManagerProvider] = { + "command-line": CommandLineLoginFlowManager, + "local-server": LocalServerLoginFlowManager, +} + + @dataclass(frozen=True) class GlobusAppConfig: """ Various configuration options for controlling the behavior of a ``GlobusApp``. - :param login_flow_manager: an optional ``LoginFlowManager`` instance or class. - An instance will be used directly when driving app login flows. A class will - 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 login_flow_manager: An optional ``LoginFlowManager`` instance, or provider, + or identifier ("command-line" or "local-server"). + For a ``UserApp``, defaults to "command-line". + For a ``ClientApp``, this value is not supported. + :param login_redirect_uri: The redirect URI to use for login flows. + For a "local-server" login flow manager, this value is not supported. + For a native client, this value defaults to a globus-hosted helper page. + For a confidential client, this value is required. + :param request_refresh_tokens: If True, the ``GlobusApp`` will request refresh + tokens for long-lived access. :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 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 token validation error is encountered. The default behavior is to retry the login flow automatically. @@ -110,9 +130,12 @@ class GlobusAppConfig: predominately for internal use and can be ignored in most cases. """ - login_flow_manager: LoginFlowManager | type[LoginFlowManager] | None = None - token_storage: TokenStorage | None = None + login_flow_manager: ( + KnownLoginFlowManager | LoginFlowManagerProvider | LoginFlowManager | None + ) = None # noqa: E501 + login_redirect_uri: str | None = None request_refresh_tokens: bool = False + token_storage: TokenStorage | None = None token_validation_error_handler: TokenValidationErrorHandler | None = ( resolve_by_login_flow ) @@ -366,33 +389,35 @@ def __init__( config=config, ) - if client_secret or isinstance(self._login_client, ConfidentialAppAuthClient): - # UserApps need more information to be accepted to properly support - # Confidential Clients. Raise an error to avoid a confusing failure later. - raise GlobusSDKUsageError( - "UserApps don't currently support Confidential Clients" - ) + self._login_flow_manager = self._resolve_login_flow_manager( + app_name=self.app_name, + login_client=self._login_client, + config=config, + ) - # get or instantiate config's login_flow_manager - if self.config.login_flow_manager: - if isinstance(self.config.login_flow_manager, LoginFlowManager): - self._login_flow_manager = self.config.login_flow_manager - elif isinstance(self.config.login_flow_manager, type(LoginFlowManager)): - self._login_flow_manager = self.config.login_flow_manager( - self._login_client, - request_refresh_tokens=self.config.request_refresh_tokens, - ) - else: - raise TypeError( - "login_flow_manager must be a LoginFlowManager instance or class" - ) - # or make a default CommandLineLoginFlowManager + def _resolve_login_flow_manager( + self, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig + ) -> LoginFlowManager: + login_flow_manager = config.login_flow_manager + if isinstance(login_flow_manager, LoginFlowManager): + return login_flow_manager + + elif isinstance(login_flow_manager, LoginFlowManagerProvider): + provider = login_flow_manager + elif login_flow_manager is None: + provider = CommandLineLoginFlowManager + elif login_flow_manager in KNOWN_LOGIN_FLOW_MANAGERS: + provider = KNOWN_LOGIN_FLOW_MANAGERS[login_flow_manager] else: - self._login_flow_manager = CommandLineLoginFlowManager( - self._login_client, - request_refresh_tokens=self.config.request_refresh_tokens, + allowed_keys = ", ".join(repr(k) for k in KNOWN_LOGIN_FLOW_MANAGERS.keys()) + raise GlobusSDKUsageError( + f"Unsupported login_flow_manager value: {login_flow_manager!r}. " + f"Expected {allowed_keys}, a , or a " + f"." ) + return provider.for_globus_app(app_name, login_client, config) + def _initialize_login_client(self, client_secret: str | None) -> None: if self.client_id is None: raise GlobusSDKUsageError( @@ -479,8 +504,8 @@ def __init__( scope_requirements: dict[str, list[Scope]] | None = None, config: GlobusAppConfig = _DEFAULT_CONFIG, ): - if config and config.login_flow_manager is not None: - raise ValueError("a ClientApp cannot use a login_flow_manager") + if config.login_flow_manager is not None: + raise GlobusSDKUsageError("A ClientApp cannot use a login_flow_manager") if login_client and not isinstance(login_client, ConfidentialAppAuthClient): raise GlobusSDKUsageError( 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 f86145c4b..54c079049 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 @@ -1,10 +1,23 @@ -from globus_sdk import AuthLoginClient, OAuthTokenResponse +from __future__ import annotations + +import textwrap +import typing as t + +from globus_sdk import ( + AuthLoginClient, + ConfidentialAppAuthClient, + GlobusSDKUsageError, + OAuthTokenResponse, +) from globus_sdk.experimental.auth_requirements_error import ( GlobusAuthorizationParameters, ) from .login_flow_manager import LoginFlowManager +if t.TYPE_CHECKING: + from globus_sdk.experimental.globus_app import GlobusAppConfig + class CommandLineLoginFlowManager(LoginFlowManager): """ @@ -25,7 +38,9 @@ def __init__( self, login_client: AuthLoginClient, *, + 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:", ): @@ -35,15 +50,55 @@ def __init__( must either be a NativeAppAuthClient or a templated ConfidentialAppAuthClient, standard ConfidentialAppAuthClients cannot use the web auth-code flow. + :param redirect_uri: The redirect URI to use for the login flow. Defaults to + a globus-hosted helper web auth-code URI for NativeAppAuthClients. :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 - super().__init__(login_client, request_refresh_tokens=request_refresh_tokens) + + if redirect_uri is None: + # Confidential clients must always define their own custom redirect URI. + if isinstance(login_client, ConfidentialAppAuthClient): + msg = "Use of a Confidential client requires an explicit redirect_uri." + raise GlobusSDKUsageError(msg) + + # Native clients may infer the globus-provided helper page if omitted. + redirect_uri = login_client.base_url + "v2/web/auth-code" + self.redirect_uri = redirect_uri + + @classmethod + def for_globus_app( + cls, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig + ) -> CommandLineLoginFlowManager: + """ + Create a ``CommandLineLoginFlowManager`` for a given ``GlobusAppConfig``. + + :param app_name: The name of the app to use for prefilling the named grant in + native auth flows. + :param login_client: The ``AuthLoginClient`` to use to drive Globus Auth flows. + :param config: A ``GlobusAppConfig`` to configure the login flow. + :returns: A ``CommandLineLoginFlowManager`` instance. + :raises: GlobusSDKUsageError if a login_redirect_uri is not set on the config + but a ConfidentialAppAuthClient is used. + """ + return cls( + login_client, + redirect_uri=config.login_redirect_uri, + request_refresh_tokens=config.request_refresh_tokens, + native_prefill_named_grant=app_name, + ) def run_login_flow( self, @@ -55,37 +110,23 @@ def run_login_flow( :param auth_parameters: ``GlobusAuthorizationParameters`` passed through 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) - # type is ignored here as AuthLoginClient does not provide a signature for - # oauth2_start_flow since it has different positional arguments between - # NativeAppAuthClient and ConfidentialAppAuthClient - self.login_client.oauth2_start_flow( # type: ignore - redirect_uri=self.login_client.base_url + "v2/web/auth-code", - refresh_tokens=self.request_refresh_tokens, - requested_scopes=auth_parameters.required_scopes, - ) + return self.login_client.oauth2_exchange_code_for_tokens(auth_code) - # create authorization url and prompt user to follow it to login + def _print_and_prompt(self, authorize_url: str) -> str: + # Prompt the user to authenticate print( - "{0}\n{1}\n{2}\n{1}\n".format( - self.login_prompt, - "-" * len(self.login_prompt), - self.login_client.oauth2_get_authorize_url( - session_required_identities=( - auth_parameters.session_required_identities - ), - session_required_single_domain=( - auth_parameters.session_required_single_domain - ), - session_required_policies=auth_parameters.session_required_policies, - session_required_mfa=auth_parameters.session_required_mfa, - prompt=auth_parameters.prompt, # type: ignore - ), + textwrap.dedent( + f""" + {self.login_prompt} + {"-" * len(self.login_prompt)} + {authorize_url} + {"-" * len(self.login_prompt)} + """ ) ) - # ask user to copy and paste auth code - auth_code = input(f"{self.code_prompt}\n").strip() - - # get and return tokens - return self.login_client.oauth2_exchange_code_for_tokens(auth_code) + # Request they to enter the auth code + return input(f"{self.code_prompt}\n").strip() diff --git a/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/__init__.py b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/__init__.py new file mode 100644 index 000000000..99b78ba8e --- /dev/null +++ b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/__init__.py @@ -0,0 +1,5 @@ +from .local_server_login_flow_manager import LocalServerLoginFlowManager + +__all__ = [ + "LocalServerLoginFlowManager", +] diff --git a/src/globus_sdk/experimental/login_flow_manager/_local_server.py b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/_local_server.py similarity index 96% rename from src/globus_sdk/experimental/login_flow_manager/_local_server.py rename to src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/_local_server.py index 121ddfcfb..48ac5530f 100644 --- a/src/globus_sdk/experimental/login_flow_manager/_local_server.py +++ b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/_local_server.py @@ -22,7 +22,7 @@ else: # Python < 3.9 import importlib_resources -from globus_sdk.experimental import html_files +import globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager.html_files as html_files # noqa: E501 _IS_WINDOWS = os.name == "nt" diff --git a/src/globus_sdk/experimental/html_files/__init__.py b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/html_files/__init__.py similarity index 100% rename from src/globus_sdk/experimental/html_files/__init__.py rename to src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/html_files/__init__.py diff --git a/src/globus_sdk/experimental/html_files/local_server_landing_page.html b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/html_files/local_server_landing_page.html similarity index 100% rename from src/globus_sdk/experimental/html_files/local_server_landing_page.html rename to src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/html_files/local_server_landing_page.html diff --git a/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager.py b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/local_server_login_flow_manager.py similarity index 71% rename from src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager.py rename to src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/local_server_login_flow_manager.py index 2a0d0af7a..69ede7ae3 100644 --- a/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager.py +++ b/src/globus_sdk/experimental/login_flow_manager/local_server_login_flow_manager/local_server_login_flow_manager.py @@ -7,10 +7,13 @@ from contextlib import contextmanager from string import Template -from globus_sdk import AuthLoginClient, OAuthTokenResponse +from globus_sdk import AuthLoginClient, GlobusSDKUsageError, OAuthTokenResponse from globus_sdk.experimental.auth_requirements_error import ( GlobusAuthorizationParameters, ) +from globus_sdk.experimental.login_flow_manager.login_flow_manager import ( + LoginFlowManager, +) from ._local_server import ( DEFAULT_HTML_TEMPLATE, @@ -18,7 +21,9 @@ RedirectHandler, RedirectHTTPServer, ) -from .login_flow_manager import LoginFlowManager + +if t.TYPE_CHECKING: + from globus_sdk.experimental.globus_app import GlobusAppConfig # a list of text-only browsers which are not allowed for use because they don't work # with Globus Auth login flows and seize control of the terminal @@ -98,6 +103,7 @@ def __init__( login_client: AuthLoginClient, *, request_refresh_tokens: bool = False, + native_prefill_named_grant: str | None = None, server_address: tuple[str, int] = ("127.0.0.1", 0), html_template: Template = DEFAULT_HTML_TEMPLATE, ): @@ -108,14 +114,46 @@ def __init__( ConfidentialAppAuthClient, standard ConfidentialAppAuthClients cannot use the web auth-code flow. :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 html_template: Optional HTML Template to be populated with the values login_result and post_login_message and displayed to the user. :param server_address: Optional tuple of the form (host, port) to specify an address to run the local server at. """ + super().__init__( + login_client, + request_refresh_tokens=request_refresh_tokens, + native_prefill_named_grant=native_prefill_named_grant, + ) self.server_address = server_address self.html_template = html_template - super().__init__(login_client, request_refresh_tokens=request_refresh_tokens) + + @classmethod + def for_globus_app( + cls, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig + ) -> LocalServerLoginFlowManager: + """ + Create a ``LocalServerLoginFlowManager`` for a given ``GlobusAppConfig``. + + :param app_name: The name of the app to use for prefilling the named grant,. + :param login_client: The ``AuthLoginClient`` to use to drive Globus Auth flows. + :param config: A ``GlobusAppConfig`` to configure the login flow. + :returns: A ``LocalServerLoginFlowManager`` instance. + :raises: GlobusSDKUsageError if a custom login_redirect_uri is defined in + the config. + """ + if config.login_redirect_uri: + # A "local server" relies on the user being redirected back to the server + # running on the local machine, so it can't use a custom redirect URI. + msg = "Cannot define a custom redirect_uri for LocalServerLoginFlowManager." + raise GlobusSDKUsageError(msg) + + return cls( + login_client, + request_refresh_tokens=config.request_refresh_tokens, + native_prefill_named_grant=app_name, + ) def run_login_flow( self, @@ -130,44 +168,26 @@ def run_login_flow( """ _check_remote_session() - with self.start_local_server() as server: + with self.background_local_server() as server: host, port = server.socket.getsockname() redirect_uri = f"http://{host}:{port}" - # type is ignored here as AuthLoginClient does not provide a signature for - # oauth2_start_flow since it has different positional arguments between - # NativeAppAuthClient and ConfidentialAppAuthClient - self.login_client.oauth2_start_flow( # type: ignore - redirect_uri=redirect_uri, - refresh_tokens=self.request_refresh_tokens, - requested_scopes=auth_parameters.required_scopes, - ) - # open authorize url in web-browser for user to authenticate - url = self.login_client.oauth2_get_authorize_url( - session_required_identities=auth_parameters.session_required_identities, - session_required_single_domain=( - auth_parameters.session_required_single_domain - ), - session_required_policies=auth_parameters.session_required_policies, - session_required_mfa=auth_parameters.session_required_mfa, - prompt=auth_parameters.prompt, # type: ignore - ) - _open_webbrowser(url) + authorize_url = self._get_authorize_url(auth_parameters, redirect_uri) + _open_webbrowser(authorize_url) # get auth code from server auth_code = server.wait_for_code() if isinstance(auth_code, BaseException): - raise LocalServerError( - f"Authorization failed with unexpected error:\n{auth_code}" - ) + msg = f"Authorization failed with unexpected error:\n{auth_code}." + raise LocalServerError(msg) # get and return tokens return self.login_client.oauth2_exchange_code_for_tokens(auth_code) @contextmanager - def start_local_server(self) -> t.Iterator[RedirectHTTPServer]: + def background_local_server(self) -> t.Iterator[RedirectHTTPServer]: """ Starts a RedirectHTTPServer in a thread as a context manager. """ diff --git a/src/globus_sdk/experimental/login_flow_manager/login_flow_manager.py b/src/globus_sdk/experimental/login_flow_manager/login_flow_manager.py index ecdfa209b..386960dcb 100644 --- a/src/globus_sdk/experimental/login_flow_manager/login_flow_manager.py +++ b/src/globus_sdk/experimental/login_flow_manager/login_flow_manager.py @@ -1,6 +1,14 @@ +from __future__ import annotations + import abc -from globus_sdk import AuthLoginClient, OAuthTokenResponse +from globus_sdk import ( + AuthLoginClient, + ConfidentialAppAuthClient, + GlobusSDKUsageError, + NativeAppAuthClient, + OAuthTokenResponse, +) from globus_sdk.experimental.auth_requirements_error import ( GlobusAuthorizationParameters, ) @@ -18,12 +26,69 @@ def __init__( login_client: AuthLoginClient, *, request_refresh_tokens: bool = False, + native_prefill_named_grant: str | None = None, ): + """ + :param login_client: The client to use for login flows. + :param request_refresh_tokens: Control whether refresh tokens will be requested. + :param native_prefill_named_grant: The name of a prefill in a Native App login + flow. This value will be ignored if the login_client is not a + NativeAppAuthClient. + """ + if not isinstance(login_client, NativeAppAuthClient) and not isinstance( + login_client, ConfidentialAppAuthClient + ): + raise GlobusSDKUsageError( + f"{type(self).__name__} requires a NativeAppAuthClient or " + f"ConfidentialAppAuthClient, but got a {type(login_client).__name__}." + ) + self.login_client = login_client self.request_refresh_tokens = request_refresh_tokens + self.native_prefill_named_grant = native_prefill_named_grant + + def _get_authorize_url( + self, auth_parameters: GlobusAuthorizationParameters, redirect_uri: str + ) -> str: """ - :param request_refresh_tokens: Control whether refresh tokens will be requested. + Utility method to provide a simpler interface for subclasses to start an + authorization flow and get an authorization URL. + """ + self._oauth2_start_flow(auth_parameters, redirect_uri) + + session_required_single_domain = auth_parameters.session_required_single_domain + return self.login_client.oauth2_get_authorize_url( + session_required_identities=auth_parameters.session_required_identities, + session_required_single_domain=session_required_single_domain, + session_required_policies=auth_parameters.session_required_policies, + session_required_mfa=auth_parameters.session_required_mfa, + prompt=auth_parameters.prompt, # type: ignore + ) + + def _oauth2_start_flow( + self, auth_parameters: GlobusAuthorizationParameters, redirect_uri: str + ) -> None: + """ + Start an authorization flow with the class's login_client, returning an + authorization URL to direct a user to. """ + login_client = self.login_client + requested_scopes = auth_parameters.required_scopes + # Native and Confidential App clients have different signatures for this method, + # so they must be type checked & called independently. + if isinstance(login_client, NativeAppAuthClient): + login_client.oauth2_start_flow( + requested_scopes, + redirect_uri=redirect_uri, + refresh_tokens=self.request_refresh_tokens, + prefill_named_grant=self.native_prefill_named_grant, + ) + elif isinstance(login_client, ConfidentialAppAuthClient): + login_client.oauth2_start_flow( + redirect_uri, + requested_scopes, + refresh_tokens=self.request_refresh_tokens, + ) @abc.abstractmethod def run_login_flow( diff --git a/tests/functional/test_login_manager.py b/tests/functional/test_login_manager.py index 110dfb5bb..ebb68cb4d 100644 --- a/tests/functional/test_login_manager.py +++ b/tests/functional/test_login_manager.py @@ -58,8 +58,11 @@ def test_command_line_login_flower_manager_confidential(monkeypatch, capsys): ) load_response(login_client.oauth2_exchange_code_for_tokens) monkeypatch.setattr("builtins.input", _mock_input) + redirect_uri = "https://example.com/callback" - login_flow_manager = CommandLineLoginFlowManager(login_client) + login_flow_manager = CommandLineLoginFlowManager( + login_client, redirect_uri=redirect_uri + ) auth_params = GlobusAuthorizationParameters( required_scopes=["urn:globus:auth:scope:transfer.api.globus.org:all"], session_required_single_domain=["org.edu"], @@ -93,14 +96,14 @@ def wait_for_code(self): return "auth_code" -@patch( - "globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager._open_webbrowser", # noqa E501 - new=lambda url: None, -) -@patch( - "globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager.RedirectHTTPServer", # noqa E501 - new=MockRedirectServer, +_LOCAL_SERVER_MODULE = ( + "globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager." + "local_server_login_flow_manager" ) + + +@patch(f"{_LOCAL_SERVER_MODULE}._open_webbrowser", new=lambda url: None) +@patch(f"{_LOCAL_SERVER_MODULE}.RedirectHTTPServer", new=MockRedirectServer) def test_local_server_login_flower_manager_native(): """ test LocalServerLoginManager with a NativeAppAuthClient @@ -120,14 +123,8 @@ def test_local_server_login_flower_manager_native(): ) -@patch( - "globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager._open_webbrowser", # noqa E501 - new=lambda url: None, -) -@patch( - "globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager.RedirectHTTPServer", # noqa E501 - new=MockRedirectServer, -) +@patch(f"{_LOCAL_SERVER_MODULE}._open_webbrowser", new=lambda url: None) +@patch(f"{_LOCAL_SERVER_MODULE}.RedirectHTTPServer", new=MockRedirectServer) def test_local_server_login_flower_manager_confidential(): """ test LocalServerLoginManager 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 ccb3d4c9f..b1d773da3 100644 --- a/tests/unit/experimental/globus_app/test_globus_app.py +++ b/tests/unit/experimental/globus_app/test_globus_app.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import time from unittest import mock @@ -6,6 +8,7 @@ from globus_sdk import ( AccessTokenAuthorizer, + AuthLoginClient, ClientCredentialsAuthorizer, ConfidentialAppAuthClient, NativeAppAuthClient, @@ -26,6 +29,7 @@ ) from globus_sdk.experimental.login_flow_manager import ( CommandLineLoginFlowManager, + LocalServerLoginFlowManager, LoginFlowManager, ) from globus_sdk.experimental.tokenstorage import ( @@ -72,7 +76,12 @@ def test_user_app_native(): def test_user_app_login_client(): - mock_client = mock.Mock(environment="production") + mock_client = mock.Mock( + spec=NativeAppAuthClient, + client_id="mock-client_id", + base_url="https://auth.globus.org", + environment="production", + ) user_app = UserApp("test-app", login_client=mock_client) assert user_app.app_name == "test-app" @@ -130,11 +139,46 @@ def test_user_app_creates_consent_client(): assert user_app._validating_token_storage._consent_client is not None -@pytest.mark.xfail(reason="UserApp does not yet support confidential clients") +class MockLoginFlowManager(LoginFlowManager): + def __init__(self, login_client: AuthLoginClient | None = None): + login_client = login_client or mock.Mock(spec=NativeAppAuthClient) + super().__init__(login_client) + + @classmethod + def for_globus_app( + cls, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig + ) -> MockLoginFlowManager: + return cls(login_client) + + def run_login_flow(self, auth_parameters: GlobusAuthorizationParameters): + return mock.Mock() + + +@pytest.mark.parametrize( + "value,login_flow_manager_class", + ( + (None, CommandLineLoginFlowManager), + ("command-line", CommandLineLoginFlowManager), + ("local-server", LocalServerLoginFlowManager), + (MockLoginFlowManager(), MockLoginFlowManager), + (MockLoginFlowManager, MockLoginFlowManager), + ), +) +def test_user_app_login_flow_manager_configuration(value, login_flow_manager_class): + client_id = "mock_client_id" + config = GlobusAppConfig(login_flow_manager=value) + user_app = UserApp("test-app", client_id=client_id, config=config) + + assert isinstance(user_app._login_flow_manager, login_flow_manager_class) + + def test_user_app_templated(): client_id = "mock_client_id" client_secret = "mock_client_secret" - user_app = UserApp("test-app", client_id=client_id, client_secret=client_secret) + config = GlobusAppConfig(login_redirect_uri="https://example.com") + user_app = UserApp( + "test-app", client_id=client_id, client_secret=client_secret, config=config + ) assert user_app.app_name == "test-app" assert isinstance(user_app._login_client, ConfidentialAppAuthClient) @@ -277,7 +321,7 @@ class RaisingLoginFlowManagerCounter(LoginFlowManager): """ def __init__(self): - super().__init__(None) + super().__init__(mock.Mock(spec=NativeAppAuthClient)) self.counter = 0 def run_login_flow( diff --git a/tests/unit/experimental/test_local_server.py b/tests/unit/experimental/test_local_server.py index 48fe9177d..6596437a5 100644 --- a/tests/unit/experimental/test_local_server.py +++ b/tests/unit/experimental/test_local_server.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk.experimental.login_flow_manager._local_server import ( +from globus_sdk.experimental.login_flow_manager.local_server_login_flow_manager._local_server import ( # noqa: E501 DEFAULT_HTML_TEMPLATE, LocalServerError, RedirectHandler,