Skip to content

feat: OAuth browser login with automatic token lifecycle (0.3.1)#93

Open
Episkey-G wants to merge 8 commits into
ucloud:masterfrom
Episkey-G:oauth-browser-login
Open

feat: OAuth browser login with automatic token lifecycle (0.3.1)#93
Episkey-G wants to merge 8 commits into
ucloud:masterfrom
Episkey-G:oauth-browser-login

Conversation

@Episkey-G

Copy link
Copy Markdown

Summary

Adds browser-based OAuth login to ucloud-cli, so interactive users no longer need to handle AK/SK keys:

  • ucloud auth login — opens the UCloud authorization page; the callback is captured automatically via an RFC 8252 loopback server on 127.0.0.1 (no copy-paste). --no-browser prints the URL for SSH/headless use and falls back to manual paste, as does a 3-minute capture timeout.
  • ucloud auth logout — removes local tokens only; stored AK/SK keys are kept and restored.
  • After login the CLI auto-configures default region/zone/project, validating any pre-existing project_id against the logged-in account.

Credential model

auth_mode on a profile selects exactly one credential mechanism per request:

  • OAuth profiles send Authorization: Bearer only — signature parameters are never emitted.
  • AK/SK and CloudShell signing paths are byte-identical to before (pinned by tests).
  • --public-key/--private-key flags still take precedence and use signing.
  • One profile = one method; scripts/CI keep using AK/SK profiles (ucloud auth login refuses non-interactive terminals with a pointer to AK/SK).

Token lifecycle (fully automatic)

  • Proactive refresh on use, with a 5-minute clock-skew margin before expiry.
  • Reactive recovery: if the gateway rejects a token mid-command (RetCode 174), the CLI refreshes and replays the request exactly once.
  • Refresh-token rotation is serialized across concurrent processes with a file lock + reread-after-lock, so two processes can never burn the same single-use refresh token; other profiles' rotated tokens are merged from disk before saving.
  • Config/credential files are written atomically (same-dir temp file + fsync + rename, mode 0600).

Robustness / security

  • Token values redacted across all log sinks and the panic path; config list shows an AuthMode column and never prints tokens.
  • Token HTTP client honors HTTPS_PROXY/HTTP_PROXY/NO_PROXY (pinned by test).
  • Fixed along the way: config update --base-url validated against the old gateway (impossible to recover from a wrong base-url); init on an OAuth profile didn't persist the switch back to AK/SK; /dev/null stdin was misdetected as an interactive terminal; AggConfigManager held process-lifetime file handles that would break os.Rename on Windows.

Testing

  • Unit/integration: full ./base/ suite incl. concurrency tests under -race (single rotation under concurrent refresh, replay-once guarantees, negative paths).
  • Black-box CLI matrix (tests/oauth_cli_matrix.sh, sandbox HOME): non-TTY fail-fast, missing-token guidance, logout idempotence, OAuth/AK-SK profile coexistence, init switch-back persistence — 8/8.
  • Live verification against production: login (loopback auto-capture), silent renewal of an expired token, reactive 174 refresh-and-replay, full uhost create/stop/start/delete lifecycle over Bearer, web-console logout independence.
  • AK/SK regression: existing prompts and exit codes preserved (asserted in the matrix).

Notes for reviewers

  • The embedded OAuth client credentials follow the public-client convention used by gcloud/gh: a CLI binary cannot keep a secret, and flow security rests on the state check and loopback redirect.
  • vendor/ adds github.com/gofrs/flock v0.8.1 (file locking).
  • Reactive replay triggers only on RetCode 174 ("Token Not Exists" — covers both invalid and expired tokens, verified against the live gateway); auth failures arrive as HTTP 200 + RetCode, so HTTP 401 handling is kept only as a defensive branch.

🤖 Generated with Claude Code

Episkey-G and others added 4 commits June 12, 2026 16:57
Add 'ucloud auth login' / 'ucloud auth logout' implementing OAuth 2.0
authorization-code flow with RFC 8252 loopback auto-capture of the
callback (--no-browser prints the URL and falls back to manual paste).
auth_mode on the profile picks exactly one credential mechanism per
request: oauth profiles send Authorization: Bearer only (stored AK/SK
stay inert for logout restore), AK/SK and CloudShell signing paths are
byte-identical to before.

Token lifecycle is fully automatic: proactive refresh before expiry
(5-minute clock-skew margin), reactive refresh-and-replay-once when the
gateway rejects a token mid-command (RetCode 174), refresh rotation
serialized across processes with a file lock plus reread-after-lock,
and credential/config files written atomically (temp+fsync+rename).
Token values are redacted across all log sinks and the panic path.

