Skip to content

feat(cli): auto-rotate access_token on 401/403 with cross-process flock#2951

Merged
eball merged 1 commit intomainfrom
feat/cli-auto-token-refresh
Apr 26, 2026
Merged

feat(cli): auto-rotate access_token on 401/403 with cross-process flock#2951
eball merged 1 commit intomainfrom
feat/cli-auto-token-refresh

Conversation

@pengpeng
Copy link
Copy Markdown
Member

@pengpeng pengpeng commented Apr 26, 2026

Summary

Adds transparent access_token refresh to olares-cli so a single command no longer aborts when the access token aged out — users only need to run profile login when the refresh_token itself becomes invalid. Token rotation is also de-duplicated across goroutines and across concurrent olares-cli processes.

What changed

Core (new behaviour)

  • refreshingTransport in cli/pkg/cmdutil/factory.go is now the RoundTripper behind every factory-provided *http.Client. Two trigger paths:
    • Reactive (replayable bodies) — on 401/403, call /api/refresh, then retry the original request once with the new token. Covers every JSON / files cat / files download / files rm / market verb.
    • Pro-active (non-replayable bodies) — for files upload chunks backed by *os.File, decode the JWT exp before each send and rotate when within 60s of expiry. Required because a consumed stream cannot be replayed on a 401.
  • credential.Refresher in cli/pkg/credential/refresher.go provides the actual rotation primitive: in-process sync.Mutex + cross-process gofrs/flock lock (under <config-dir>/locks/<sanitized-olaresId>.refresh.lock) + double-check before and after each gate, so /api/refresh is hit at most once per stale token even when many goroutines / parallel CLI processes race the same expiry window.
  • internal/lockfile (new package) wraps gofrs/flock with context-cancelable acquisition and a deterministic per-olaresId path layout.
  • auth.ErrRefreshUnauthorized — new sentinel; auth.Refresh wraps 401/403 from /api/refresh with it so the refresher can distinguish a dead grant from a transient 5xx / network blip.

Failure handling

  • /api/refresh returning 401/403 stamps InvalidatedAt on the keychain entry and surfaces *credential.ErrTokenInvalidated (profile list already shows these as invalidated). Subsequent commands skip the network round-trip and go straight to the CTA.
  • Reformat helpers (reformatHTTPErr, reformatRmHTTPErr, MarketClient.executeRequest) detect *credential.ErrTokenInvalidated / *credential.ErrNotLoggedIn via errors.As and surface them verbatim. Eliminates the previous request failed: Get \"https://...\": refresh token for X became invalid... double-wrapping; users now see just the typed error message with its built-in run profile login CTA.
  • MarkInvalidated failure no longer masks the typed error — it is logged to stderr instead, and the caller still receives *ErrTokenInvalidated so the CTA renders.

Cleanup at call sites

  • Removed manual X-Authorization injection from MarketClient, download.Client, upload.Client, and rm.Client. They now just consume the factory-provided *http.Client. The MarketClient keeps two clients (timed + untimed) but they share the same refreshingTransport so refreshes are immediately visible to both.
  • Dropped the early ErrTokenExpired exit in credential.DefaultProvider.Resolve so the transport gets to refresh stale JWTs instead of failing fast on the local heuristic.

Skills

Bumped olares-shared, olares-files, olares-market from 1.0.01.1.0 and added an "Automatic token refresh" section to olares-shared (canonical) plus targeted updates in olares-files (call out the streaming files upload pre-flight path) and olares-market (update the auth-transport section, drop the stale newMarketUploadHTTPClient reference).

Misc

.gitignore: switch /files/, /market/ patterns from repo-root-only to anywhere, add user-service/. Pre-existing local maintenance, folded in by the user's request.

Test plan

  • go build ./...
  • go test -race -count=1 ./pkg/cmdutil/... ./pkg/credential/... ./internal/lockfile/... — full concurrency + transport matrix
  • go test -count=1 ./cmd/ctl/... ./internal/files/... — unit tests for the touched call sites
  • Concurrency:
    • TestRefresh_ConcurrentGoroutines — 50 goroutines on the same Refresher → exactly 1 hit on /api/refresh.
    • TestRefresh_CrossProcess — re-execs the test binary 4× sharing a file-backed token store + OLARES_CLI_HOME → still exactly 1 /api/refresh hit, validates the flock + double-check works across processes.
  • Transport behaviour (cli/pkg/cmdutil/factory_test.go):
    • 200 happy path injects X-Authorization.
    • 401 → refresh → retry with new token; 403 same.
    • Retry that also returns 401 surfaces verbatim (no infinite loop).
    • Non-replayable body + non-JWT token → 401 verbatim, no refresh.
    • Pre-flight: stale JWT + non-replayable → exactly 1 upstream request with new token, no 401.
    • Pre-flight: token within 60s skew → still rotates.
    • Pre-flight: fresh token → no rotation.
    • Replayable body + stale JWT → still uses reactive path (no JWT decode hot loop).
    • Pre-flight refresh failure → typed error returned, body NOT consumed (upstream 0 hits).
  • Errors: typed credential errors surface cleanly through all three reformatters (files / rm / market).
  • Manual smoke: pending against a live Olares instance (recommend testing files upload of a multi-chunk file across the 60s expiry boundary, and market install --watch after letting the token age out).

