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.
+