Also fixes: config update --base-url validated against the old gateway
(chicken-and-egg), login kept a stale project_id from another account,
init on an oauth profile did not persist the switch back to AK/SK,
/dev/null was misdetected as an interactive terminal, and
AggConfigManager held process-lifetime file handles that break
os.Rename on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…arning; redact oauth error desc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Episkey-G

Copy link
Copy Markdown
Author

Self-review follow-ups addressed in the latest push:

  • Callback server no longer aborts on noise requests (28b8587): a request to the loopback callback with a missing code or mismatched state now gets HTTP 400 and the CLI keeps waiting for the genuine callback (3-minute timeout unchanged). Only an explicit OAuth error (e.g. access_denied) or a valid code+state terminates the wait. Covered by new tests: noise → 400 + no delivery → subsequent valid callback still succeeds.
  • Login post-save warning: if persisting the auto-configured region/project fails after tokens were already saved, the CLI now prints a warning with a ucloud config update hint instead of a generic error before the success line.
  • error_description from the OAuth server is redacted before being echoed in error messages.
  • Dropped internal verification scaffolding (hack/t0) from the branch (5f6104b).

All gates re-run green: full ./base/ + ./cmd/ tests, black-box CLI matrix 8/8, gofmt clean.

🤖 Generated with Claude Code

… base/endpoints.go

Move the API gateway base URL, OAuth base URL, client credentials,
scope, /authorize and /token endpoint paths, and the loopback callback
constants into one file grouped by service domain. The callback path is
now a single exported constant (base.OAuthRedirectPath) shared by the
redirect_uri builder and the callback server's mux, removing a
cross-file duplicate that had to be kept in sync by hand. No behavior
change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Episkey-G

Copy link
Copy Markdown
Author

Follow-up: centralized all service URLs, hosts and endpoint paths into a new base/endpoints.go, grouped by service domain (business API gateway vs OAuth authorization service). Previously the /authorize and /token paths were inline format-string literals, the callback path /authorization was duplicated across base/oauth.go and cmd/callback.go (had to be kept in sync by hand), and the loopback hosts were scattered. They are now single named constants; the callback path and loopback listen host are exported and reused by cmd/callback.go. Pure structural change, no runtime behavior difference — full ./base + ./cmd tests and the black-box matrix (8/8) stay green.

🤖 Generated with Claude Code

Episkey-G and others added 2 commits June 17, 2026 14:28
…r defaults

DefaultBaseURL belongs with the other profile-default constants; a
dedicated file separated it from its peers. Keep the overridable default
URLs together (api gateway + oauth) and group the OAuth protocol/loopback
constants in config.go. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bal flag

Mirror --base-url: add --oauth-base-url to config add/update, a global
--oauth-base-url override, and the GlobalFlag/mergeConfigIns plumbing so
oauth_base_url can be configured without hand-editing config.json — needed
for environments whose OAuth domain differs from the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Episkey-G

Copy link
Copy Markdown
Author

Two more follow-ups pushed:

  1. Inlined the centralized constants back into base/config.go (reverted the separate endpoints.go file). DefaultBaseURL belongs next to the other profile-default constants (DefaultTimeoutSec/DefaultProfile/Version); a dedicated file had split it from its peers. The overridable default URLs (api gateway + oauth) now sit together, with the OAuth protocol/loopback constants grouped just below — all in config.go, no separate file.

  2. Made the OAuth base URL settable/overridable, symmetric with --base-url. Previously oauth_base_url could only be set by hand-editing config.json. Now: config add --oauth-base-url, config update --oauth-base-url, a global --oauth-base-url override, and the GlobalFlag/mergeConfigIns plumbing — mirroring --base-url exactly. Needed for environments whose OAuth domain differs from the default. Covered by unit tests (global-override + persistence round-trip).

Pure-structure change (1) and new-flag change (2, TDD). Full ./base + ./cmd tests and the matrix (8/8) stay green; config add/update --help show the new flag.

🤖 Generated with Claude Code

…-domain login

A new user targeting a non-default OAuth environment was stuck: the
global --oauth-base-url is not accepted on the auth command group,
config update needs an existing profile, and config add requires AK/SK.
auth login now takes its own --oauth-base-url, usable with no prior
config and persisted to the profile so later refreshes reuse it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Episkey-G

Copy link
Copy Markdown
Author

Added --oauth-base-url to auth login to fix a first-time-user deadlock: a brand-new user who must target a non-default OAuth domain previously had no working entry point — the global --oauth-base-url isn't accepted on the auth command group, config update needs an existing profile, and config add requires AK/SK (which an OAuth user doesn't have). Now ucloud auth login --oauth-base-url <url> works with zero prior config and persists the URL to the profile so later token refreshes reuse it. Unit-tested (resolveLoginOAuthBase: flag override + persistence wiring + fallbacks) and verified end-to-end from an empty HOME — the authorize URL is built against the supplied domain. README EN/CN updated.

🤖 Generated with Claude Code

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