From a1cc22d6f881a2420f20f6fb0a33838fc2907bdb Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Fri, 12 Jan 2024 12:06:32 -0800 Subject: [PATCH 1/4] dev: Remove use of typing_extensions.Literal and .Protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These come with the standard typing library as of Python 3.8.¹ ¹ --- nextstrain/cli/types.py | 5 ++--- nextstrain/cli/util.py | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/nextstrain/cli/types.py b/nextstrain/cli/types.py index a6df8229..578f60b6 100644 --- a/nextstrain/cli/types.py +++ b/nextstrain/cli/types.py @@ -7,10 +7,9 @@ import sys import urllib.parse from pathlib import Path -from typing import Any, Iterable, List, Mapping, Optional, Tuple, Union -# TODO: Use typing.Protocol once Python 3.8 is the minimum supported version. +from typing import Any, Iterable, List, Mapping, Optional, Protocol, Tuple, Union # TODO: Use typing.TypeAlias once Python 3.10 is the minimum supported version. -from typing_extensions import Protocol, TypeAlias +from typing_extensions import TypeAlias from .volume import NamedVolume # Re-export EllipsisType so we can paper over its absence from older Pythons diff --git a/nextstrain/cli/util.py b/nextstrain/cli/util.py index 14efd8ac..6bbf6ea9 100644 --- a/nextstrain/cli/util.py +++ b/nextstrain/cli/util.py @@ -7,9 +7,7 @@ import sys from functools import partial from importlib.metadata import distribution as distribution_info, PackageNotFoundError -from typing import Any, Callable, Iterable, Mapping, List, Optional, Sequence, Tuple, Union, overload -# TODO: Use typing.Literal once Python 3.8 is the minimum supported version. -from typing_extensions import Literal +from typing import Any, Callable, Iterable, Literal, Mapping, List, Optional, Sequence, Tuple, Union, overload from packaging.version import parse as parse_version from pathlib import Path from shlex import quote as shquote From dc957d5eb91e5fd98072485f5a268ed003ced8fa Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Fri, 12 Jan 2024 12:20:54 -0800 Subject: [PATCH 2/4] dev: Silence Mypy's unfounded objections to an import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mypy 1.8.0 and some earlier versions complain about these two imports: $ mypy -p nextstrain.cli nextstrain/cli/runner/singularity.py:98: error: Name "docker" already defined (by an import) [no-redef] nextstrain/cli/runner/aws_batch/__init__.py:94: error: Name "docker" already defined (by an import) [no-redef] Found 2 errors in 2 files (checked 61 source files) …but there is no other definition of "docker" in those files! Mypy's got something wrong. These seem like new complaints as the code has existed for a while. Pyright does not complain. Adding reveal_type(docker) before the imports to see what each thinks produces: note: Revealed type is "types.ModuleType" from Mypy and information: Type of "docker" is "Unbound" from Pyright. --- nextstrain/cli/runner/aws_batch/__init__.py | 2 +- nextstrain/cli/runner/singularity.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nextstrain/cli/runner/aws_batch/__init__.py b/nextstrain/cli/runner/aws_batch/__init__.py index 7bcc5bc9..47188264 100644 --- a/nextstrain/cli/runner/aws_batch/__init__.py +++ b/nextstrain/cli/runner/aws_batch/__init__.py @@ -90,7 +90,7 @@ from ...types import Env, RunnerSetupStatus, RunnerTestResults, RunnerUpdateStatus from ...util import colored, prose_list, warn from ... import config -from .. import docker +from .. import docker # type: ignore[no-redef] # for mypy from . import jobs, s3 diff --git a/nextstrain/cli/runner/singularity.py b/nextstrain/cli/runner/singularity.py index 6a238702..14573223 100644 --- a/nextstrain/cli/runner/singularity.py +++ b/nextstrain/cli/runner/singularity.py @@ -95,7 +95,7 @@ from ..paths import RUNTIMES from ..types import Env, RunnerSetupStatus, RunnerTestResults, RunnerUpdateStatus from ..util import capture_output, colored, exec_or_return, split_image_name, warn -from . import docker +from . import docker # type: ignore[no-redef] # for mypy flatten = itertools.chain.from_iterable From b4b29d02893fb5735fa0e9e3032a4344d3c109ab Mon Sep 17 00:00:00 2001 From: Thomas Sibley Date: Tue, 21 Nov 2023 14:03:19 -0800 Subject: [PATCH 3/4] Support other nextstrain.org-like remotes Adds support for other nextstrain.org-like remotes to the `nextstrain remote` family of commands, along with support for OIDC/OAuth2 authentication with them to the `nextstrain login` and related commands. Required IdP and client configuration for OIDC/OAuth2 is auto-discovered. One giant pile of changes, because I did not have time to go thru my typical editing and splitting process before going on leave. :/ Ah well. Related-to: --- CHANGES.md | 33 +- doc/commands/authorization.rst | 22 +- doc/commands/index.rst | 4 +- doc/commands/login.rst | 45 +- doc/commands/logout.rst | 19 +- doc/commands/whoami.rst | 21 +- doc/development.md | 14 +- nextstrain/cli/authn.py | 177 ----- nextstrain/cli/authn/__init__.py | 239 +++++++ nextstrain/cli/authn/configuration.py | 58 ++ nextstrain/cli/authn/errors.py | 40 ++ nextstrain/cli/authn/session.py | 754 ++++++++++++++++++++ nextstrain/cli/aws/cognito/__init__.py | 262 ------- nextstrain/cli/browser.py | 8 +- nextstrain/cli/command/authorization.py | 31 +- nextstrain/cli/command/login.py | 105 ++- nextstrain/cli/command/logout.py | 31 +- nextstrain/cli/command/whoami.py | 37 +- nextstrain/cli/net.py | 38 + nextstrain/cli/remote/__init__.py | 154 +++- nextstrain/cli/remote/nextstrain_dot_org.py | 126 ++-- nextstrain/cli/remote/s3.py | 40 +- nextstrain/cli/types.py | 33 +- nextstrain/cli/url.py | 119 +++ 24 files changed, 1790 insertions(+), 620 deletions(-) delete mode 100644 nextstrain/cli/authn.py create mode 100644 nextstrain/cli/authn/__init__.py create mode 100644 nextstrain/cli/authn/configuration.py create mode 100644 nextstrain/cli/authn/errors.py create mode 100644 nextstrain/cli/authn/session.py create mode 100644 nextstrain/cli/net.py create mode 100644 nextstrain/cli/url.py diff --git a/CHANGES.md b/CHANGES.md index bff503fb..36af2a6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,15 +19,40 @@ This release drops support for Python versions 3.6 and 3.7 and adds support for ## Improvements +* The `nextstrain remote` family of commands now support alternative + nextstrain.org-like remotes such as internal Nextstrain Groups Server + instances and development instances of nextstrain.org. Authentication with + these remotes is supported via `nextstrain login` and related commands. + Remotes maintain their authentication alongside each other, e.g. you can be + logged into nextstrain.org as well as an alternative nextstrain.org-like + instance. + + As an example, a Nextstrain Groups Server instance accessible at + nextstrain.example.com could now be logged into and interacted with like so: + + nextstrain login nextstrain.example.com + nextstrain whoami nextstrain.example.com + nextstrain remote ls nextstrain.example.com/groups/bedford-lab/ + + The default remote is still nextstrain.org. + ([#333](https://github.com/nextstrain/cli/pull/333)) + +* `nextstrain login` now performs authentication via a web browser by default + (using OpenID Connect 1.0 and OAuth 2.0). The previously method of direct + password entry is still used when a username is provided (e.g. with + `--username` or `-u`). See `nextstrain login --help` for more information. + ([#333](https://github.com/nextstrain/cli/pull/333)) + * `nextstrain remote upload` now skips gzip compression when uploading zstandard-compressed (`.zst`) files, matching its behaviour for other types of compressed files. ([#330](https://github.com/nextstrain/cli/pull/330)) -* Commands that may automatically open a web browser, e.g. `nextstrain view`, - now respect the semi-conventional `NOBROWSER` environment variable to disable - opening a browser. Set `NOBROWSER` to a non-empty value to disable automatic - opening. +* Commands that may automatically open a web browser, e.g. `nextstrain view` or + `nextstrain login`, now respect the semi-conventional `NOBROWSER` environment + variable to disable opening a browser. Set `NOBROWSER` to a non-empty value + to disable automatic opening. When disabled, the URL to manually open will + be shown. ([#332](https://github.com/nextstrain/cli/pull/332)) * The error message emitted by the `nextstrain remote` family of commands when diff --git a/doc/commands/authorization.rst b/doc/commands/authorization.rst index 2161f4b2..cb442c4c 100644 --- a/doc/commands/authorization.rst +++ b/doc/commands/authorization.rst @@ -12,14 +12,15 @@ nextstrain authorization .. code-block:: none - usage: nextstrain authorization [-h] + usage: nextstrain authorization [-h] [] -Produce an Authorization header appropriate for nextstrain.org's web API. +Produce an Authorization header appropriate for the web API of nextstrain.org +(and other remotes). This is a development tool unnecessary for normal usage. It's useful for -directly making API requests to nextstrain.org with `curl` or similar -commands. For example:: +directly making API requests to nextstrain.org (and other remotes) with `curl` +or similar commands. For example:: curl -si https://nextstrain.org/whoami \ --header "Accept: application/json" \ @@ -27,6 +28,19 @@ commands. For example:: Exits with an error if no one is logged in. +positional arguments +==================== + + + +.. option:: + + Remote URL for which to produce an Authorization header. Expects + URLs like the remote source/destination URLs used by the + `nextstrain remote` family of commands. Only the domain name + (technically, the origin) of the URL is required/used, but a full + URL may be specified. + options ======= diff --git a/doc/commands/index.rst b/doc/commands/index.rst index ab9057e7..3f86e805 100644 --- a/doc/commands/index.rst +++ b/doc/commands/index.rst @@ -74,11 +74,11 @@ commands .. option:: login - Log into Nextstrain.org. See :doc:`/commands/login`. + Log into Nextstrain.org (and other remotes). See :doc:`/commands/login`. .. option:: logout - Log out of Nextstrain.org. See :doc:`/commands/logout`. + Log out of Nextstrain.org (and other remotes). See :doc:`/commands/logout`. .. option:: whoami diff --git a/doc/commands/login.rst b/doc/commands/login.rst index 40133cd3..1ebdb43d 100644 --- a/doc/commands/login.rst +++ b/doc/commands/login.rst @@ -12,23 +12,37 @@ nextstrain login .. code-block:: none - usage: nextstrain login [-h] [--username ] [--no-prompt] [--renew] + usage: nextstrain login [-h] [--username ] [--no-prompt] [--renew] [] -Log into Nextstrain.org and save credentials for later use. +Log into Nextstrain.org (and other remotes) and save credentials for later use. -The first time you log in, you'll be prompted for your Nextstrain.org username -and password. After that, locally-saved authentication tokens will be used and -automatically renewed as needed when you run other `nextstrain` commands -requiring log in. You can also re-run this `nextstrain login` command to force -renewal if you want. You'll only be prompted for your username and password if -the locally-saved tokens are unable to be renewed or missing entirely. +The first time you log in to a remote you'll be prompted to authenticate via +your web browser or, if you provide a username (e.g. with --username), for your +Nextstrain.org password. After that, locally-saved authentication tokens will +be used and automatically renewed as needed when you run other `nextstrain` +commands requiring log in. You can also re-run this `nextstrain login` command +to force renewal if you want. You'll only be prompted to reauthenticate (via +your web browser or username/password) if the locally-saved tokens are unable +to be renewed or missing entirely. -If you log out of Nextstrain.org on other devices/clients (like your web -browser), you may be prompted to re-enter your username and password by this -command sooner than usual. +If you log out of Nextstrain.org (or other remotes) on other devices/clients +(like your web browser), you may be prompted to reauthenticate by this command +sooner than usual. -Your password itself is never saved locally. +Your username and password themselves are never saved locally. + +positional arguments +==================== + + + +.. option:: + + Remote URL to log in to, like the remote source/destination URLs + used by the `nextstrain remote` family of commands. Only the + domain name (technically, the origin) of the URL is required/used, + but a full URL may be specified. options ======= @@ -45,7 +59,7 @@ options .. option:: --no-prompt - Never prompt for a username/password; succeed only if there are login credentials in the environment or existing valid/renewable tokens saved locally, otherwise error. Useful for scripting. + Never prompt for authentication (via web browser or username/password); succeed only if there are login credentials in the environment or existing valid/renewable tokens saved locally, otherwise error. Useful for scripting. .. option:: --renew @@ -62,4 +76,7 @@ of interactive input and/or command-line options: .. envvar:: NEXTSTRAIN_PASSWORD Password for nextstrain.org user. Required if :option:`--no-prompt` is - used without existing valid/renewable tokens. \ No newline at end of file + used without existing valid/renewable tokens. + +If you want to suppress ever opening a web browser automatically, you +may set the environment variable ``NOBROWSER=1``. \ No newline at end of file diff --git a/doc/commands/logout.rst b/doc/commands/logout.rst index a6a981f2..0e31143c 100644 --- a/doc/commands/logout.rst +++ b/doc/commands/logout.rst @@ -12,16 +12,29 @@ nextstrain logout .. code-block:: none - usage: nextstrain logout [-h] + usage: nextstrain logout [-h] [] -Log out of Nextstrain.org by deleting locally-saved credentials. +Log out of Nextstrain.org (and other remotes) by deleting locally-saved +credentials. The authentication tokens are removed but not invalidated, so if you used them outside of the `nextstrain` command, they will remain valid until they expire. Other devices/clients (like your web browser) are not logged out of -Nextstrain.org. +Nextstrain.org (or other remotes). + +positional arguments +==================== + + + +.. option:: + + Remote URL to log out of, like the remote source/destination URLs + used by the `nextstrain remote` family of commands. Only the + domain name (technically, the origin) of the URL is required/used, + but a full URL may be specified. options ======= diff --git a/doc/commands/whoami.rst b/doc/commands/whoami.rst index 2886e852..eec3fcb2 100644 --- a/doc/commands/whoami.rst +++ b/doc/commands/whoami.rst @@ -12,16 +12,29 @@ nextstrain whoami .. code-block:: none - usage: nextstrain whoami [-h] + usage: nextstrain whoami [-h] [] -Show information about the logged-in user. +Show information about the logged-in user for Nextstrain.org (and other +remotes). -The username, email address, and Nextstrain Groups memberships of the currently -logged-in user are shown. +The username, email address (if available), and Nextstrain Groups memberships +of the currently logged-in user are shown. Exits with an error if no one is logged in. +positional arguments +==================== + + + +.. option:: + + Remote URL for which to show the logged-in user. Expects URLs like + the remote source/destination URLs used by the `nextstrain remote` + family of commands. Only the domain name (technically, the origin) + of the URL is required/used, but a full URL may be specified. + options ======= diff --git a/doc/development.md b/doc/development.md index 4b2c75be..33a42086 100644 --- a/doc/development.md +++ b/doc/development.md @@ -59,13 +59,23 @@ resources from our ["testing" configuration][], you can configure `nextstrain` with the same, e.g.: export NEXTSTRAIN_DOT_ORG=http://localhost:5000 - export NEXTSTRAIN_COGNITO_USER_POOL_ID="$(jq -r .COGNITO_USER_POOL_ID ../nextstrain.org/env/testing/config.json)" - export NEXTSTRAIN_COGNITO_CLI_CLIENT_ID="$(jq -r .OAUTH2_CLI_CLIENT_ID ../nextstrain.org/env/testing/config.json)" nextstrain login nextstrain whoami nextstrain remote ls groups/test-private +Most of the times the above is not necessary, however, as you can specify the +local remote explicitly instead of pretending it's nextstrain.org, e.g.: + + nextstrain remote ls http://localhost:5000/groups/test + + nextstrain login http://localhost:5000 + nextstrain whoami http://localhost:5000 + nextstrain remote ls http://localhost:5000/groups/test-private + +Setting `NEXTSTRAIN_DOT_ORG` is mostly useful when testing the default-remote +code paths themselves. + ## Releasing New releases are made frequently and tagged in git using a [_signed_ tag][]. diff --git a/nextstrain/cli/authn.py b/nextstrain/cli/authn.py deleted file mode 100644 index e8ab655a..00000000 --- a/nextstrain/cli/authn.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Authentication routines. - - -Environment variables -===================== - -.. warning:: - For development only. You don't need to set these during normal operation. - -.. envvar:: NEXTSTRAIN_COGNITO_USER_POOL_ID - -.. envvar:: NEXTSTRAIN_COGNITO_CLI_CLIENT_ID -""" -import os -from functools import partial -from sys import stderr -from typing import Dict, List, Optional - -from . import config -from .errors import UserError -from .aws import cognito - - -# Section to use in config.SECRETS file -CONFIG_SECTION = "authn" - -# Public ids. Client id is specific to the CLI. -COGNITO_USER_POOL_ID = os.environ.get("NEXTSTRAIN_COGNITO_USER_POOL_ID") \ - or "us-east-1_Cg5rcTged" - -COGNITO_CLIENT_ID = os.environ.get("NEXTSTRAIN_COGNITO_CLI_CLIENT_ID") \ - or "2vmc93kj4fiul8uv40uqge93m5" - -CognitoSession = partial(cognito.Session, COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID) - - -class User: - """ - Data class holding information about a user. - """ - username: str - groups: List[str] - email: str - http_authorization: str - - def __init__(self, session: cognito.Session): - assert session.id_claims - - self.username = session.id_claims["cognito:username"] - self.groups = session.id_claims.get("cognito:groups", []) - self.email = session.id_claims["email"] - - self.http_authorization = f"Bearer {session.id_token}" - - -def login(username: str, password: str) -> User: - """ - Authenticates the given *username* and *password*. - - Returns a :class:`User` object with information about the logged in user - when successful. - - Raises a :class:`UserError` if authentication fails. - """ - session = CognitoSession() - - try: - session.authenticate(username, password) - - except cognito.NewPasswordRequiredError: - raise UserError("Password change required. Please login to Nextstrain.org first.") - - except cognito.NotAuthorizedError as error: - raise UserError(f"Login failed: {error}") - - _save_tokens(session) - print(f"Credentials saved to {config.SECRETS}.", file = stderr) - - return User(session) - - -def renew(): - """ - Renews existing tokens, if possible. - - Returns a :class:`User` object with renewed information about the logged in - user when successful. - - Raises a :class:`UserError` if authentication fails. - """ - session = CognitoSession() - tokens = _load_tokens() - refresh_token = tokens.get("refresh_token") - - if not refresh_token: - return None - - try: - session.renew_tokens(refresh_token = refresh_token) - - except (cognito.TokenError, cognito.NotAuthorizedError): - return None - - _save_tokens(session) - print(f"Renewed login credentials in {config.SECRETS}.", file = stderr) - - return User(session) - - -def logout(): - """ - Remove locally-saved credentials. - - The authentication tokens are not invalidated and will remain valid until - they expire. This does not contact Cognito and other devices/clients are - not logged out of Nextstrain.org. - """ - if config.remove(CONFIG_SECTION, config.SECRETS): - print(f"Credentials removed from {config.SECRETS}.", file = stderr) - print("Logged out.", file = stderr) - else: - print("Not logged in.", file = stderr) - - -def current_user() -> Optional[User]: - """ - Information about the currently logged in user, if any. - - Returns a :class:`User` object after validating saved credentials, renewing - and updating them if necessary. - - Returns ``None`` if there are no saved credentials or if they're unable to - be automatically renewed. - """ - session = CognitoSession() - tokens = _load_tokens() - - try: - try: - session.verify_tokens(**tokens) - - except cognito.ExpiredTokenError: - session.renew_tokens(refresh_token = tokens.get("refresh_token")) - _save_tokens(session) - print(f"Renewed login credentials in {config.SECRETS}.", file = stderr) - - except (cognito.TokenError, cognito.NotAuthorizedError): - return None - - return User(session) - - -def _load_tokens() -> Dict[str, Optional[str]]: - """ - Load id, access, and refresh tokens (if any) from the local secrets file. - """ - def load(name): - return config.get(CONFIG_SECTION, name, fallback = None, path = config.SECRETS) - - return { - "id_token": load("id_token"), - "access_token": load("access_token"), - "refresh_token": load("refresh_token") } - - -def _save_tokens(session: cognito.Session): - """ - Save id, access, and refresh tokens from the :class:`cognito.Session` - *session* to the local secrets file. - """ - def save(name, value): - return config.set(CONFIG_SECTION, name, value, path = config.SECRETS) - - save("id_token", session.id_token) - save("access_token", session.access_token) - save("refresh_token", session.refresh_token) diff --git a/nextstrain/cli/authn/__init__.py b/nextstrain/cli/authn/__init__.py new file mode 100644 index 00000000..fde7e04f --- /dev/null +++ b/nextstrain/cli/authn/__init__.py @@ -0,0 +1,239 @@ +""" +Authentication routines. + +Primarily for OpenID Connect 1.0 / OAuth 2.0 identity providers, with a bit of +AWS Cognito-specific support. + +Baked in is an assumption of a nextstrain.org-like remote which provides us +with dynamic provider and client configuration via a discovery request. +""" +from sys import stderr +from typing import Callable, Dict, List, Optional, Tuple + +from .. import config +from ..errors import UserError +from ..url import Origin +from . import errors +from .configuration import client_configuration +from .session import Session + + +# Section (or section prefix) to use in config.SECRETS file +CONFIG_SECTION = "authn" + + +class User: + """ + Data class holding information about a user. + """ + origin: Origin + username: str + groups: List[str] + email: Optional[str] + http_authorization: str + + def __init__(self, origin: Origin, session: Session): + assert origin + assert origin == session.origin + self.origin = origin + + client_config = client_configuration(origin) + username_claim = client_config["id_token_username_claim"] + groups_claim = client_config["id_token_groups_claim"] + + assert session.id_claims + self.username = session.id_claims[username_claim] + self.groups = session.id_claims.get(groups_claim, []) + self.email = session.id_claims.get("email") + + self.http_authorization = f"Bearer {session.id_token}" + + +def login(origin: Origin, credentials: Optional[Callable[[], Tuple[str, str]]] = None) -> User: + """ + Authenticates with *origin* by using a (username, password) tuple obtained + by calling *credentials* or, when *credentials* is omitted, via an + interactive flow thru the user's web browser. + + Returns a :class:`User` object with information about the logged in user + when successful. + + Raises a :class:`UserError` if authentication fails. + """ + assert origin + + session = Session(origin) + + try: + if credentials: + if not session.can_authenticate_with_password: + raise UserError(f""" + Remote {origin} does not support logging in + with a username and password. + + Omit specifying any username or password to login via a + web browser instead. + """) + session.authenticate_with_password(*credentials()) + else: + if not session.can_authenticate_with_browser: + raise UserError(f""" + Remote {origin} does not support logging in + via a web browser. + + Specify a username (e.g. with --username) to login with a + password instead. + """) + session.authenticate_with_browser() + + except errors.NewPasswordRequiredError: + raise UserError(f"Password change required. Please visit {origin} and login there first.") + + except errors.NotAuthorizedError as error: + raise UserError(f"Login failed: {error}") + + _save_tokens(origin, session) + print(f"Credentials for {origin} saved to {config.SECRETS}.", file = stderr) + + return User(origin, session) + + +def renew(origin: Origin) -> Optional[User]: + """ + Renews existing tokens for *origin*, if possible. + + Returns a :class:`User` object with renewed information about the logged in + user when successful. + + Raises a :class:`UserError` if authentication fails. + """ + assert origin + + session = Session(origin) + tokens = _load_tokens(origin) + refresh_token = tokens.get("refresh_token") + + if not refresh_token: + return None + + try: + session.renew_tokens(refresh_token = refresh_token) + + except (errors.TokenError, errors.NotAuthorizedError): + return None + + _save_tokens(origin, session) + print(f"Renewed login credentials for {origin} in {config.SECRETS}.", file = stderr) + + return User(origin, session) + + +def logout(origin: Origin): + """ + Remove locally-saved credentials. + + The authentication tokens are not invalidated and will remain valid until + they expire. This does not contact the origin's IdP (e.g. Cognito) and + other devices/clients are not logged out of Nextstrain.org. + """ + assert origin + + if config.remove(_config_section(origin), config.SECRETS): + print(f"Credentials for {origin} removed from {config.SECRETS}.", file = stderr) + print(f"Logged out of {origin}.", file = stderr) + else: + print(f"Not logged in to {origin}.", file = stderr) + + +def current_user(origin: Origin) -> Optional[User]: + """ + Information about the currently logged in user for *origin*, if any. + + Returns a :class:`User` object after validating saved credentials, renewing + and updating them if necessary. + + Returns ``None`` if there are no saved credentials or if they're unable to + be automatically renewed. + """ + assert origin + + session = Session(origin) + tokens = _load_tokens(origin) + + try: + try: + session.verify_tokens(**tokens) + + except errors.ExpiredTokenError: + session.renew_tokens(refresh_token = tokens.get("refresh_token")) + _save_tokens(origin, session) + print(f"Renewed login credentials for {origin} in {config.SECRETS}.", file = stderr) + + except (errors.TokenError, errors.NotAuthorizedError): + return None + + return User(origin, session) + + +def _load_tokens(origin: Origin) -> Dict[str, Optional[str]]: + """ + Load id, access, and refresh tokens (if any) from the local secrets file. + """ + assert origin + + with config.read_lock(): + secrets = config.load(config.SECRETS) + section = _config_section(origin) + + def load(name): + if section in secrets: + return secrets[section].get(name, None) + else: + return None + + return { + "id_token": load("id_token"), + "access_token": load("access_token"), + "refresh_token": load("refresh_token") } + + +def _save_tokens(origin: Origin, session: Session): + """ + Save id, access, and refresh tokens from the :class:`Session` + *session* to the local secrets file. + """ + assert origin + assert origin == session.origin + + with config.write_lock(): + secrets = config.load(config.SECRETS) + section = _config_section(origin) + + if section not in secrets: + secrets.add_section(section) + + assert session.id_token + assert session.access_token + assert session.refresh_token + + secrets[section]["id_token"] = session.id_token + secrets[section]["access_token"] = session.access_token + secrets[section]["refresh_token"] = session.refresh_token + + config.save(secrets, config.SECRETS) + + +def _config_section(origin: Origin) -> str: + assert origin + + # In the future, consider removing this special-casing of + # nextstrain.org—that is, stop using [authn] and have it use [authn + # https://nextstrain.org] like other remotes—by detecting the old section + # and automatically migrating it from the former to the latter. For now, + # I'm inclined not to worry about it. Using [authn] for now also means + # that older and newer versions of the CLI can co-exist with the same + # secrets file. + # -trs, 20 Nov 2023 + if origin == "https://nextstrain.org": + return CONFIG_SECTION + return f"{CONFIG_SECTION} {origin}" diff --git a/nextstrain/cli/authn/configuration.py b/nextstrain/cli/authn/configuration.py new file mode 100644 index 00000000..cf270eb2 --- /dev/null +++ b/nextstrain/cli/authn/configuration.py @@ -0,0 +1,58 @@ +""" +Authentication configuration. +""" +import requests +from functools import lru_cache +from ..errors import UserError +from ..url import Origin + + +@lru_cache(maxsize = None) +def openid_configuration(origin: Origin): + """ + Fetch the OpenID provider configuration/metadata for *origin*. + + While this information is typically served by an OP (OpenID provider), aka + IdP, here we expect *origin* to be a nextstrain.org-like RP (relying + party), aka SP (service provider), which is passing along its own IdP/OP's + configuration for us to discover. + """ + assert origin + + with requests.Session() as http: + response = http.get(origin.rstrip("/") + "/.well-known/openid-configuration") + + if response.status_code == 404: + raise UserError(f""" + Failed to retrieve authentication metadata for {origin}. + + That remote seems unlikely to be an alternate nextstrain.org + instance or an internal Nextstrain Groups Server instance. + """) + + response.raise_for_status() + return response.json() + + +def client_configuration(origin: Origin): + """ + OpenID client configuration/metadata for *origin*. + + The OpenID provider configuration of a nextstrain.org-like remote includes + client configuration for Nextstrain CLI. This details the OpenID client + that's registered with the corresponding provider for Nextstrain CLI's use. + """ + assert origin + + config = openid_configuration(origin) + + if "nextstrain_cli_client_configuration" not in config: + raise UserError(f""" + Authentication metadata for {origin} + does not contain required client information for Nextstrain CLI. + + That remote seems unlikely to be an alternate nextstrain.org + instance or an internal Nextstrain Groups Server instance. + """) + + return config["nextstrain_cli_client_configuration"] diff --git a/nextstrain/cli/authn/errors.py b/nextstrain/cli/authn/errors.py new file mode 100644 index 00000000..a0362284 --- /dev/null +++ b/nextstrain/cli/authn/errors.py @@ -0,0 +1,40 @@ +""" +Authentication errors. +""" +from ..aws.cognito.srp import NewPasswordRequiredError # noqa: F401 (NewPasswordRequiredError is for re-export) + +class IdPError(Exception): + """Error from IdP during authentication.""" + pass + +class NotAuthorizedError(IdPError): + """Not Authorized response during authentication.""" + pass + +class TokenError(Exception): + """Error when verifying tokens.""" + pass + +class MissingTokenError(TokenError): + """ + No token provided but one is required. + + Context is the kind of token ("use") that was missing. + """ + pass + +class ExpiredTokenError(TokenError): + """ + Token is expired. + + Context is the kind of token ("use") that was missing. + """ + pass + +class InvalidUseError(TokenError): + """ + The "use" of the token does not match the expected value. + + May indicate an accidental token swap. + """ + pass diff --git a/nextstrain/cli/authn/session.py b/nextstrain/cli/authn/session.py new file mode 100644 index 00000000..84906289 --- /dev/null +++ b/nextstrain/cli/authn/session.py @@ -0,0 +1,754 @@ +""" +Authentication sessions. +""" +import boto3 +import jwt +import jwt.exceptions +import requests +import secrets + +from base64 import b64encode +from errno import EADDRINUSE +from hashlib import sha256 +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from inspect import cleandoc +from textwrap import fill +from threading import Thread +from typing import Any, Dict, Mapping, Optional, Set + +from ..aws.cognito.srp import CognitoSRP +from ..browser import BROWSER, open_browser +from ..debug import debug +from ..errors import UserError +from ..net import is_loopback +from ..url import URL, Origin, query +from .configuration import openid_configuration, client_configuration +from .errors import NotAuthorizedError, TokenError, MissingTokenError, ExpiredTokenError, InvalidUseError + + +class Session: + origin: Origin + can_authenticate_with_browser: bool = False + can_authenticate_with_password: bool = False + + def __new__(cls, origin: Origin) -> 'Session': + assert origin + if cls is Session: + if client_configuration(origin).get("aws_cognito_user_pool_id"): + cls = CognitoSession + else: + cls = OpenIDSession + return super().__new__(cls) # type: ignore + + def authenticate_with_password(self, username: str, password: str) -> None: + raise NotImplementedError + + def authenticate_with_browser(self) -> None: + raise NotImplementedError + + def renew_tokens(self, *, refresh_token: Optional[str]) -> None: + raise NotImplementedError + + def verify_tokens(self, *, id_token: Optional[str], access_token: Optional[str], refresh_token: Optional[str]) -> None: + raise NotImplementedError + + @property + def id_token(self) -> Optional[str]: + raise NotImplementedError + + @property + def access_token(self) -> Optional[str]: + raise NotImplementedError + + @property + def refresh_token(self) -> Optional[str]: + raise NotImplementedError + + @property + def id_claims(self) -> Optional[Mapping[str, Any]]: + raise NotImplementedError + + +class OpenIDSession(Session): + """ + Authentication session interface for OpenID. + + The interface of this class aims to be hard or impossible to accidentally + use insecurely. + """ + def __init__(self, origin: Origin): + assert origin + self.origin = origin + + self.openid_configuration = openid_configuration(origin) + self.client_configuration = client_configuration(origin) + + self.jwks = jwt.PyJWKClient(self.openid_configuration["jwks_uri"]) + + self.can_authenticate_with_browser = "code" in self.client_configuration.get("response_types", []) + self.can_authenticate_with_password = False + + self._tokens: Dict[str, Optional[str]] = {} + self._claims: Dict[str, Dict[str, Any]] = {} + + @property + def id_token(self): + """ + The id token for this session, set by calling + :meth:`.authenticate_with_password`, + :meth:`.authenticate_with_browser`, or :meth:`.verify_tokens`. + + Useful for persisting in external storage, but should be treated as an + opaque value. The claims embedded in this token are accessible in + :attr:`.id_claims`. + """ + return self._tokens.get("id") + + @property + def access_token(self): + """ + The access token for this session, set by calling + :meth:`.authenticate_with_password`, + :meth:`.authenticate_with_browser`, or :meth:`.verify_tokens`. + + Useful for persisting in external storage, but should be treated as an + opaque value. + """ + return self._tokens.get("access") + + @property + def refresh_token(self): + """ + The refresh token for this session, set by calling + :meth:`.authenticate_with_password`, + :meth:`.authenticate_with_browser`, or :meth:`.verify_tokens`. + + Useful for persisting in external storage, but should be treated as an + opaque value. + """ + return self._tokens.get("refresh") + + @property + def id_claims(self): + """ + Dictionary of verified claims from the :attr:`.id_token`. + """ + return self._claims.get("id") + + + def authenticate_with_password(self, username: str, password: str) -> None: + """ + Authenticates the given *username* and *password* with the IdP. + + If successful, returns nothing, but several instance attributes will be + set: + + * :attr:`.id_token` + * :attr:`.access_token` + * :attr:`.refresh_token` + * :attr:`.id_claims` + + If unsuccessful, raises an :exc:`IdPError` or :exc:`TokenError` (or + one of their subclasses). + """ + # This could implement OAuth2's "password" grant type¹, but Cognito + # doesn't support it² and we don't need to support it for other IdPs. + # -trs, 17 Nov 2023 + # + # ¹ + # ² Cognito supports password auth via its own API instead; see + # CognitoSession below. + raise NotImplementedError + + + def authenticate_with_browser(self) -> None: + """ + Authenticates with the IdP via the user's web browser. + + If successful, returns nothing, but several instance attributes will be + set: + + * :attr:`.id_token` + * :attr:`.access_token` + * :attr:`.refresh_token` + * :attr:`.id_claims` + + If unsuccessful, raises an :exc:`IdPError` or :exc:`TokenError` (or + one of their subclasses). + """ + # What follows is a basic implementation of the OpenID Connect (OIDC)¹ + # and OAuth 2.0² authorization code flow additionally secured with + # Proof Key for Code Exchange (PKCE)³ and informed by the best current + # practices for OAuth 2.0 native apps.⁴ Variable names and such + # intentionally stick to the terms used in the specs to ease + # understanding. A survey of OIDC/OAuth2 libraries found nothing + # suitable for our uses here. + # -trs, 17 Nov 2023 + # + # ¹ + # ² + # ³ + # ⁴ + assert self.can_authenticate_with_browser + + + # XXX TODO: This giant function should likely be broken up into + # smaller, more digestable parts. But I am out of time and cannot do + # that now. It works as-is and since it's only about internal + # organization, I think fine to ship like this and fix it up later. Of + # course, fine for someone else to clean it up too! + # -trs, 21 Nov 2023 + + + # Set up a minimal HTTP server to receive the authorization response, + # which contains query parameters we need to complete authentication. + class AuthorizationResponseHandler(BaseHTTPRequestHandler): + def do_GET(self): + assert isinstance(self.server, AuthorizationResponseServer) + + url = URL(self.path) + + if url.path.lstrip("/") == self.server.redirect_uri.path.lstrip("/"): + # Capture the HTTP request URL so we can parse the + # authorization response details (code, state, etc) later. + self.server.response_url = url + self.respond(200, "You may now close this page and return to the terminal.") + else: + self.respond(400, "(Redirect path does not match expectation.)") + + # Accept only one request; close this request's connection and + # stop the server. + # + # Shutting down the server from within a request handler only + # works because we're using a ThreadingHTTPServer so we're + # requesting shutdown from a different thread than the server. + # With a non-threading HTTPServer, we'd deadlock here. + self.close_connection = True + self.server.shutdown() + + def respond(self, status: int, details: str): + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + css = """ + body { + margin-top: 2rem; + text-align: center; + font-family: monospace; + font-size: 1.5rem; + } + h1 { + font-size: 2rem; + font-weight: normal; + background: black; + color: white; + background: linear-gradient(to right, #4377cd, #5097ba, #63ac9a, #7cb879, #9abe5c, #b9bc4a, #d4b13f, #e49938, #e67030, #de3c26); + } + .success { color: green } + .failure { color: red } + aside { font-size: 1rem } + """ + html = f""" + +

Nextstrain CLI

+

+ Authentication {'complete' if status == 200 else 'failed'}. +