Skip to content

Add OAuth login (cld login) with auto-refreshing tokens and saveable default configs#116

Open
const-cloudinary wants to merge 14 commits into
masterfrom
oauth-login
Open

Add OAuth login (cld login) with auto-refreshing tokens and saveable default configs#116
const-cloudinary wants to merge 14 commits into
masterfrom
oauth-login

Conversation

@const-cloudinary

@const-cloudinary const-cloudinary commented Jun 25, 2026

Copy link
Copy Markdown
Member

Brief Summary of Changes

Adds OAuth-based authentication to the CLI and configuration management around it.

New commands

  • cld login [name] [--set-default] — browser-based OAuth login (Authorization Code + PKCE, loopback redirect on 127.0.0.1:49421). The session is saved as a named cloudinary:// config entry; no API secret is stored on disk. Reports whether the login became the default, and if not, how to make it one.
  • cld logout [name] — revoke a saved OAuth login's token at the server and remove its configuration (interactive picker when no name is given). The local entry is removed even if revocation fails. Refuses to remove non-OAuth configs.

cld config additions

  • -d/--default <name>, --set-default, -ud/--unset-default — manage a stored default configuration.
  • --refresh [name], --refresh-all, -f/--force — refresh saved OAuth tokens.
  • -ls/-s/bare config gain --json output and an inventory view marking the default and active config; secrets (api_secret, oauth_token, refresh_token, account_url password) are masked.

Token handling

  • The active OAuth config refreshes its access token lazily when the SDK reads oauth_token at request-build time. Presence/type checks (has_oauth) never touch the network.
  • Refresh rotates the stored refresh token and rewrites the saved entry; a failed refresh serves the existing token and surfaces a re-login hint once per config.

Config resolution precedence: -c (inline URL) > -C (saved name) > stored default > CLOUDINARY_URL env > unconfigured.

Config storage

  • Config writes are atomic (temp file + replace) and serialized across processes with a file lock; the file is written 0600 (POSIX).
  • Parsed-config cache keyed on file mtime/size.

Env-overridable settings (defaults unchanged): CLOUDINARY_OAUTH_CLIENT_ID, CLOUDINARY_OAUTH_SCOPES, CLOUDINARY_OAUTH_REDIRECT_HOST, CLOUDINARY_OAUTH_REDIRECT_PORT.

Other

  • Non-interactive stdin/stdout handling: confirms/logout EOF-safe, cld login fast-fails when headless, print_json colorizes only on a TTY.
  • New dependency: filelock.
  • Minimum Python raised to 3.8.
  • CI matrix extended to macOS + Windows.

What does this PR address?

  • New feature

Are tests included?

  • Yes

Reviewer, please note:

  • Self-refresh seam (cloudinary_cli/auth/oauth_config.py): every active config is installed as an OAuthConfig so oauth_token reads can refresh. Confirm no code path swaps in a plain Config and silently disables refresh.
  • Refresh concurrency (auth/__init__.py::refresh_url_if_stale): the single-use refresh token is consumed under a cross-process lock with a freshness re-check; a peer refresh is adopted rather than burning the token again.
  • Secret masking (config_utils.py): verify no config view prints a token/secret in the clear.
  • config command rename: the command function is config_command (CLI name still config) to avoid shadowing the core.config submodule on Python 3.10.
  • filelock added to requirements.txt.

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I ran the full test suite before pushing the changes and all the tests pass.

const-cloudinary and others added 13 commits June 22, 2026 02:53
Add `cld login` / `cld logout` backed by an OAuth 2.0 Authorization Code
+ PKCE loopback flow (RFC 8252). A login is persisted as a named
`cloudinary://` entry in config.json so it flows through the SDK parser
and existing config machinery; tokens refresh transparently when a saved
login is selected.

Highlights:
- auth/: protocol helpers (flow), session codec + JWT decode (session),
  single-shot loopback server (loopback_server), and the login façade.
- Region drives both the API host and the OAuth host; `--region` on the
  login subcommand, falling back to CLOUDINARY_REGION.
- config_resolver: explicit-config guard so a sole OAuth login never
  hijacks an explicitly chosen `-c`/`-C` account; strips OAuth bookkeeping
  keys before they reach the API/upload kwargs.
- Hardening: request timeouts on token calls, broadened refresh except,
  cloud_name guard, loopback path check, key-based is_oauth_url, and an
  expires_in fallback.
- logout is OAuth-only and, with no name, lists saved logins for a
  validated numbered selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add atomic_write primitive (temp file + os.replace) so an interrupted or
interleaved write can never truncate/corrupt the config or sync meta file.
write_json_to_file gains an opt-in atomic= flag (default False); enabled for
save_config and the .cld-sync meta write. atomic_write normalizes the temp
file's mkstemp 0600 mode to the umask default so non-config output files keep
their usual permissions.

Add a reentrant cross-process FileLock around config read-modify-write
(update_config / remove_config_keys), and use it in refresh_url_if_stale to
re-read and re-check token freshness inside the lock. This prevents two
concurrent processes from both refreshing — and thus burning — Hydra's
single-use rotated refresh token; a peer's fresh token is adopted instead.

Fix OAUTH_CLIENT_ID to the registered Hydra client. Add filelock dependency.
Add OAUTH_ATOMIC_CONFIG_REVIEW.md for the next reviewer.

Tests: atomic write (incl. read-only dir/target, umask), both json modes,
config 0600 + concurrency, peer-refresh adoption vs re-refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n refresh

Replace the implicit "sole OAuth login auto-applies" rule with an explicit,
stored default configuration, and split config handling into an offline
format-check (group level) and a lazy network refresh at point-of-use.

- Stored default in config.json under reserved key `__default__`; settable via
  `cld login --set-default`, `cld config -d/--set-default/--unset-default`, and
  auto-set when a login is the only config with no env/default.
- Resolution precedence: -c > -C > stored default > environment > unconfigured.
  The default outranks env; env is the fallback when no default is set.
- Resolver does selection + load only (no network). Stale OAuth tokens refresh
  lazily at the API chokepoints (call_api + search .execute()), closing the
  eager-refresh hang (Finding 1).
- `cld config -ls` table + `--json`; `cld config`/`-s` gain a header and `--json`
  with masked secrets, structured expires_at, decomposed account_url, and an
  active/default/source view. Synthetic (environment)/(command-line) rows.
- `cld config --refresh [name] / --refresh-all [--force]` to refresh OAuth tokens;
  failure hint preserves the config's region in the re-login command.
- Fixed-width secret masking (no asterisk walls / length leak); empty fields hidden.
- Extract config inventory presentation into utils/config_listing.py; move
  cloud_name_from_url / config_type into config_utils.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace ensure_active_config_fresh() and its four hand-maintained API
chokepoints with a single self-refreshing oauth_token on the active config
object. Access tokens are valid ~5 minutes, so refreshes are frequent; the
SDK reads cloudinary.config().oauth_token per request, which is the one seam
that needs a fresh token.

- New cloudinary_cli/auth/oauth_config.py: OAuthConfig(cloudinary.Config).
  oauth_token is a class-level property that refreshes-if-stale on read
  (reusing refresh_url_if_stale's lock + double-check + atomic persist) and
  short-circuits with no I/O once the in-object session is fresh. It never
  calls reset_config() in the getter (avoids the global-swap thread race).
  has_oauth reports token presence without refreshing.
- Every active config the CLI installs is now an OAuthConfig (saved,
  env-fallback, inline -c) via install_oauth_config / install_env_config, so
  has_oauth is universal and offline paths stay offline.
- Delete ensure_active_config_fresh and its call sites in core/search.py and
  utils/api_utils.py; refresh_cloudinary_config delegates to the install seam.
- load_config() gains an (mtime_ns, size) cache (copy-on-return, invalidated
  on save) to cut redundant reads on the remaining hot paths.
- Type/validity/-ls classifiers read has_oauth (presence), never the
  refreshing property, so config -ls / -s / the group-level check do no
  network even on a stale token.

Test isolation fixes (pre-existing bugs surfaced by the refactor):
- test_auth_session: patch load_config so refresh tests never read/write the
  real ~/.cloudinary-cli/config.json (this had poisoned a real entry).
- test_cli_config show_default_no_config: clear CLOUDINARY_* so it actually
  exercises the unconfigured path.
- test_cli_config_oauth TestConfigSecretMasking: strip CLOUDINARY_* in setUp so
  a dev env's account_url does not leak into masking assertions (fixed 4
  failures on account-enabled machines).