Made with Cursor


Note

High Risk
High risk because it changes the CLI-wide HTTP/auth transport behavior (automatic refresh + retry on 401/403) and adds cross-process locking/persistence logic that affects all networked commands.

Overview
Introduces transparent access-token rotation across the CLI. cmdutil.Factory now provides HTTP clients backed by a new refreshingTransport that injects X-Authorization, refreshes the token on 401/403, and retries eligible requests once; a no-timeout variant is added for long uploads.

Adds a new cross-process refresh primitive. New credential.Refresher performs /api/refresh with in-process mutex + per-user flock-based locking (internal/lockfile) and stamps InvalidatedAt on refresh-token rejection so subsequent commands fail fast with typed ErrTokenInvalidated/ErrNotLoggedIn.

Migrates call sites to rely on the transport. Files (cat/download/upload/rm) and market clients drop manual X-Authorization handling, update error reformatting to surface typed credential errors cleanly, and tests are updated/added to cover retry/preflight behavior and concurrency; docs/skills are bumped to describe the new auto-refresh behavior.

Reviewed by Cursor Bugbot for commit 0b7effe. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds transparent access_token refresh so users no longer need to re-run
`profile login` just because their access_token aged out — only when the
refresh_token itself becomes invalid.

Two trigger paths in the new refreshingTransport (cli/pkg/cmdutil/factory.go):

  - Reactive (replayable bodies): on a 401/403, /api/refresh and retry
    the original request once with the new token. Covers every JSON
    request, files cat/download/rm, and all market verbs.
  - Pro-active (non-replayable bodies): for files upload chunks backed
    by *os.File, decode the JWT exp before each send and rotate when
    within 60s of expiry, so a streaming body is never consumed by a
    request the server is about to 401.

Concurrency is handled by a new credential.Refresher (cli/pkg/credential/
refresher.go) using sync.Mutex + a gofrs/flock-backed cross-process lock
(cli/internal/lockfile) plus double-check before and after each gate, so
across goroutines AND parallel olares-cli processes /api/refresh is hit
at most once per stale token.

When refresh itself fails:

  - 401/403 from /api/refresh stamps InvalidatedAt on the keychain
    entry and surfaces *credential.ErrTokenInvalidated; subsequent
    commands skip the network round-trip and go straight to the
    "run profile login" CTA.
  - Reformat helpers in cmd/ctl/files and cmd/ctl/market detect the
    typed credential errors via errors.As and surface them clean,
    avoiding the *url.Error "Get \"https://...\":" wrapping.

Removes manual X-Authorization injection from MarketClient, download.Client,
upload.Client, and rm.Client — they now just consume the factory-provided
http.Client. Also drops the early ErrTokenExpired short-circuit in
DefaultProvider.Resolve so the transport gets to refresh stale JWTs
instead of failing fast.

Tested with goroutine + child-process concurrency (TestRefresh_Concurrent
Goroutines, TestRefresh_CrossProcess) and the full transport matrix
(401, 403, second-401-no-loop, non-replayable body, refresh failure,
preflight stale/within-skew/fresh/replayable/failure).

Skills (olares-shared/files/market) bumped to 1.1.0 with new
"Automatic token refresh" sections.

Made-with: Cursor
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
olares-docs Ignored Ignored Apr 26, 2026 1:32pm

Request Review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0b7effe. Configure here.

Comment thread .gitignore
/files/
/market/
files/
market/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unanchored gitignore patterns will hide new source files

High Severity

Changing /files/ and /market/ (root-anchored) to files/ and market/ (unanchored) makes the pattern match directories named files or market at any depth. This silently gitignores new files added to tracked source directories like cli/cmd/ctl/files/, cli/internal/files/, and cli/cmd/ctl/market/. Already-tracked files are unaffected, but any newly created source file in those directories will be invisible to git status / git add ., requiring git add -f to include it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0b7effe. Configure here.

@eball eball merged commit 916d4d4 into main Apr 26, 2026
14 checks passed
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.

2 participants