diff --git a/cwmscli/__main__.py b/cwmscli/__main__.py index 088d892..4376b77 100644 --- a/cwmscli/__main__.py +++ b/cwmscli/__main__.py @@ -83,6 +83,7 @@ def cli( cli.add_command(usgs_group, name="usgs") cli.add_command(commands_cwms.shefcritimport) cli.add_command(commands_cwms.csv2cwms_cmd) +cli.add_command(commands_cwms.login_cmd) cli.add_command(commands_cwms.update_cli_cmd) cli.add_command(commands_cwms.blob_group) cli.add_command(commands_cwms.clob_group) diff --git a/cwmscli/commands/blob.py b/cwmscli/commands/blob.py index 16fb31a..19ea44c 100644 --- a/cwmscli/commands/blob.py +++ b/cwmscli/commands/blob.py @@ -9,7 +9,7 @@ from collections import defaultdict from typing import Optional, Sequence, Tuple, Union -from cwmscli.utils import colors, get_api_key, log_scoped_read_hint +from cwmscli.utils import colors, get_api_key, init_cwms_session, log_scoped_read_hint from cwmscli.utils.click_help import DOCS_BASE_URL from cwmscli.utils.deps import requires @@ -133,6 +133,18 @@ def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Option return get_api_key(api_key, None) +def _resolve_credential_kind(api_key: Optional[str], anonymous: bool) -> Optional[str]: + if anonymous: + return None + from cwmscli.utils import get_saved_login_token + + if get_saved_login_token(): + return "token" + if _resolve_optional_api_key(api_key, anonymous): + return "api_key" + return None + + def _response_status_code(exc: BaseException) -> Optional[int]: response = getattr(exc, "response", None) return getattr(response, "status_code", None) @@ -411,7 +423,7 @@ def upload_cmd( import cwms import requests - cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None)) + init_cwms_session(cwms, api_root=api_root, api_key=api_key) using_single = bool(input_file) using_multi = bool(input_dir) @@ -570,8 +582,8 @@ def download_cmd( f"DRY RUN: would GET {api_root} blob with blob-id={blob_id} office={office}." ) return - resolved_api_key = _resolve_optional_api_key(api_key, anonymous) - cwms.init_session(api_root=api_root, api_key=resolved_api_key) + credential_kind = _resolve_credential_kind(api_key, anonymous) + init_cwms_session(cwms, api_root=api_root, api_key=api_key, anonymous=anonymous) bid = blob_id.upper() logging.debug(f"Office={office} BlobID={bid}") @@ -588,7 +600,7 @@ def download_cmd( detail = getattr(e.response, "text", "") or str(e) logging.error(f"Failed to download (HTTP): {detail}") log_scoped_read_hint( - api_key=resolved_api_key, + credential_kind=credential_kind, anonymous=anonymous, office=office, action="download", @@ -598,7 +610,7 @@ def download_cmd( except Exception as e: logging.error(f"Failed to download: {e}") log_scoped_read_hint( - api_key=resolved_api_key, + credential_kind=credential_kind, anonymous=anonymous, office=office, action="download", @@ -616,7 +628,7 @@ def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str, dry_run: f"DRY RUN: would DELETE {api_root} blob with blob-id={blob_id} office={office}" ) return - cwms.init_session(api_root=api_root, api_key=api_key) + init_cwms_session(cwms, api_root=api_root, api_key=api_key) try: cwms.delete_blob(office_id=office, blob_id=blob_id) except requests.HTTPError as e: @@ -649,7 +661,7 @@ def update_cmd( f"DRY RUN: would PATCH {api_root} blob with blob-id={blob_id} office={office}" ) return - cwms.init_session(api_root=api_root, api_key=api_key) + init_cwms_session(cwms, api_root=api_root, api_key=api_key) file_data = None if input_file: try: @@ -695,8 +707,8 @@ def list_cmd( import cwms import pandas as pd - resolved_api_key = _resolve_optional_api_key(api_key, anonymous) - cwms.init_session(api_root=api_root, api_key=resolved_api_key) + credential_kind = _resolve_credential_kind(api_key, anonymous) + init_cwms_session(cwms, api_root=api_root, api_key=api_key, anonymous=anonymous) try: df = list_blobs( office=office, @@ -709,7 +721,7 @@ def list_cmd( ) except Exception: log_scoped_read_hint( - api_key=resolved_api_key, + credential_kind=credential_kind, anonymous=anonymous, office=office, action="list", diff --git a/cwmscli/commands/commands_cwms.py b/cwmscli/commands/commands_cwms.py index 97af6cc..276a6ef 100644 --- a/cwmscli/commands/commands_cwms.py +++ b/cwmscli/commands/commands_cwms.py @@ -1,7 +1,9 @@ +import logging import os import subprocess import sys import textwrap +from pathlib import Path from typing import Optional import click @@ -20,6 +22,7 @@ office_option_notrequired, to_uppercase, ) +from cwmscli.utils.auth import DEFAULT_REDIRECT_HOST, DEFAULT_REDIRECT_PORT from cwmscli.utils.deps import requires from cwmscli.utils.update import ( build_update_package_spec, @@ -29,6 +32,208 @@ from cwmscli.utils.version import get_cwms_cli_version +@click.command( + "login", + help="Authenticate with CWBI OIDC using PKCE and save tokens for reuse.", +) +@click.option( + "--provider", + type=click.Choice(["federation-eams", "login.gov"], case_sensitive=False), + default="federation-eams", + show_default=True, + help="Identity provider hint to send to Keycloak.", +) +@click.option( + "--client-id", + default="cwms", + show_default=True, + help="OIDC client ID.", +) +@click.option( + "-a", + "--api-root", + default="https://cwms-data.usace.army.mil/cwms-data", + envvar="CDA_API_ROOT", + show_default=True, + help="CDA API root used to discover the OpenID Connect configuration.", +) +@click.option( + "--oidc-base-url", + default=None, + show_default=True, + hidden=True, + help="Override the discovered OIDC realm base URL ending in /protocol/openid-connect.", +) +@click.option( + "--scope", + default="openid profile", + show_default=True, + help="OIDC scopes to request.", +) +@click.option( + "--redirect-host", + default=DEFAULT_REDIRECT_HOST, + show_default=True, + help="Local host for the login callback listener.", +) +@click.option( + "--redirect-port", + default=DEFAULT_REDIRECT_PORT, + type=int, + show_default=True, + help="Local port for the login callback listener.", +) +@click.option( + "--token-file", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Path to save the login session JSON. Defaults to a provider-specific file under ~/.config/cwms-cli/auth/.", +) +@click.option( + "--refresh", + "refresh_only", + is_flag=True, + default=False, + help="Refresh an existing saved session instead of opening a new browser login.", +) +@click.option( + "--no-browser", + is_flag=True, + default=False, + help="Print the authorization URL instead of trying to open a browser automatically.", +) +@click.option( + "--timeout", + default=30, + type=int, + show_default=True, + help="Seconds to wait for the local login callback.", +) +@click.option( + "--ca-bundle", + type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=Path), + default=None, + help="CA bundle to use for TLS verification.", +) +@requires(reqs.requests) +def login_cmd( + provider: str, + client_id: str, + api_root: str, + oidc_base_url: Optional[str], + scope: str, + redirect_host: str, + redirect_port: int, + token_file: Path, + refresh_only: bool, + no_browser: bool, + timeout: int, + ca_bundle: Path, +): + from cwmscli.utils.auth import ( + DEFAULT_CDA_API_ROOT, + AuthError, + CallbackBindError, + LoginTimeoutError, + OIDCLoginConfig, + default_token_file, + discover_oidc_base_url, + discover_oidc_configuration, + login_with_browser, + refresh_saved_login, + refresh_token_expiry_text, + save_login, + token_expiry_text, + ) + from cwmscli.utils.colors import c, err + + provider = provider.lower() + token_file = token_file or default_token_file(provider) + verify = str(ca_bundle) if ca_bundle else None + api_root = (api_root or DEFAULT_CDA_API_ROOT).rstrip("/") + action = ( + "refreshed your saved sign-in for" if refresh_only else "authenticated against" + ) + + try: + if refresh_only: + result = refresh_saved_login(token_file=token_file, verify=verify) + config = result["config"] + token = result["token"] + else: + discovered_oidc = ( + { + "oidc_base_url": oidc_base_url.rstrip("/"), + "authorization_endpoint": f"{oidc_base_url.rstrip('/')}/auth", + "token_endpoint": f"{oidc_base_url.rstrip('/')}/token", + } + if oidc_base_url + else discover_oidc_configuration( + api_root=api_root, + verify=verify, + ) + ) + config = OIDCLoginConfig( + client_id=client_id, + oidc_base_url=discovered_oidc["oidc_base_url"].rstrip("/"), + authorization_endpoint_url=discovered_oidc["authorization_endpoint"], + token_endpoint_url=discovered_oidc["token_endpoint"], + redirect_host=redirect_host, + redirect_port=redirect_port, + scope=scope, + provider=provider, + timeout_seconds=timeout, + verify=verify, + ) + auth_url_shown = False + + def show_auth_url(url: str) -> None: + nonlocal auth_url_shown + click.echo("Visit this URL to authenticate:") + click.echo(url) + auth_url_shown = True + + result = login_with_browser( + config=config, + launch_browser=not no_browser, + authorization_url_callback=show_auth_url if no_browser else None, + ) + config = result.get("config", config) + if (not auth_url_shown) and (not result["browser_opened"]): + click.echo("Visit this URL to authenticate:") + click.echo(result["authorization_url"]) + token = result["token"] + + save_login(token_file=token_file, config=config, token=token) + except LoginTimeoutError as e: + click.echo(err(f"ALERT: {e}"), err=True) + raise click.exceptions.Exit(1) from e + except CallbackBindError as e: + click.echo(err(f"ALERT: {e}"), err=True) + raise click.exceptions.Exit(1) from e + except AuthError as e: + raise click.ClickException(str(e)) from e + except OSError as e: + raise click.ClickException(f"Login setup failed: {e}") from e + + click.echo(f"You have successfully {action} CWBI.") + refresh_expiry = refresh_token_expiry_text(token) + if refresh_expiry: + click.echo( + c( + f"Your refresh session is good until {refresh_expiry}.", + "blue", + bright=True, + ) + ) + logging.debug("Saved login session to %s", token_file) + expiry = token_expiry_text(token) + if expiry: + logging.debug("Access token expires at %s", expiry) + if token.get("refresh_token"): + logging.debug("A refresh token is available for future reuse.") + + @click.command( "shefcritimport", help="Import SHEF crit file into timeseries group for SHEF file processing", diff --git a/cwmscli/commands/csv2cwms/__main__.py b/cwmscli/commands/csv2cwms/__main__.py index 0717def..77ae3e5 100644 --- a/cwmscli/commands/csv2cwms/__main__.py +++ b/cwmscli/commands/csv2cwms/__main__.py @@ -6,6 +6,8 @@ import cwms +from cwmscli.utils import init_cwms_session + # Add the current directory to the path # This is necessary for the script to be run as a standalone script sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) @@ -70,8 +72,8 @@ def main(*args, **kwargs): tz = safe_zoneinfo(kwargs.get("tz")) begin_time = _resolve_begin_time(tz, kwargs.get("begin")) - cwms.api.init_session( - api_root=kwargs.get("api_root"), api_key=kwargs.get("api_key") + init_cwms_session( + cwms, api_root=kwargs.get("api_root"), api_key=kwargs.get("api_key") ) setup_logger(kwargs.get("log"), verbose=kwargs.get("verbose")) logger.info(f"Begin time: {begin_time}") diff --git a/cwmscli/commands/shef_critfile_import.py b/cwmscli/commands/shef_critfile_import.py index 218575f..093d6a5 100644 --- a/cwmscli/commands/shef_critfile_import.py +++ b/cwmscli/commands/shef_critfile_import.py @@ -5,6 +5,8 @@ import cwms import pandas as pd +from cwmscli.utils import init_cwms_session + def import_shef_critfile( file_path: str, @@ -40,8 +42,7 @@ def import_shef_critfile( None """ - api_key = "apikey " + api_key - cwms.api.init_session(api_root=api_root, api_key=api_key) + init_cwms_session(cwms, api_root=api_root, api_key="apikey " + api_key) logging.info(f"CDA connection: {api_root}") # Parse the file and get the parsed data diff --git a/cwmscli/commands/users.py b/cwmscli/commands/users.py index 5809fe6..0b3bb1c 100644 --- a/cwmscli/commands/users.py +++ b/cwmscli/commands/users.py @@ -4,7 +4,7 @@ import click -from cwmscli.utils import colors, get_api_key +from cwmscli.utils import colors, init_cwms_session def _format_table(headers: list[str], rows: list[list[str]]) -> str: @@ -46,8 +46,7 @@ def _handle_api_error(error: Exception, cwms_module) -> None: def _init_cwms(api_root: str, api_key: str, api_key_loc: str) -> object: import cwms - resolved_api_key = get_api_key(api_key, api_key_loc) - cwms.init_session(api_root=api_root, api_key=resolved_api_key) + init_cwms_session(cwms, api_root=api_root, api_key=api_key, api_key_loc=api_key_loc) return cwms diff --git a/cwmscli/load/location/location_ids.py b/cwmscli/load/location/location_ids.py index e8f45b4..e567fe8 100644 --- a/cwmscli/load/location/location_ids.py +++ b/cwmscli/load/location/location_ids.py @@ -5,6 +5,7 @@ import click import cwms +from cwmscli.utils import init_cwms_session from cwmscli.utils.links import CDA_REGEXP_GUIDE_URL logger = logging.getLogger(__name__) @@ -34,7 +35,7 @@ def load_locations( ): logger.info(" CDA regex guide: %s", CDA_REGEXP_GUIDE_URL) - cwms.init_session(api_root=source_cda, api_key=None) + init_cwms_session(cwms, api_root=source_cda) cat_kwargs = {"office_id": source_office} if like: @@ -85,7 +86,7 @@ def load_locations( return # init target once - cwms.init_session(api_root=target_cda, api_key=target_api_key) + init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key) errors = 0 for loc in locations: diff --git a/cwmscli/load/location/location_ids_bygroup.py b/cwmscli/load/location/location_ids_bygroup.py index 7d59dcb..59caae1 100644 --- a/cwmscli/load/location/location_ids_bygroup.py +++ b/cwmscli/load/location/location_ids_bygroup.py @@ -6,6 +6,8 @@ import click import cwms +from cwmscli.utils import init_cwms_session + logger = logging.getLogger(__name__) @@ -43,7 +45,7 @@ def copy_from_group( f"filter_office={filter_office} dry_run={dry_run}" ) - cwms.init_session(api_root=source_cda, api_key=None) + init_cwms_session(cwms, api_root=source_cda) try: grp = cwms.get_location_group( @@ -106,7 +108,7 @@ def copy_from_group( return try: - cwms.init_session(api_root=target_cda, api_key=target_api_key) + init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key) except Exception as e: raise click.ClickException(f"Failed to init target session: {e}") diff --git a/cwmscli/load/root.py b/cwmscli/load/root.py index c343894..61b753a 100644 --- a/cwmscli/load/root.py +++ b/cwmscli/load/root.py @@ -97,7 +97,7 @@ def shared_source_target_options(f): f = click.option( "--target-api-key", envvar="CDA_API_KEY", - help="Target API key (if required by the target CDA).", + help="Target API key used when no saved cwms-cli login token is available.", )(f) f = click.option( "--dry-run/--no-dry-run", diff --git a/cwmscli/load/timeseries/timeseries_ids.py b/cwmscli/load/timeseries/timeseries_ids.py index 90fb355..ad4cce8 100644 --- a/cwmscli/load/timeseries/timeseries_ids.py +++ b/cwmscli/load/timeseries/timeseries_ids.py @@ -4,6 +4,8 @@ import click import pandas as pd +from cwmscli.utils import init_cwms_session + logger = logging.getLogger(__name__) @@ -24,7 +26,7 @@ def load_timeseries_ids( f"to target CDA '{target_cda}'." ) - cwms.init_session(api_root=source_cda, api_key=None) + init_cwms_session(cwms, api_root=source_cda) ts_ids = cwms.get_timeseries_identifiers( office_id=source_office, timeseries_id_regex=timeseries_id_regex ).df @@ -39,7 +41,7 @@ def load_timeseries_ids( if verbose: logger.info("Found %s timeseries IDs to copy.", len(ts_lo_ids)) - cwms.init_session(api_root=target_cda, api_key=target_api_key) + init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key) errors = 0 for i, row in ts_lo_ids.iterrows(): ts_id = row["time-series-id"] diff --git a/cwmscli/requirements.py b/cwmscli/requirements.py index 339f487..7c97982 100644 --- a/cwmscli/requirements.py +++ b/cwmscli/requirements.py @@ -1,6 +1,5 @@ -# A file containing shared minimum version requirements for dependencies -# used by the `@requires` decorator in `cwmscli.utils.deps`. - +# Shared minimum version requirements for optional dependencies used by +# the `@requires` decorator in `cwmscli.utils.deps`. cwms = { "module": "cwms", diff --git a/cwmscli/usgs/getUSGS_ratings_cda.py b/cwmscli/usgs/getUSGS_ratings_cda.py index 6597de9..4a6641e 100644 --- a/cwmscli/usgs/getUSGS_ratings_cda.py +++ b/cwmscli/usgs/getUSGS_ratings_cda.py @@ -9,6 +9,8 @@ import requests from dataretrieval import nwis +from cwmscli.utils import init_cwms_session + def getusgs_rating_cda( api_root: str, @@ -17,8 +19,7 @@ def getusgs_rating_cda( days_back: float = 1, rating_subset: list = None, ): - api_key = "apikey " + api_key - cwms.api.init_session(api_root=api_root, api_key=api_key) + init_cwms_session(cwms, api_root=api_root, api_key="apikey " + api_key) logging.info(f"CDA connection: {api_root}") logging.info( f"Updated Ratings will be obtained from the USGS for the past {days_back} days" diff --git a/cwmscli/usgs/getusgs_cda.py b/cwmscli/usgs/getusgs_cda.py index 73ce699..19db973 100644 --- a/cwmscli/usgs/getusgs_cda.py +++ b/cwmscli/usgs/getusgs_cda.py @@ -7,7 +7,7 @@ import pandas as pd import requests -from cwmscli.utils import colors +from cwmscli.utils import colors, init_cwms_session def _log_error_and_exit( @@ -47,8 +47,7 @@ def getusgs_cda( api_key: str, backfill_tsids: list = None, ): - api_key = "apikey " + api_key - cwms.api.init_session(api_root=api_root, api_key=api_key) + init_cwms_session(cwms, api_root=api_root, api_key="apikey " + api_key) logging.info(f"CDA connection: {api_root}") logging.info( f"Data will be grabbed and stored from USGS for past {days_back} days for office: {office_id}" diff --git a/cwmscli/usgs/getusgs_measurements_cda.py b/cwmscli/usgs/getusgs_measurements_cda.py index 8fa9043..9377855 100644 --- a/cwmscli/usgs/getusgs_measurements_cda.py +++ b/cwmscli/usgs/getusgs_measurements_cda.py @@ -10,6 +10,8 @@ import requests from dataretrieval import nwis +from cwmscli.utils import init_cwms_session + # --- Constants --- CWMS_MISSING_VALUE = -340282346638528859811704183484516925440 @@ -76,8 +78,7 @@ def getusgs_measurement_cda( backfill_list: list = None, backfill_group: bool = False, ): - apiKey = "apikey " + api_key - api = cwms.api.init_session(api_root=api_root, api_key=apiKey) + api = init_cwms_session(cwms, api_root=api_root, api_key="apikey " + api_key) logging.info("Fetching CWMS location groups...") try: diff --git a/cwmscli/usgs/rating_ini_file_import.py b/cwmscli/usgs/rating_ini_file_import.py index fc961ea..970efc5 100644 --- a/cwmscli/usgs/rating_ini_file_import.py +++ b/cwmscli/usgs/rating_ini_file_import.py @@ -2,6 +2,8 @@ import cwms +from cwmscli.utils import init_cwms_session + rating_types = { "store_corr": {"db_type": "db_corr", "db_disc": "USGS-CORR"}, "store_base": {"db_type": "db_base", "db_disc": "USGS-BASE"}, @@ -10,8 +12,7 @@ def rating_ini_file_import(api_root, api_key, ini_filename): - api_key = "apikey " + api_key - cwms.api.init_session(api_root=api_root, api_key=api_key) + init_cwms_session(cwms, api_root=api_root, api_key="apikey " + api_key) logging.info(f"CDA connection: {api_root}") logging.info(f"Opening ini file: {ini_filename}") diff --git a/cwmscli/utils/__init__.py b/cwmscli/utils/__init__.py index 353538a..a540cbd 100644 --- a/cwmscli/utils/__init__.py +++ b/cwmscli/utils/__init__.py @@ -1,5 +1,7 @@ import logging as py_logging -from typing import Optional +import time +from pathlib import Path +from typing import Optional, Union import click from click.core import ParameterSource @@ -86,14 +88,14 @@ def _set_log_level(ctx, param, value): default=None, type=str, envvar="CDA_API_KEY", - help="api key for CDA. Can be user defined or place in env variable CDA_API_KEY. one of api-key or api-key-loc are required", + help="API key for CDA. Optional when a saved cwms-cli login token is available. Can also be provided by CDA_API_KEY.", ) api_key_loc_option = click.option( "-kl", "--api-key-loc", default=None, type=str, - help="file storing Api Key. One of api-key or api-key-loc are required", + help="File storing an API key. Optional when a saved cwms-cli login token is available.", ) log_level_option = click.option( "--log-level", @@ -122,21 +124,111 @@ def get_api_key(api_key: str, api_key_loc: str) -> str: ) +def get_saved_login_token( + token_file: Optional[Union[str, Path]] = None, + provider: str = "federation-eams", +) -> Optional[str]: + from cwmscli.utils.auth import ( + AuthError, + default_token_file, + load_saved_login, + refresh_saved_login, + save_login, + ) + + candidate = Path(token_file) if token_file else default_token_file(provider) + try: + saved = load_saved_login(candidate) + except AuthError as error: + if candidate.exists(): + py_logging.warning("Ignoring saved login at %s: %s", candidate, error) + return None + + token = saved.get("token", {}) + access_token = token.get("access_token") + if not access_token: + py_logging.warning( + "Ignoring saved login at %s: no access token found", candidate + ) + return None + expires_at = token.get("expires_at") + if expires_at is not None: + try: + if float(expires_at) <= time.time(): + py_logging.info("Refreshing expired saved login token at %s", candidate) + try: + refreshed = refresh_saved_login(token_file=candidate) + save_login( + token_file=candidate, + config=refreshed["config"], + token=refreshed["token"], + ) + except AuthError as error: + py_logging.warning( + "Could not refresh saved login at %s: %s. Falling back to API key if available.", + candidate, + error, + ) + return None + return refreshed["token"].get("access_token") + except (TypeError, ValueError, OSError): + py_logging.warning( + "Ignoring saved login at %s: invalid token expiration value %r", + candidate, + expires_at, + ) + return None + return access_token + + +def init_cwms_session( + cwms_module, + *, + api_root: str, + api_key: Optional[str] = None, + api_key_loc: Optional[str] = None, + anonymous: bool = False, + token_file: Optional[Union[str, Path]] = None, + provider: str = "federation-eams", +): + init_fn = getattr(cwms_module, "init_session", None) + if init_fn is None: + init_fn = cwms_module.api.init_session + + if anonymous: + return init_fn(api_root=api_root, api_key=None) + + token = get_saved_login_token(token_file=token_file, provider=provider) + if token: + return init_fn(api_root=api_root, token=token) + + resolved_api_key = None + if api_key_loc is not None or api_key is not None: + resolved_api_key = get_api_key(api_key, api_key_loc) + + return init_fn(api_root=api_root, api_key=resolved_api_key) + + def log_scoped_read_hint( *, - api_key: Optional[str], + credential_kind: Optional[str], anonymous: bool, office: str, action: str, resource: str = "content", ) -> None: - if anonymous or not api_key: + if anonymous or not credential_kind: return + credential_text = ( + "a saved login token was sent" + if credential_kind == "token" + else "an API key was sent" + ) py_logging.warning( colors.c( - f"Access scope hint: a key was sent for this {action} request in office {office}. " - f"If you need to view {resource} outside that key's access scope, retry with " - f"--anonymous or remove the configured API key. Docs: {DOCS_BASE_URL}/cli/blob.html#blob-auth-scope", + f"Access scope hint: {credential_text} for this {action} request in office {office}. " + f"If you need to view {resource} outside that credential's access scope, retry with " + f"--anonymous or remove the configured credential. Docs: {DOCS_BASE_URL}/cli/blob.html#blob-auth-scope", "yellow", bright=True, ) diff --git a/cwmscli/utils/auth.py b/cwmscli/utils/auth.py new file mode 100644 index 0000000..d6f5a9c --- /dev/null +++ b/cwmscli/utils/auth.py @@ -0,0 +1,693 @@ +import datetime as dt +import hashlib +import http.server +import json +import os +import re +import secrets +import socketserver +import time +import urllib.parse +import webbrowser +from base64 import urlsafe_b64encode +from collections.abc import Callable +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Dict, Optional + +DEFAULT_CLIENT_ID = "cwms" +DEFAULT_CDA_API_ROOT = "https://cwms-data.usace.army.mil/cwms-data" +DEFAULT_OIDC_BASE_URL = ( + "https://identity-test.cwbi.us/auth/realms/cwbi/protocol/openid-connect" +) +DEFAULT_REDIRECT_HOST = "localhost" +DEFAULT_REDIRECT_PORT = 5555 +DEFAULT_SCOPE = "openid profile" +DEFAULT_TIMEOUT_SECONDS = 30 +PORT_SEARCH_ATTEMPTS = 4 +PROVIDER_IDP_HINTS = { + "federation-eams": "federation-eams", + "login.gov": "login.gov", +} + + +class AuthError(Exception): + pass + + +class LoginTimeoutError(AuthError): + pass + + +class CallbackBindError(AuthError): + pass + + +@dataclass(frozen=True) +class OIDCLoginConfig: + client_id: str = DEFAULT_CLIENT_ID + oidc_base_url: str = DEFAULT_OIDC_BASE_URL + authorization_endpoint_url: Optional[str] = None + token_endpoint_url: Optional[str] = None + redirect_host: str = DEFAULT_REDIRECT_HOST + redirect_port: int = DEFAULT_REDIRECT_PORT + scope: str = DEFAULT_SCOPE + provider: str = "federation-eams" + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS + verify: Optional[str] = None + + @property + def redirect_uri(self) -> str: + return f"http://{self.redirect_host}:{self.redirect_port}" + + @property + def authorization_endpoint(self) -> str: + if self.authorization_endpoint_url: + return self.authorization_endpoint_url + return f"{self.oidc_base_url}/auth" + + @property + def token_endpoint(self) -> str: + if self.token_endpoint_url: + return self.token_endpoint_url + return f"{self.oidc_base_url}/token" + + @property + def provider_hint(self) -> str: + return PROVIDER_IDP_HINTS[self.provider] + + +class _SingleRequestServer(socketserver.TCPServer): + allow_reuse_address = True + + def __init__(self, server_address, handler_cls): + super().__init__(server_address, handler_cls) + self.callback_params: Optional[Dict[str, str]] = None + + +def _callback_success_page() -> bytes: + template_path = Path(__file__).with_name("callback_success.html") + return template_path.read_bytes() + + +class _CallbackHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed_path = urllib.parse.urlparse(self.path) + query_params = urllib.parse.parse_qs(parsed_path.query) + flattened = {key: values[0] for key, values in query_params.items() if values} + self.server.callback_params = flattened # type: ignore[attr-defined] + + self.send_response(200, "OK") + body = _callback_success_page() + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: + return + + +def default_token_file(provider: str) -> Path: + config_root = os.getenv("XDG_CONFIG_HOME") + if config_root: + base_dir = Path(config_root) + else: + base_dir = Path.home() / ".config" + return base_dir / "cwms-cli" / "auth" / f"{provider}.json" + + +def _oidc_cache_file() -> Path: + return default_token_file("discovery").with_name("oidc-cache.json") + + +def _normalize_api_root(api_root: str) -> str: + return api_root.rstrip("/") + + +def _swagger_docs_url(api_root: str) -> str: + return f"{_normalize_api_root(api_root)}/swagger-docs" + + +def _is_local_host(hostname: Optional[str]) -> bool: + return hostname in {"localhost", "127.0.0.1", "::1"} + + +def _realm_base_from_url(candidate: str) -> Optional[str]: + if not candidate: + return None + parsed = urllib.parse.urlparse(candidate) + if not parsed.scheme or not parsed.netloc: + return None + matches = re.findall( + r"(/auth/realms/[^/]+/protocol/openid-connect)(?:/(?:auth|token))?$", + parsed.path, + ) + if matches: + return f"{parsed.scheme}://{parsed.netloc}{matches[-1]}" + + if "/.well-known/openid-configuration" in parsed.path: + base_path = parsed.path.split("/.well-known/openid-configuration", 1)[0] + if base_path: + return ( + f"{parsed.scheme}://{parsed.netloc}{base_path}/protocol/openid-connect" + ) + return None + + +def _extract_oidc_base_url_from_openapi(document: Dict[str, Any]) -> str: + schemes = document.get("components", {}).get("securitySchemes", {}) + oidc = schemes.get("OpenIDConnect", {}) + + candidates = [] + openid_url = oidc.get("openIdConnectUrl") + if isinstance(openid_url, str): + candidates.append(openid_url) + + flows = oidc.get("flows", {}) + for flow in flows.values(): + if not isinstance(flow, dict): + continue + authorization_url = flow.get("authorizationUrl") + token_url = flow.get("tokenUrl") + if isinstance(authorization_url, str): + candidates.append(authorization_url) + if isinstance(token_url, str): + candidates.append(token_url) + + for candidate in candidates: + base = _realm_base_from_url(candidate) + if base: + return base + + raise AuthError( + "CDA OpenAPI spec did not contain a usable OpenID Connect configuration." + ) + + +def _well_known_url_from_oidc_base_url(oidc_base_url: str) -> Optional[str]: + parsed = urllib.parse.urlparse(oidc_base_url) + marker = "/protocol/openid-connect" + if marker not in parsed.path: + return None + realm_path = parsed.path.split(marker, 1)[0] + return urllib.parse.urlunparse( + ( + parsed.scheme, + parsed.netloc, + f"{realm_path}/.well-known/openid-configuration", + "", + "", + "", + ) + ) + + +def _oidc_base_url_from_well_known_url(well_known_url: str) -> Optional[str]: + parsed = urllib.parse.urlparse(well_known_url) + marker = "/.well-known/openid-configuration" + if marker not in parsed.path: + return None + realm_path = parsed.path.split(marker, 1)[0] + return urllib.parse.urlunparse( + ( + parsed.scheme, + parsed.netloc, + f"{realm_path}/protocol/openid-connect", + "", + "", + "", + ) + ) + + +def _local_oidc_base_url_candidates(api_root: str, oidc_base_url: str) -> list[str]: + candidates = [oidc_base_url] + parsed_root = urllib.parse.urlparse(api_root) + parsed_oidc = urllib.parse.urlparse(oidc_base_url) + + if not _is_local_host(parsed_root.hostname): + return candidates + if _is_local_host(parsed_oidc.hostname): + return candidates + + ports = [] + for port in (parsed_oidc.port, parsed_root.port, 8082, 8081): + if port and port not in ports: + ports.append(port) + + scheme = parsed_root.scheme or parsed_oidc.scheme or "http" + host = parsed_root.hostname or "localhost" + for port in ports: + local_candidate = urllib.parse.urlunparse( + ( + scheme, + f"{host}:{port}", + parsed_oidc.path, + "", + "", + "", + ) + ) + if local_candidate not in candidates: + candidates.append(local_candidate) + return candidates + + +def _select_reachable_oidc_discovery( + api_root: str, + discovery_url: str, + verify: Optional[str] = None, +) -> Dict[str, Any]: + import requests + + base_url = _oidc_base_url_from_well_known_url(discovery_url) + candidates = [discovery_url] + if base_url: + candidates = [ + _well_known_url_from_oidc_base_url(candidate) or discovery_url + for candidate in _local_oidc_base_url_candidates(api_root, base_url) + ] + + for candidate in candidates: + try: + response = requests.get( + candidate, + verify=_verify_setting(verify), + timeout=10, + ) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError): + continue + + if ( + isinstance(payload, dict) + and payload.get("authorization_endpoint") + and payload.get("token_endpoint") + ): + return payload + raise AuthError( + "OpenID discovery document was not reachable from any candidate URL." + ) + + +def _load_oidc_cache() -> Dict[str, str]: + cache_file = _oidc_cache_file() + if not cache_file.exists(): + return {} + try: + with cache_file.open("r", encoding="utf-8") as f: + payload = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + entries = payload.get("entries", {}) + return entries if isinstance(entries, dict) else {} + + +def _save_oidc_cache(entries: Dict[str, str]) -> None: + cache_file = _oidc_cache_file() + cache_file.parent.mkdir(parents=True, exist_ok=True) + with cache_file.open("w", encoding="utf-8") as f: + json.dump( + {"saved_at": time.time(), "entries": entries}, f, indent=2, sort_keys=True + ) + f.write("\n") + + +def discover_oidc_configuration( + api_root: str, + verify: Optional[str] = None, +) -> Dict[str, str]: + import requests + + normalized_root = _normalize_api_root(api_root) + cache = _load_oidc_cache() + + try: + response = requests.get( + _swagger_docs_url(normalized_root), + verify=_verify_setting(verify), + timeout=30, + ) + response.raise_for_status() + document = response.json() + oidc_base_url = _extract_oidc_base_url_from_openapi(document) + discovery_url = _well_known_url_from_oidc_base_url(oidc_base_url) + if not discovery_url: + raise AuthError( + "Could not derive an OpenID discovery URL from CDA metadata." + ) + discovery = _select_reachable_oidc_discovery( + normalized_root, + discovery_url, + verify=verify, + ) + discovered_base_url = _realm_base_from_url( + str(discovery.get("authorization_endpoint", "")) + ) + if not discovered_base_url: + issuer = discovery.get("issuer") + if isinstance(issuer, str) and issuer: + discovered_base_url = _oidc_base_url_from_well_known_url( + issuer.rstrip("/") + "/.well-known/openid-configuration" + ) + if not discovered_base_url: + discovered_base_url = _oidc_base_url_from_well_known_url(discovery_url) + if not discovered_base_url: + discovered_base_url = oidc_base_url + cache[normalized_root] = oidc_base_url + _save_oidc_cache(cache) + return { + "oidc_base_url": discovered_base_url, + "authorization_endpoint": discovery["authorization_endpoint"], + "token_endpoint": discovery["token_endpoint"], + } + except requests.RequestException as e: + cached = cache.get(normalized_root) + if cached: + return { + "oidc_base_url": cached, + "authorization_endpoint": f"{cached}/auth", + "token_endpoint": f"{cached}/token", + } + raise AuthError( + f"Could not retrieve CDA OpenAPI spec from {_swagger_docs_url(normalized_root)}: {e}" + ) from e + except ValueError as e: + cached = cache.get(normalized_root) + if cached: + return { + "oidc_base_url": cached, + "authorization_endpoint": f"{cached}/auth", + "token_endpoint": f"{cached}/token", + } + raise AuthError( + f"CDA OpenAPI spec at {_swagger_docs_url(normalized_root)} was not valid JSON." + ) from e + + +def discover_oidc_base_url( + api_root: str, + verify: Optional[str] = None, +) -> str: + return discover_oidc_configuration(api_root=api_root, verify=verify)[ + "oidc_base_url" + ] + + +def token_expiry_text(token: Dict[str, Any]) -> Optional[str]: + expires_at = token.get("expires_at") + if expires_at is None: + return None + try: + expiry = dt.datetime.fromtimestamp(float(expires_at), tz=dt.timezone.utc) + except (TypeError, ValueError, OSError): + return None + return expiry.isoformat() + + +def _local_timestamp_text(expires_at: Any) -> Optional[str]: + try: + expiry = dt.datetime.fromtimestamp(float(expires_at), tz=dt.timezone.utc) + except (TypeError, ValueError, OSError): + return None + local_expiry = expiry.astimezone() + hour = local_expiry.hour % 12 or 12 + return ( + f"{local_expiry:%B} {local_expiry.day}, {local_expiry.year} " + f"at {hour}:{local_expiry:%M %p} {local_expiry:%Z}" + ) + + +def refresh_token_expiry_text(token: Dict[str, Any]) -> Optional[str]: + return _local_timestamp_text(token.get("refresh_expires_at")) + + +def load_saved_login(token_file: Path) -> Dict[str, Any]: + try: + with token_file.open("r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError as e: + raise AuthError(f"No saved login found at {token_file}") from e + except json.JSONDecodeError as e: + raise AuthError(f"Saved login file is not valid JSON: {token_file}") from e + + +def save_login( + token_file: Path, config: OIDCLoginConfig, token: Dict[str, Any] +) -> None: + token_file.parent.mkdir(parents=True, exist_ok=True) + payload = { + "saved_at": dt.datetime.now(tz=dt.timezone.utc).isoformat(), + "client_id": config.client_id, + "oidc_base_url": config.oidc_base_url, + "authorization_endpoint": config.authorization_endpoint, + "token_endpoint": config.token_endpoint, + "provider": config.provider, + "scope": config.scope, + "redirect_uri": config.redirect_uri, + "token": token, + } + with token_file.open("w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + f.write("\n") + os.chmod(token_file, 0o600) + + +def _verify_setting(verify: Optional[str]) -> Any: + if verify: + return verify + return True + + +def _is_address_in_use_error(error: OSError) -> bool: + if getattr(error, "errno", None) in {98, 10048}: + return True + if getattr(error, "winerror", None) == 10048: + return True + return "address already in use" in str(error).lower() + + +def _select_callback_config(config: OIDCLoginConfig) -> OIDCLoginConfig: + last_error: Optional[OSError] = None + for offset in range(PORT_SEARCH_ATTEMPTS): + candidate = replace(config, redirect_port=config.redirect_port + offset) + try: + with _SingleRequestServer( + (candidate.redirect_host, candidate.redirect_port), _CallbackHandler + ): + return candidate + except OSError as e: + if not _is_address_in_use_error(e): + raise CallbackBindError( + f"Could not listen on {candidate.redirect_uri}. " + "Try a different callback port with --redirect-port, for example " + "`cwms-cli login --redirect-port 5555`." + ) from e + last_error = e + + final_port = config.redirect_port + PORT_SEARCH_ATTEMPTS - 1 + raise CallbackBindError( + f"Could not listen on http://{config.redirect_host}:{config.redirect_port} " + f"through http://{config.redirect_host}:{final_port} because those ports are already in use. " + "Another `cwms-cli login` instance may still be running. Stop it before continuing, " + "or try a different callback port with --redirect-port, for example " + f"`cwms-cli login --redirect-port {final_port + 1}`." + ) from last_error + + +def _generate_token(length: int) -> str: + alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def _create_s256_code_challenge(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + return urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _token_expiry_timestamp(expires_in: Any) -> Optional[float]: + try: + seconds = int(expires_in) + except (TypeError, ValueError): + return None + return time.time() + seconds + + +def _normalize_token_payload(token: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(token) + if "expires_at" not in normalized: + expires_at = _token_expiry_timestamp(normalized.get("expires_in")) + if expires_at is not None: + normalized["expires_at"] = expires_at + if "refresh_expires_at" not in normalized: + refresh_expires_at = _token_expiry_timestamp( + normalized.get("refresh_expires_in") + ) + if refresh_expires_at is not None: + normalized["refresh_expires_at"] = refresh_expires_at + return normalized + + +def _request_token( + url: str, + data: Dict[str, Any], + verify: Optional[str] = None, +) -> Dict[str, Any]: + import requests + + response = requests.post( + url, + data=data, + verify=_verify_setting(verify), + timeout=30, + ) + try: + payload = response.json() + except ValueError as e: + raise AuthError( + f"Token endpoint returned non-JSON response with status {response.status_code}" + ) from e + if response.ok: + return _normalize_token_payload(payload) + + if isinstance(payload, dict): + error = payload.get("error") + description = payload.get("error_description") + else: + error = None + description = None + details = error or f"HTTP {response.status_code}" + if description: + details = f"{details}: {description}" + raise AuthError(f"Token request failed: {details}") + + +def _receive_callback(config: OIDCLoginConfig) -> Dict[str, str]: + try: + with _SingleRequestServer( + (config.redirect_host, config.redirect_port), _CallbackHandler + ) as server: + server.timeout = 1 + deadline = time.monotonic() + config.timeout_seconds + while server.callback_params is None: + server.handle_request() + if time.monotonic() >= deadline: + raise LoginTimeoutError( + f"Timed out waiting for the login callback on {config.redirect_uri}" + ) + return server.callback_params + except OSError as e: + if _is_address_in_use_error(e): + raise CallbackBindError( + f"Could not listen on {config.redirect_uri} because that port is already in use. " + "Another `cwms-cli login` instance may still be running. Stop it before continuing, " + "or try a different callback port with --redirect-port, for example " + "`cwms-cli login --redirect-port 5555`." + ) from e + raise CallbackBindError( + f"Could not listen on {config.redirect_uri}. " + "Try a different callback port with --redirect-port, for example " + "`cwms-cli login --redirect-port 5555`." + ) from e + + +def login_with_browser( + config: OIDCLoginConfig, + launch_browser: bool = True, + authorization_url_callback: Optional[Callable[[str], None]] = None, +) -> Dict[str, Any]: + config = _select_callback_config(config) + code_verifier = _generate_token(48) + code_challenge = _create_s256_code_challenge(code_verifier) + state = _generate_token(30) + authorization_params = { + "response_type": "code", + "client_id": config.client_id, + "redirect_uri": config.redirect_uri, + "scope": config.scope, + "state": state, + "code_challenge_method": "S256", + "code_challenge": code_challenge, + "kc_idp_hint": config.provider_hint, + } + authorization_url = ( + f"{config.authorization_endpoint}?" + f"{urllib.parse.urlencode(authorization_params)}" + ) + if authorization_url_callback is not None: + authorization_url_callback(authorization_url) + + if launch_browser: + opened = webbrowser.open(authorization_url) + if not opened: + launch_browser = False + + callback_params = _receive_callback(config) + if callback_params.get("error"): + raise AuthError( + f"Identity provider returned an error: {callback_params['error']}" + ) + if callback_params.get("state") != state: + raise AuthError("OIDC state mismatch in login callback") + if "code" not in callback_params: + raise AuthError("OIDC callback did not include an authorization code") + + token = _request_token( + config.token_endpoint, + data={ + "grant_type": "authorization_code", + "client_id": config.client_id, + "code": callback_params["code"], + "redirect_uri": config.redirect_uri, + "code_verifier": code_verifier, + }, + verify=config.verify, + ) + return { + "authorization_url": authorization_url, + "browser_opened": launch_browser, + "config": config, + "token": token, + } + + +def refresh_saved_login( + token_file: Path, + verify: Optional[str] = None, +) -> Dict[str, Any]: + saved = load_saved_login(token_file) + token = saved.get("token", {}) + refresh_token = token.get("refresh_token") + if not refresh_token: + raise AuthError(f"No refresh token is stored in {token_file}") + + refreshed = _request_token( + f"{saved['oidc_base_url']}/token", + data={ + "grant_type": "refresh_token", + "client_id": saved["client_id"], + "refresh_token": refresh_token, + "scope": saved.get("scope"), + }, + verify=verify, + ) + if "refresh_token" not in refreshed: + refreshed["refresh_token"] = refresh_token + return { + "config": OIDCLoginConfig( + client_id=saved["client_id"], + oidc_base_url=saved["oidc_base_url"], + authorization_endpoint_url=saved.get("authorization_endpoint"), + token_endpoint_url=saved.get("token_endpoint"), + provider=saved["provider"], + scope=saved["scope"], + redirect_host=urllib.parse.urlparse(saved["redirect_uri"]).hostname + or DEFAULT_REDIRECT_HOST, + redirect_port=urllib.parse.urlparse(saved["redirect_uri"]).port + or DEFAULT_REDIRECT_PORT, + verify=verify, + ), + "token": refreshed, + } diff --git a/cwmscli/utils/callback_success.html b/cwmscli/utils/callback_success.html new file mode 100644 index 0000000..723da5d --- /dev/null +++ b/cwmscli/utils/callback_success.html @@ -0,0 +1,160 @@ + + + + + + + USACE CWMS Login Complete + + + + +
+

CWMS Authentication

+

Login complete.

+

+ Your browser has finished the sign-in step for Corps Water Management Systems. + Return to the terminal to continue, then close this tab when you are done. +

+
+ +
+

+ You are now authorized to issue protected cwms-cli commands as the user you authenticated with. +

+
+
U.S. Army Corps of Engineers
+
+ CWMS CLI Docs : https://cwms-cli.readthedocs.io/ +
+
+
+ + + diff --git a/cwmscli/utils/click_help.py b/cwmscli/utils/click_help.py index 3eaf8a8..a5e0c47 100644 --- a/cwmscli/utils/click_help.py +++ b/cwmscli/utils/click_help.py @@ -42,6 +42,7 @@ def _docs_url_for_context(ctx: click.Context) -> Optional[str]: command = (ctx.info_name or getattr(ctx.command, "name", None) or "").strip() page_map = { "blob": "blob", + "login": "login", "update": "update", "users": "users", "version": "version", diff --git a/docs/cli/api_arguments.rst b/docs/cli/api_arguments.rst index 73718a4..e33620b 100644 --- a/docs/cli/api_arguments.rst +++ b/docs/cli/api_arguments.rst @@ -16,6 +16,9 @@ the first line of a file instead of passing the key inline. This is currently available on the USGS subcommands and ``shefcritimport``. These are the standard API inputs used by commands such as ``csv2cwms``. +If you have already run ``cwms-cli login``, cwms-cli will prefer the saved +access token from ``~/.config/cwms-cli/auth/federation-eams.json`` over an API +key. If no saved token is available, it falls back to the configured API key. Environment setup ----------------- @@ -56,6 +59,8 @@ Notes - When ``--api-key-loc`` is provided for a command that supports it, the key read from that file takes precedence over ``--api-key`` and over a ``CDA_API_KEY`` value coming from the environment. +- When a saved login token exists, cwms-cli uses that token before consulting + ``--api-key``, ``--api-key-loc``, or ``CDA_API_KEY``. - For CDA-backed regex filters such as ``--like``, ``--location-kind-like``, and ``--timeseries-id-regex``, see the :doc:`CWMS Data API regular expression guide `. - Commands may still expose additional non-API options such as config files, timezone selection, or dry-run behavior. diff --git a/docs/cli/blob.rst b/docs/cli/blob.rst index 39fe484..85d84c0 100644 --- a/docs/cli/blob.rst +++ b/docs/cli/blob.rst @@ -31,7 +31,7 @@ Quick reference - ``blob upload`` supports single-file upload and directory upload. - ``blob download`` writes the returned blob content to disk using the server media type when possible. -- ``blob list`` and ``blob download`` send an API key if one is configured, unless ``--anonymous`` is used. +- ``blob list`` and ``blob download`` send a saved login token if one exists, otherwise an API key if one is configured, unless ``--anonymous`` is used. - ``blob list --limit`` caps displayed rows, and sets the blob endpoint request page size unless ``--page-size`` is provided to override the fetch size. - Directory upload stops before sending anything if generated blob IDs would collide. - ``blob upload --overwrite``: To replace existing blobs. @@ -71,12 +71,13 @@ Example: Auth and scope -------------- -Blob reads can behave differently depending on whether an API key is sent. +Blob reads can behave differently depending on whether credentials are sent. -- If ``--api-key`` is provided, or ``CDA_API_KEY`` is set, cwms-cli sends that key. -- If no key is provided, blob read commands default to anonymous access. -- Use ``--anonymous`` on ``blob download`` or ``blob list`` to force an anonymous read even when a key is configured. -- If a keyed read fails because the key scope is narrower than the content you are trying to view, the CLI logs a scope hint telling you to retry with ``--anonymous`` or remove the configured key. +- If a saved login token exists, cwms-cli sends that token first. +- Otherwise, if ``--api-key`` is provided, or ``CDA_API_KEY`` is set, cwms-cli sends that key. +- If no token or key is available, blob read commands default to anonymous access. +- Use ``--anonymous`` on ``blob download`` or ``blob list`` to force an anonymous read even when a token or key is configured. +- If an authenticated read fails because the credential scope is narrower than the content you are trying to view, the CLI logs a scope hint telling you to retry with ``--anonymous`` or remove the configured credential. Examples: diff --git a/docs/cli/login.rst b/docs/cli/login.rst new file mode 100644 index 0000000..e98038b --- /dev/null +++ b/docs/cli/login.rst @@ -0,0 +1,63 @@ +Login command +============= + +Use ``cwms-cli login`` to start the CWBI OIDC PKCE flow and save the resulting +session for reuse. The command already has working defaults for the provider, +client ID, scope, callback host, callback port, timeout, and the +provider-specific token file path under ``~/.config/cwms-cli/auth/``. + +By default, cwms-cli discovers the OIDC realm from the target CDA API's OpenAPI +spec at ``/swagger-docs`` and caches the discovered value locally. + +By default, the callback listener starts at port ``5555`` and automatically +tries up to three subsequent ports if earlier ones are already in use. + +If a browser cannot be opened automatically, the command prints the +authorization URL so the user can continue manually. + +Examples +-------- + +- Use the default login settings: + + ``cwms-cli login`` + +- Print the authorization URL instead of opening a browser: + + ``cwms-cli login --no-browser`` + +- Use the ``login.gov`` identity provider hint: + + ``cwms-cli login --provider login.gov`` + +- Save the session to a custom file: + + ``cwms-cli login --token-file ~/.config/cwms-cli/auth/custom-login.json`` + +- Change the local callback listener host and port: + + ``cwms-cli login --redirect-host 127.0.0.1 --redirect-port 6000`` + +- Override the client ID and scopes: + + ``cwms-cli login --client-id cwms --scope "openid profile"`` + +- Discover OIDC configuration from a different CDA target: + + ``cwms-cli login --api-root https://cwms-data.usace.army.mil/cwms-data`` + +- Wait longer for the callback during manual authentication: + + ``cwms-cli login --timeout 300 --no-browser`` + +- Use a custom CA bundle for TLS verification: + + ``cwms-cli login --ca-bundle /path/to/ca-bundle.pem`` + +- Refresh an existing saved session without opening a browser: + + ``cwms-cli login --refresh`` + +.. click:: cwmscli.commands.commands_cwms:login_cmd + :prog: cwms-cli login + :nested: full diff --git a/docs/index.rst b/docs/index.rst index 1e53bb2..2402c4b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,6 +58,9 @@ Contents cli/csv2cwms cli/blob + cli/login + cli/clob + cli/login cli/clob cli/users cli/load_location_ids_all diff --git a/poetry.lock b/poetry.lock index 82404b7..e0e4377 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "black" @@ -6,6 +6,7 @@ version = "24.10.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -52,6 +53,7 @@ version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, @@ -63,6 +65,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -74,6 +77,7 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -212,6 +216,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -226,10 +231,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "cwms-python" @@ -237,6 +244,7 @@ version = "1.0.7" description = "Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data" optional = false python-versions = "<4.0,>=3.9" +groups = ["main", "dev"] files = [ {file = "cwms_python-1.0.7-py3-none-any.whl", hash = "sha256:219548fcc3fbf1986c0007dc6ec7036608cc59b5d8b42b68baefcf1312f60d5d"}, {file = "cwms_python-1.0.7.tar.gz", hash = "sha256:2bd0bb6a6e3c6bbdc9ad2454039b61cfb547d1bed37c7aaddf999aa854010e92"}, @@ -253,6 +261,7 @@ version = "0.4.0" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, @@ -264,6 +273,7 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -275,6 +285,8 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -292,6 +304,8 @@ version = "3.19.1" description = "A platform independent file lock." optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.9\"" files = [ {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, @@ -303,6 +317,8 @@ version = "3.25.2" description = "A platform independent file lock." optional = false python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version >= \"3.10\"" files = [ {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, @@ -314,6 +330,7 @@ version = "0.1.29" description = "Python wrapper for the HEC-DSS file database C library." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "hecdss-0.1.29-py3-none-any.whl", hash = "sha256:6884e5a47c98a761c3429ec4aa3ba382e8a6eb55f1d5ac40416ecb0c10757848"}, {file = "hecdss-0.1.29.tar.gz", hash = "sha256:39289029d0ec6791e6423f0b91617b473c452fe340448452d8134011ad8f8624"}, @@ -329,6 +346,7 @@ version = "2.6.15" description = "File identification library for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, @@ -343,6 +361,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -357,6 +376,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -368,6 +388,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -382,6 +403,8 @@ version = "0.8.1" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, @@ -481,6 +504,7 @@ version = "1.4.2" description = "Read settings from config files" optional = false python-versions = ">=3.7.1,<4.0.0" +groups = ["dev"] files = [ {file = "maison-1.4.2-py3-none-any.whl", hash = "sha256:b63fe6751494935fc453dfb76319af223e4cb8bab32ac5464c2a9ca0edda8765"}, {file = "maison-1.4.2.tar.gz", hash = "sha256:d2abac30a5c6a0749526d70ae95a63c6acf43461a1c10e51410b36734e053ec7"}, @@ -497,6 +521,7 @@ version = "1.19.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, @@ -558,6 +583,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -569,6 +595,7 @@ version = "1.10.0" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, @@ -580,6 +607,8 @@ version = "2.0.2" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" files = [ {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, @@ -634,6 +663,8 @@ version = "2.4.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" +groups = ["main", "dev"] +markers = "python_version >= \"3.11\"" files = [ {file = "numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db"}, {file = "numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0"}, @@ -715,6 +746,7 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, @@ -726,6 +758,7 @@ version = "2.3.3" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, @@ -825,6 +858,7 @@ version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, @@ -842,6 +876,7 @@ version = "4.4.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, @@ -858,6 +893,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -873,6 +909,7 @@ version = "3.8.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, @@ -891,6 +928,7 @@ version = "1.10.26" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546"}, {file = "pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572"}, @@ -944,6 +982,7 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -958,6 +997,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -981,6 +1021,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -995,6 +1036,7 @@ version = "1.2.1" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502"}, {file = "python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e"}, @@ -1014,6 +1056,7 @@ version = "2026.1.post1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["main", "dev"] files = [ {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, @@ -1025,6 +1068,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1107,6 +1151,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1128,6 +1173,7 @@ version = "1.0.0" description = "A utility belt for advanced users of python-requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main", "dev"] files = [ {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, @@ -1142,6 +1188,7 @@ version = "0.91.0" description = "ruyaml is a fork of ruamel.yaml" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "ruyaml-0.91.0-py3-none-any.whl", hash = "sha256:50e0ee3389c77ad340e209472e0effd41ae0275246df00cdad0a067532171755"}, {file = "ruyaml-0.91.0.tar.gz", hash = "sha256:6ce9de9f4d082d696d3bde264664d1bcdca8f5a9dff9d1a1f1a127969ab871ab"}, @@ -1160,19 +1207,20 @@ version = "82.0.1" description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.13.0)"] -core = ["importlib_metadata (>=6)", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.18.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "six" @@ -1180,6 +1228,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1191,6 +1240,7 @@ version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["dev"] files = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1202,6 +1252,8 @@ version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, @@ -1258,6 +1310,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -1269,6 +1322,7 @@ version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["main", "dev"] files = [ {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, @@ -1280,16 +1334,17 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" @@ -1297,6 +1352,7 @@ version = "21.2.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, @@ -1318,6 +1374,7 @@ version = "1.16.1" description = "A simple opionated yaml formatter that keeps your comments!" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "yamlfix-1.16.1-py3-none-any.whl", hash = "sha256:8c505ca27cf19181ca8943101b56b8e4ad58f47aa792fbab01339ededaddb7d2"}, {file = "yamlfix-1.16.1.tar.gz", hash = "sha256:f49ba70e457a1add6724a6859505d22f7f222f56f7e31f37822c530fc2e7ec94"}, @@ -1329,6 +1386,6 @@ maison = ">=1.4.0,<1.4.3" ruyaml = ">=0.91.0" [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.9" -content-hash = "50037b9fdc094a37656c7b4e7e54c9e268f812d6d4a1140abb6d27e695986efa" +content-hash = "056bcf91885aac3e6fe947610dbf8925f3ab359f94382b8dcb12035db4610472" diff --git a/pyproject.toml b/pyproject.toml index f2024e0..7eaacac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,9 @@ version = "0.4.0" packages = [ { include = "cwmscli" }, ] +include = [ + "cwmscli/utils/*.html", +] description = "Command line utilities for Corps Water Management Systems (CWMS) python scripts. This is a collection of shared scripts across the enterprise Water Management Enterprise System (WMES) teams." readme = "README.md" license = "LICENSE" @@ -17,6 +20,7 @@ authors = ["Eric Novotny ", "Charles Graham = 400: + raise FakeRequests.RequestException(f"HTTP {self.status_code}") + + def json(self): + return self._payload + + class FakeRequests: + class RequestException(Exception): + pass + + @staticmethod + def get(url, verify=True, timeout=30): + if url == "http://localhost:8081/cwms-data/swagger-docs": + return FakeResponse(swagger_document) + if ( + url + == "http://auth:8081/auth/realms/cwms/.well-known/openid-configuration" + ): + raise FakeRequests.RequestException("unreachable") + if ( + url + == "http://localhost:8081/auth/realms/cwms/.well-known/openid-configuration" + ): + raise FakeRequests.RequestException("still unreachable") + if ( + url + == "http://localhost:8082/auth/realms/cwms/.well-known/openid-configuration" + ): + return FakeResponse( + { + "issuer": "http://localhost:8082/auth/realms/cwms", + "authorization_endpoint": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect/token", + } + ) + raise AssertionError(f"Unexpected URL: {url}") + + monkeypatch.setitem(__import__("sys").modules, "requests", FakeRequests) + + assert discover_oidc_configuration("http://localhost:8081/cwms-data") == { + "oidc_base_url": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect", + "authorization_endpoint": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect/token", + } + + +def test_discover_oidc_base_url_prefers_reachable_localhost_oidc_endpoint( + monkeypatch, tmp_path +): + monkeypatch.setattr( + "cwmscli.utils.auth._oidc_cache_file", lambda: tmp_path / "oidc-cache.json" + ) + + swagger_document = { + "components": { + "securitySchemes": { + "OpenIDConnect": { + "type": "openIdConnect", + "openIdConnectUrl": "http://auth:8081/auth/realms/cwms/.well-known/openid-configuration", + } + } + } + } + + class FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise FakeRequests.RequestException(f"HTTP {self.status_code}") + + def json(self): + return self._payload + + class FakeRequests: + class RequestException(Exception): + pass + + @staticmethod + def get(url, verify=True, timeout=30): + if url == "http://localhost:8081/cwms-data/swagger-docs": + return FakeResponse(swagger_document) + if ( + url + == "http://auth:8081/auth/realms/cwms/.well-known/openid-configuration" + ): + raise FakeRequests.RequestException("unreachable") + if ( + url + == "http://localhost:8081/auth/realms/cwms/.well-known/openid-configuration" + ): + raise FakeRequests.RequestException("still unreachable") + if ( + url + == "http://localhost:8082/auth/realms/cwms/.well-known/openid-configuration" + ): + return FakeResponse( + { + "authorization_endpoint": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:8082/auth/realms/cwms/protocol/openid-connect/token", + } + ) + raise AssertionError(f"Unexpected URL: {url}") + + monkeypatch.setitem(__import__("sys").modules, "requests", FakeRequests) + + assert ( + discover_oidc_base_url("http://localhost:8081/cwms-data") + == "http://localhost:8082/auth/realms/cwms/protocol/openid-connect" + ) diff --git a/tests/utils/test_credentials.py b/tests/utils/test_credentials.py new file mode 100644 index 0000000..9c2f623 --- /dev/null +++ b/tests/utils/test_credentials.py @@ -0,0 +1,127 @@ +from pathlib import Path + +from cwmscli.utils import get_saved_login_token, init_cwms_session + + +def test_get_saved_login_token_returns_access_token(monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.auth.default_token_file", lambda provider: Path("/tmp/test.json") + ) + monkeypatch.setattr( + "cwmscli.utils.auth.load_saved_login", + lambda path: {"token": {"access_token": "saved-token"}}, + ) + + assert get_saved_login_token() == "saved-token" + + +def test_init_cwms_session_falls_back_to_api_key(monkeypatch): + calls = [] + + class FakeCwms: + @staticmethod + def init_session(api_root, api_key=None, token=None): + calls.append((api_root, api_key, token)) + return "session" + + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: None + ) + + result = init_cwms_session( + FakeCwms, + api_root="https://example.test/cwms-data", + api_key="apikey 123", + ) + + assert result == "session" + assert calls == [("https://example.test/cwms-data", "apikey 123", None)] + + +def test_init_cwms_session_prefers_saved_token(monkeypatch): + calls = [] + + class FakeCwms: + @staticmethod + def init_session(api_root, api_key=None, token=None): + calls.append((api_root, api_key, token)) + return "session" + + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: "saved-token" + ) + + result = init_cwms_session( + FakeCwms, + api_root="https://example.test/cwms-data", + api_key="apikey 123", + ) + + assert result == "session" + assert calls == [("https://example.test/cwms-data", None, "saved-token")] + + +def test_get_saved_login_token_ignores_expired_token(monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.auth.default_token_file", lambda provider: Path("/tmp/test.json") + ) + monkeypatch.setattr( + "cwmscli.utils.auth.load_saved_login", + lambda path: {"token": {"access_token": "saved-token", "expires_at": 1}}, + ) + + assert get_saved_login_token() is None + + +def test_get_saved_login_token_refreshes_expired_token(monkeypatch): + saved = {} + + monkeypatch.setattr( + "cwmscli.utils.auth.default_token_file", lambda provider: Path("/tmp/test.json") + ) + monkeypatch.setattr( + "cwmscli.utils.auth.load_saved_login", + lambda path: {"token": {"access_token": "stale-token", "expires_at": 1}}, + ) + monkeypatch.setattr( + "cwmscli.utils.auth.refresh_saved_login", + lambda token_file: { + "config": "config-object", + "token": {"access_token": "fresh-token", "refresh_token": "refresh"}, + }, + ) + monkeypatch.setattr( + "cwmscli.utils.auth.save_login", + lambda token_file, config, token: saved.update( + {"token_file": token_file, "config": config, "token": token} + ), + ) + + assert get_saved_login_token() == "fresh-token" + assert saved == { + "token_file": Path("/tmp/test.json"), + "config": "config-object", + "token": {"access_token": "fresh-token", "refresh_token": "refresh"}, + } + + +def test_get_saved_login_token_falls_back_when_refresh_fails(monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.auth.default_token_file", lambda provider: Path("/tmp/test.json") + ) + monkeypatch.setattr( + "cwmscli.utils.auth.load_saved_login", + lambda path: {"token": {"access_token": "stale-token", "expires_at": 1}}, + ) + + class FakeAuthError(Exception): + pass + + monkeypatch.setattr("cwmscli.utils.auth.AuthError", FakeAuthError) + + def fail_refresh(token_file): + raise FakeAuthError("invalid_grant") + + monkeypatch.setattr("cwmscli.utils.auth.refresh_saved_login", fail_refresh) + + assert get_saved_login_token() is None