- Rewrite TestEnsureActiveConfigFresh -> TestSelfRefreshingOAuthToken for the
  new model, with a presence-does-not-refresh regression guard.
- Add CONFIG_PRESENT/REQUIRES_CONFIG skip predicate so the 13 mocked-HTTP tests
  that still need a resolvable config skip cleanly on a bare machine.

See OAUTH_LAZY_TOKEN_HANDOFF.md for the full writeup and the remaining
follow-ups (thread-local refresh lock, refresh-on-401 retry, transactional
multi-step config ops). Builds on OAUTH_DEFAULT_CONFIG_IMPLEMENTATION.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sh visibility

A1: reject -c and -C together with a UsageError instead of silently ignoring -C.
A2: surface a clear error when the loopback login port is in use, not a raw OSError.
A4: write config.json 0600 via the atomic temp file so it is never momentarily
    world-readable mid-write (drops the post-replace chmod that left a window).
A3a: on a failed background token refresh, warn once per config with a re-login
    hint instead of a silent debug line followed by a bare downstream 401.

Also adds the previously-uncommitted tests for the load_config mtime cache and the
SDK oauth_token refresh seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously a closed/non-interactive stdin raised a bare EOFError from input(),
surfacing as a blank "Command execution failed" with exit 0 — so a destructive
bulk op or a name-less `cld logout` silently no-op'd while reporting success.
Separately, colored --json output was only suppressed on Windows, so it relied
on click.echo's implicit ANSI stripping for piped output.

Interactivity is now centralized in cloudinary_cli/utils/utils.py:
- is_interactive(): the single sys.stdin.isatty() check.
- prompt_user(): the single input() call; returns None on EOF, logging an
  optional hint so the caller's decision is never a silent no-op.
- get_user_action / confirm_action delegate to prompt_user; on no input they
  apply the default and hint at --force (-F).

Behavior:
- `cld logout` with no name: on EOF, error with the `cld logout <name>` form and
  exit non-zero (the selection has no flag substitute).
- `cld login`: when no browser opens and stdin is not a TTY, fail fast with a
  headless-usage hint (-c/-C) instead of blocking until the 300s callback timeout.
- print_json: colorize only when stdout is an interactive TTY (drops the
  Windows-only guard), so piped/captured JSON (LLM agents, `| jq`, redirects) is
  never corrupted by ANSI escapes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CI matrix: full Python (3.10-3.14) on Linux, plus one latest-Python (3.14)
  smoke job each on macos-latest and windows-latest. A single bash shell runs
  every step identically on all three (Git Bash ships on the Windows runner).
- Guard the remaining POSIX-mode assertions (umask/file-mode) with skipIf(win32);
  os.chmod only honors the read-only flag on Windows, so exact-mode checks do not
  apply there (the code still runs, the hardening is just POSIX-only).
- Rewrite the loopback port-busy test to mock HTTPServer raising OSError instead
  of relying on a real double-bind, which Windows does not reject like POSIX does.
- Add print_json tests asserting non-TTY output stays plain on Windows too and
  that colorization is decided by isatty(), not the OS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…shadowing

The `config` command function shadowed the `cloudinary_cli.core.config`
submodule as a package attribute, so on Python 3.10 mock.patch resolved
"cloudinary_cli.core.config.<name>" to the click.Command (no such attribute)
instead of the module. Python 3.11+ prefers the real submodule, so only the
3.10 CI job failed. Rename the function to config_command (CLI command name
"config" is unchanged) so the submodule is never shadowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `cld login`/`logout` as the recommended setup path (no API secret on
disk), and document choosing a default configuration, resolution precedence,
and manual OAuth token refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These are working notes, not part of the published package; keep them local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add CLOUDINARY_OAUTH_CLIENT_ID and CLOUDINARY_OAUTH_SCOPES overrides
(mirroring the redirect host/port pattern) for testing against a non-prod
authorization server; production defaults are unchanged. Also drop
implementation-specific server naming from comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI imports dataclasses (3.7+), and the pinned urllib3>=2.2.2 / zipp>=3.19.1
security floors require Python 3.8+, so 3.6 and 3.7 cannot install or run it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
logout now revokes the refresh token at the authorization server (RFC 7009)
before removing the saved configuration; the local entry is removed even if
revocation fails. login reports whether the new login became the default and,
when it did not, prints the command to make it the default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant