Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
59280d0
Add the authlib dep requirement lib
krowvin Mar 13, 2026
792ded7
Add custom handling of auth flow state and cwms-cli login command
krowvin Mar 16, 2026
1d1592d
Add login doc and login doc check test
krowvin Mar 16, 2026
725adb8
Remove authlib dependency
krowvin Mar 17, 2026
48f433d
Add tests for auth/login
krowvin Mar 17, 2026
06b6956
Add initial login docs with examples
krowvin Mar 17, 2026
deb3ede
Change the timeout to 30s, color the alerts, and handle socket access…
krowvin Mar 17, 2026
f5a93cd
Handle port in use error
krowvin Mar 17, 2026
346a1c6
Document and change the default port to prevent using VSCode ports, a…
krowvin Mar 17, 2026
aba2534
Improve the successful login page, add doc links/coloring/close this …
krowvin Mar 17, 2026
bbadced
Ensure the template is included in the cwmscli/utils for the package
krowvin Mar 17, 2026
0ab8f52
Add tests for fallback port and callback html page
krowvin Mar 17, 2026
da551a9
Improve the terminal output for the end user once logged in
krowvin Mar 17, 2026
2715c7f
add tests for login output and refresh token
krowvin Mar 17, 2026
3d260a9
install precommit and fix formatting
krowvin Mar 17, 2026
b21aead
update the poetry lock to match the toml
krowvin Mar 17, 2026
33131e1
Update the port in the test to match the new port that does not walk …
krowvin Mar 17, 2026
a78bfaa
Merge branch 'main' into 79-standard-login-should-be-supported
krowvin Apr 7, 2026
3b5dddc
Centralize init cwms and create login session helpers
krowvin Apr 7, 2026
d2be0c8
Update all uses of cwms to use new login helpers
krowvin Apr 7, 2026
2dd109f
Update docs
krowvin Apr 7, 2026
2375ffc
Update tests for token
krowvin Apr 7, 2026
dc0da5d
update the lock file
krowvin Apr 7, 2026
72933a4
Fix union for python3.9
krowvin Apr 7, 2026
c83abca
Add OIDCbase url discovery from swagger docs, update tests and docs
krowvin Apr 7, 2026
f940a12
Add in auth and token endpoint discovery
krowvin Apr 8, 2026
c61c081
Create auth and login command tests
krowvin Apr 8, 2026
8f76444
Centralize refresh logic and save login on expiry
krowvin Apr 8, 2026
c21e3ea
ensure token refresh logic is tested
krowvin Apr 8, 2026
d8f823b
Merge branch 'main' into 79-standard-login-should-be-supported
krowvin Apr 9, 2026
941c609
Merge remote-tracking branch 'origin/main' into 79-standard-login-sho…
krowvin Apr 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cwmscli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 23 additions & 11 deletions cwmscli/commands/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")

Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
205 changes: 205 additions & 0 deletions cwmscli/commands/commands_cwms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import logging
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Optional

import click
Expand All @@ -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,
Expand All @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions cwmscli/commands/csv2cwms/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)))
Expand Down Expand Up @@ -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}")
Expand Down
5 changes: 3 additions & 2 deletions cwmscli/commands/shef_critfile_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import cwms
import pandas as pd

from cwmscli.utils import init_cwms_session


def import_shef_critfile(
file_path: str,
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions cwmscli/commands/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading