Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Move login flows #1057

Merged
merged 6 commits into from
Sep 20, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add LoginFlowManagers to docs
derek-globus committed Sep 19, 2024
commit 2cc16e3afa1e366466cd4a85c71fea5bbd5e4bbc
1 change: 1 addition & 0 deletions docs/authorization/index.rst
Original file line number Diff line number Diff line change
@@ -8,4 +8,5 @@ Components of the Globus SDK which handle application authorization.

globus_authorizers
scopes_and_consents/index
login_flows
gare
100 changes: 100 additions & 0 deletions docs/authorization/login_flows.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@

Login Flow Managers
===================

.. currentmodule:: globus_sdk.login_flows

This page provides references for the LoginFlowManager abstract class and some concrete
implementations.

A login flow manager is a class responsible for driving a user through a login flow,
with the ultimate goal of obtaining tokens. The tokens are required to make
requests against any Globus services.

Interface
---------

.. autoclass:: LoginFlowManager
:members:

Command Line
------------

As the name might suggest, a CommandLineLoginFlowManager drives user logins through
the command line (stdin/stdout). When run, the manager will print a URL to the console
then prompt a user to navigate to that url and enter the resulting auth code
back into the terminal.

Example Code:

.. code-block:: pycon

>>> from globus_sdk import NativeAppAuthClient
>>> from globus_sdk.scopes import TransferScopes
>>> from globus_sdk.login_flows import CommandLineLoginFlowManager

>>> login_client = NativeAppAuthClient(client_id=client_id)
>>> manager = CommandLineLoginFlowManager(login_client)
>>>
>>> token_response = manager.run_login_flow(
... GlobusAuthorizationParameters(required_scopes=[TransferScopes.all])
... )
Please authenticate with Globus here:
-------------------------------------
https://auth.globus.org/v2/oauth2/authorize?cli...truncated...
-------------------------------------

Enter the resulting Authorization Code here:

.. autoclass:: CommandLineLoginFlowManager
:members:
:member-order: bysource
:show-inheritance:

Local Server
------------

A LocalServerLoginFlowManager drives more automated but less portable login flows
compared with its command line counterpart. When run, rather than printing the
authorization URL, the manager will open it in the user's default browser. Alongside
this, the manager will start a local web server to receive the auth code upon completion
of the login flow.

This provides a more user-friendly login experience as there is no manually copy/pasting
of links and codes but also requires that the python process is running in an
environment with access to a supported browser. This flow is not suitable for headless
environments (e.g., while ssh-ed into a cluster node).

.. warning::

Globus Auth requires that redirect URIs, including the local server used
here, be pre-registered with the client in use.

Before using this flow, navigate to the
`Globus Developers Pane <https://auth.globus.org/v2/web/developers>`_ and ensure
that `https://localhost` is listed as an allowed "Redirect URL" for your client.


Example Usage:

.. code-block:: pycon

>>> from globus_sdk import NativeAppAuthClient
>>> from globus_sdk.scopes import TransferScopes
>>> from globus_sdk.login_flows import LocalServerLoginFlowManager

>>> login_client = NativeAppAuthClient(client_id=client_id)
>>> manager = LocalServerLoginFlowManager(login_client)
>>>
>>> token_response = manager.run_login_flow(
... GlobusAuthorizationParameters(required_scopes=[TransferScopes.all])
... )

.. autoclass:: LocalServerLoginFlowManager
:members:
:member-order: bysource
:show-inheritance:

.. autoexception:: LocalServerLoginError

.. autoexception:: LocalServerEnvironmentalLoginError
6 changes: 4 additions & 2 deletions src/globus_sdk/login_flows/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from .command_line_login_flow_manager import CommandLineLoginFlowManager
from .local_server_login_flow_manager import (
LocalServerError,
LocalServerEnvironmentalLoginError,
LocalServerLoginError,
LocalServerLoginFlowManager,
)
from .login_flow_manager import LoginFlowManager

__all__ = [
"CommandLineLoginFlowManager",
"LocalServerError",
"LocalServerLoginError",
"LocalServerEnvironmentalLoginError",
"LocalServerLoginFlowManager",
"LoginFlowManager",
]
63 changes: 31 additions & 32 deletions src/globus_sdk/login_flows/command_line_login_flow_manager.py
Original file line number Diff line number Diff line change
@@ -19,17 +19,22 @@

class CommandLineLoginFlowManager(LoginFlowManager):
"""
A ``CommandLineLoginFlowManager`` is a ``LoginFlowManager`` that uses the command
line for interacting with the user during its interactive login flows.

Example usage:

>>> login_client = globus_sdk.NativeAppAuthClient(...)
>>> login_flow_manager = CommandLineLoginFlowManager(login_client)
>>> scopes = [globus_sdk.scopes.TransferScopes.all]
>>> auth_params = GlobusAuthorizationParameters(required_scopes=scopes)
>>> tokens = login_flow_manager.run_login_flow(auth_params)

A login flow manager which drives authorization-code token grants through the
command line.

:param AuthLoginClient login_client: The client that will be making Globus
Auth API calls required for a login flow.

.. note::
If this client is a :class:`globus_sdk.ConfidentialAppAuthClient`, an
explicit `redirect_uri` param is required.

:param str redirect_uri: The redirect URI to use for the login flow. When the
`login_client` is a native client, this defaults to a globus-hosted URI.
:param bool request_refresh_tokens: A signal of whether refresh tokens are expected
to be requested, in addition to access tokens.
:param str native_prefill_named_grant: A string to prefill in a Native App login
flow. This value is only used if the `login_client` is a native client.
"""

def __init__(
@@ -40,18 +45,6 @@ def __init__(
request_refresh_tokens: bool = False,
native_prefill_named_grant: str | None = None,
) -> None:
"""
:param login_client: The ``AuthLoginClient`` that will be making the Globus
Auth API calls needed for the authentication flow. Note that this
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.
"""
super().__init__(
login_client,
request_refresh_tokens=request_refresh_tokens,
@@ -73,15 +66,13 @@ 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.
Creates a ``CommandLineLoginFlowManager`` for use in a GlobusApp.

:param app_name: The name of the app. Will be prefilled in native auth flows.
:param login_client: A client used to make Globus Auth API calls.
:param config: A GlobusApp-bounded object used to configure login flow manager.
:raises: GlobusSDKUsageError if login_redirect_uri is not set on the config
but a ConfidentialAppAuthClient is supplied.
"""
return cls(
login_client,
@@ -110,6 +101,9 @@ def print_authorize_url(self, authorize_url: str) -> None:
"""
Prompt the user to authenticate using the provided ``authorize_url``.

This method is publicly exposed to allow for simpler customization through
subclassing and overriding.

:param authorize_url: The URL at which the user will login and consent to
application accesses.
"""
@@ -128,6 +122,11 @@ def print_authorize_url(self, authorize_url: str) -> None:
def prompt_for_code(self) -> str:
"""
Prompt the user to enter an authorization code.

This method is publicly exposed to allow for simpler customization through
subclassing and overriding.

:returns str: The authorization code entered by the user.
"""
code_prompt = "Enter the resulting Authorization Code here: "
return input(code_prompt).strip()
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from ._local_server import LocalServerError
from .errors import LocalServerEnvironmentalLoginError, LocalServerLoginError
from .local_server_login_flow_manager import LocalServerLoginFlowManager

__all__ = [
"LocalServerError",
"LocalServerLoginError",
"LocalServerEnvironmentalLoginError",
"LocalServerLoginFlowManager",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class LocalServerLoginError(Exception):
"""An error raised during a LocalServerLoginFlowManager's run."""


class LocalServerEnvironmentalLoginError(LocalServerLoginError):
"""
Error raised when a local server login flow fails to start due to incompatible
environment conditions (e.g., a remote session or text-only browser).
"""
Original file line number Diff line number Diff line change
@@ -13,10 +13,13 @@
import sys
import time
import typing as t
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer
from string import Template
from urllib.parse import parse_qsl, urlparse

from .errors import LocalServerLoginError

if sys.version_info >= (3, 9):
import importlib.resources as importlib_resources
else: # Python < 3.9
@@ -33,19 +36,50 @@
)


class LocalServerError(Exception):
class RedirectHandler(BaseHTTPRequestHandler):
"""
Error class for errors raised by the local server when using a
LocalServerLoginFlowManager
BaseHTTPRequestHandler to be used by RedirectHTTPServer.
Displays the RedirectHTTPServer's html_template and parses auth_code out of
the redirect url.
"""

server: RedirectHTTPServer

def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()

html_template = self.server.html_template
query_params = dict(parse_qsl(urlparse(self.path).query))
code = query_params.get("code")
if code:
self.wfile.write(
html_template.substitute(
post_login_message="", login_result="Login successful"
).encode("utf-8")
)
self.server.return_code(code)
else:
msg = query_params.get("error_description", query_params.get("error"))

self.wfile.write(
html_template.substitute(
post_login_message=msg, login_result="Login failed"
).encode("utf-8")
)

self.server.return_code(LocalServerLoginError(msg))


class RedirectHTTPServer(HTTPServer):
"""
HTTPServer that accepts an html_template to be displayed to the user
An HTTPServer which accepts a html_template to be displayed to the user
and uses a Queue to receive an auth_code from its RequestHandler.
"""

WAIT_TIMEOUT = timedelta(minutes=5)

def __init__(
self,
server_address: tuple[str, int],
@@ -72,7 +106,7 @@ def return_code(self, code: str | BaseException) -> None:
def wait_for_code(self) -> str | BaseException:
# Windows needs special handling as blocking prevents ctrl-c interrupts
if _IS_WINDOWS:
deadline = time.time() + 3600
deadline = time.time() + self.WAIT_TIMEOUT.total_seconds()
while time.time() < deadline:
try:
return self._auth_code_queue.get()
@@ -83,40 +117,4 @@ def wait_for_code(self) -> str | BaseException:
return self._auth_code_queue.get(block=True, timeout=3600)
except queue.Empty:
pass
raise LocalServerError("Login timed out. Please try again.")


class RedirectHandler(BaseHTTPRequestHandler):
"""
BaseHTTPRequestHandler to be used by RedirectHTTPServer.
Displays the RedirectHTTPServer's html_template and parses auth_code out of
the redirect url.
"""

server: RedirectHTTPServer

def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()

html_template = self.server.html_template
query_params = dict(parse_qsl(urlparse(self.path).query))
code = query_params.get("code")
if code:
self.wfile.write(
html_template.substitute(
post_login_message="", login_result="Login successful"
).encode("utf-8")
)
self.server.return_code(code)
else:
msg = query_params.get("error_description", query_params.get("error"))

self.wfile.write(
html_template.substitute(
post_login_message=msg, login_result="Login failed"
).encode("utf-8")
)

self.server.return_code(LocalServerError(msg))
raise LocalServerLoginError("Login timed out. Please try again.")
Original file line number Diff line number Diff line change
@@ -11,9 +11,10 @@
from globus_sdk.gare import GlobusAuthorizationParameters
from globus_sdk.login_flows.login_flow_manager import LoginFlowManager

from ._local_server import (
from .errors import LocalServerEnvironmentalLoginError
from .local_server import (
DEFAULT_HTML_TEMPLATE,
LocalServerError,
LocalServerLoginError,
RedirectHandler,
RedirectHTTPServer,
)
@@ -29,22 +30,13 @@
BROWSER_BLACKLIST = ["lynx", "www-browser", "links", "elinks", "w3m"]


class LocalServerLoginFlowError(BaseException):
"""
Error class for errors raised due to inability to run a local server login flow
due to known failure conditions such as remote sessions or text-only browsers.
Catching this should be sufficient to detect cases where one should fallback
to a CommandLineLoginFlowManager
"""


def _check_remote_session() -> None:
"""
Try to check if this is being run during a remote session, if so
raise LocalServerLoginFlowError
"""
if bool(os.environ.get("SSH_TTY", os.environ.get("SSH_CONNECTION"))):
raise LocalServerLoginFlowError(
raise LocalServerEnvironmentalLoginError(
"Cannot use LocalServerLoginFlowManager in a remote session"
)

@@ -64,34 +56,42 @@ def _open_webbrowser(url: str) -> None:
# https://github.com/python/cpython/issues/82828
browser_name = browser._name
else:
raise LocalServerLoginFlowError("Unable to determine local browser name.")
raise LocalServerEnvironmentalLoginError(
"Unable to determine local browser name."
)

if browser_name in BROWSER_BLACKLIST:
raise LocalServerLoginFlowError(
raise LocalServerEnvironmentalLoginError(
"Cannot use LocalServerLoginFlowManager with "
f"text-only browser '{browser_name}'"
)

if not browser.open(url, new=1):
raise LocalServerLoginFlowError(f"Failed to open browser '{browser_name}'")
raise LocalServerEnvironmentalLoginError(
f"Failed to open browser '{browser_name}'"
)
except webbrowser.Error as exc:
raise LocalServerLoginFlowError("Failed to open browser") from exc
raise LocalServerEnvironmentalLoginError("Failed to open browser") from exc


class LocalServerLoginFlowManager(LoginFlowManager):
"""
A ``LocalServerLoginFlowManager`` is a ``LoginFlowManager`` that uses a locally
hosted server to automatically receive the auth code from Globus auth after the
user has authenticated.
Example usage:
>>> login_client = globus_sdk.NativeAppAuthClient(...)
>>> login_flow_manager = LocalServerLoginFlowManager(login_client)
>>> scopes = [globus_sdk.scopes.TransferScopes.all]
>>> auth_params = GlobusAuthorizationParameters(required_scopes=scopes)
>>> tokens = login_flow_manager.run_login_flow(auth_params)
A login flow manager which uses a locally hosted server to drive authentication-code
token grants. The local server is used as the authorization redirect URI,
automatically receiving the auth code from Globus Auth after authentication/consent.
:param AuthLoginClient login_client: The client that will be making Globus
Auth API calls required for a login flow.
:param bool request_refresh_tokens: A signal of whether refresh tokens are expected
to be requested, in addition to access tokens.
:param str native_prefill_named_grant: A string to prefill in a Native App login
flow. This value is only used if the `login_client` is a native client.
:param Template html_template: Optional HTML Template to be populated with the
values login_result and post_login_message and displayed to the user. A simple
default is supplied if not provided which informs the user that the login was
successful and that they may close the browser window.
:param tuple[str, int] server_address: Optional tuple of the form (host, port) to
specify an address to run the local server at. Defaults to ("127.0.0.1", 0).
"""

def __init__(
@@ -103,20 +103,6 @@ def __init__(
server_address: tuple[str, int] = ("127.0.0.1", 0),
html_template: Template = DEFAULT_HTML_TEMPLATE,
) -> None:
"""
:param login_client: The ``AuthLoginClient`` that will be making the Globus
Auth API calls needed for the authentication flow. Note that this
must either be a NativeAppAuthClient or a templated
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,
@@ -130,12 +116,11 @@ def for_globus_app(
cls, app_name: str, login_client: AuthLoginClient, config: GlobusAppConfig
) -> LocalServerLoginFlowManager:
"""
Create a ``LocalServerLoginFlowManager`` for a given ``GlobusAppConfig``.
Create a ``LocalServerLoginFlowManager`` for use in a GlobusApp.
: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.
:param app_name: The name of the app. Will be prefilled in native auth flows.
:param login_client: A client used to make Globus Auth API calls.
:param config: A GlobusApp-bounded object used to configure login flow manager.
:raises: GlobusSDKUsageError if a custom login_redirect_uri is defined in
the config.
"""
@@ -161,6 +146,11 @@ def run_login_flow(
:param auth_parameters: ``GlobusAuthorizationParameters`` passed through
to the authentication flow to control how the user will authenticate.
:raises LocalServerEnvironmentalLoginError: If the local server login flow
cannot be run due to known failure conditions such as remote sessions or
text-only browsers.
:raises LocalServerLoginError: If the local server login flow fails for any
reason.
"""
_check_remote_session()

@@ -177,7 +167,7 @@ def run_login_flow(

if isinstance(auth_code, BaseException):
msg = f"Authorization failed with unexpected error:\n{auth_code}."
raise LocalServerError(msg)
raise LocalServerLoginError(msg)

# get and return tokens
return self.login_client.oauth2_exchange_code_for_tokens(auth_code)
24 changes: 13 additions & 11 deletions src/globus_sdk/login_flows/login_flow_manager.py
Original file line number Diff line number Diff line change
@@ -14,9 +14,18 @@

class LoginFlowManager(metaclass=abc.ABCMeta):
"""
A ``LoginFlowManager`` is an abstract superclass for subclasses that manage
interactive login flows with a user in order to authenticate with Globus Auth
and obtain tokens.
The abstract base class defining the interface for managing login flows.
Implementing classes must supply a ``run_login_flow`` method.
Utility functions starting an authorization-code grant flow and getting an
authorization-code URL are provided on the class.
:ivar AuthLoginClient login_client: A native or confidential login client to be
used by the login flow manager.
:ivar bool request_refresh_tokens: A signal of whether refresh tokens are expected
to be requested, in addition to access tokens.
:ivar str native_prefill_named_grant: A string to prefill in a Native App login
flow. This value is only to be used if the `login_client` is a native client.
"""

def __init__(
@@ -26,13 +35,6 @@ def __init__(
request_refresh_tokens: bool = False,
native_prefill_named_grant: str | None = 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
):
@@ -94,7 +96,7 @@ def run_login_flow(
auth_parameters: GlobusAuthorizationParameters,
) -> OAuthTokenResponse:
"""
Run an interactive login flow to get tokens for the user.
Run a login flow to get tokens for a user.
:param auth_parameters: ``GlobusAuthorizationParameters`` passed through
to the authentication flow to control how the user will authenticate.
10 changes: 5 additions & 5 deletions tests/unit/login_flows/test_local_server.py
Original file line number Diff line number Diff line change
@@ -2,8 +2,8 @@

import pytest

from globus_sdk.login_flows import LocalServerError
from globus_sdk.login_flows.local_server_login_flow_manager._local_server import ( # noqa: E501
from globus_sdk.login_flows import LocalServerLoginError
from globus_sdk.login_flows.local_server_login_flow_manager.local_server import ( # noqa: E501
DEFAULT_HTML_TEMPLATE,
RedirectHandler,
RedirectHTTPServer,
@@ -21,8 +21,8 @@ def test_default_html_template_contains_expected_text():
"url,expected_result",
[
(b"localhost?code=abc123", "abc123"),
(b"localhost?error=bad_login", LocalServerError("bad_login")),
(b"localhost", LocalServerError(None)),
(b"localhost?error=bad_login", LocalServerLoginError("bad_login")),
(b"localhost", LocalServerLoginError(None)),
],
)
def test_server(url, expected_result):
@@ -54,7 +54,7 @@ def test_server(url, expected_result):
result = server.wait_for_code()
if isinstance(result, str):
assert result == expected_result
elif isinstance(result, LocalServerError):
elif isinstance(result, LocalServerLoginError):
assert result.args == expected_result.args
else:
raise AssertionError("unexpected result type")