diff --git a/CHANGES.md b/CHANGES.md index bff503fb..08da27b8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -19,15 +19,45 @@ 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)) + +* With the new support for being logged into multiple remotes, `nextstrain + logout` now also supports an `--all` flag to remove all locally-saved + credentials without having to logout of each remote separately. + ([#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..37e30143 100644 --- a/doc/commands/logout.rst +++ b/doc/commands/logout.rst @@ -12,16 +12,31 @@ nextstrain logout .. code-block:: none - usage: nextstrain logout [-h] + usage: nextstrain logout [] + nextstrain logout --all + nextstrain logout --help -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 ======= @@ -32,3 +47,7 @@ options show this help message and exit +.. option:: --all + + Log out of all remotes for which there are locally-saved credentials + 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..aef7b76d --- /dev/null +++ b/nextstrain/cli/authn/__init__.py @@ -0,0 +1,274 @@ +""" +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 logout_all(): + """ + Remove **all** locally-saved credentials. + + Equivalent to calling :func:`logout` on all origins found in the secrets + file. + """ + with config.write_lock(): + secrets = config.load(config.SECRETS) + + sections = [ + (section, _parse_section(section)) + for section in secrets + if _parse_section(section) ] + + if sections: + for section, origin in sections: + del secrets[section] + print(f"Credentials for {origin} removed from {config.SECRETS}.", file = stderr) + print(f"Logged out of {origin}.", file = stderr) + + config.save(secrets, config.SECRETS) + else: + print(f"Not logged in to any remotes.", 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}" + + +def _parse_section(section: str) -> Optional[Origin]: + if section == CONFIG_SECTION: + return Origin("https://nextstrain.org") + elif section.startswith(CONFIG_SECTION + " "): + return Origin(section.split(" ", 1)[1]) + else: + return None 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'}. +