Refresh the Pear access token before it expires#23
Conversation
Cloud-side TTLs from `/api/v1/cli/login`:
- accessToken : 1 day
- refreshToken: 7 days
Pear's login flow captured `access_token` and `refresh_token` but
ignored `access_token_expires_at`, so the stored token had no
`expiresAt`. `isTokenExpired` would then always return false, and
`getAccessToken` would return the stale token verbatim. Result: every
cloud-backed IPC handler (`cloud-agent:list`, `cloud-agent:attach`,
`integrations:*`, etc.) 401s ~24h after login until the user manually
re-logs in.
Fix:
1. Capture `access_token_expires_at` from the CLI-login redirect query
and persist it as `expiresAt` in `StoredTokens`.
2. `getAccessToken` now checks `isTokenExpired(tokens)`; if near
expiry, calls `POST /api/v1/auth/token/refresh` with the stored
refresh token, saves the rotated pair (cloud rotates refresh
tokens on each refresh — single-use per
`cloud/.../auth/token/refresh/route.ts`), and returns the fresh
access token.
3. Refresh failure handling:
- 403 (`invalid_grant`) — refresh token itself dead (expired after
7d or revoked). Clear stored tokens so `getAuthStatus` reports
loggedIn=false and the UI prompts re-login.
- Other failures (transient 5xx, network) — return the stale
token. Caller's cloud request will 401 and the next refresh
attempt can retry; we keep the refresh token so the recovery
path stays open.
4. Concurrent-call coalescing — bursty IPC traffic (cloud-agent +
integrations + whoami all firing at once on app launch) shares a
single in-flight refresh promise, so rotation stays consistent
and we don't burn an N-shot refresh chain.
Tests: 5 new cases covering the not-near-expiry skip, successful
refresh + rotation persistence, 403 dead-refresh clears tokens,
transient 5xx keeps tokens, concurrent-call coalescing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (2)
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
Follow-up to #23 (token refresh). The refresh path only kicks in when `isTokenExpired(tokens)` returns true, and the previous logic short-circuited to `false` whenever `tokens.expiresAt` was missing or unparseable. For anyone whose tokens were persisted before #23 began capturing `access_token_expires_at` on login, that branch was always hit: stored tokens had no expiresAt → isTokenExpired returned false → getAccessToken returned the stored access token verbatim → cloud calls 401'd because the access token had actually expired ~24h after its original issue. This means #23 alone didn't fix anyone already in the field — only new logins would benefit. Existing users still had to log out + log back in to bootstrap a fresh stored pair. Fix: in the absence of a usable expiresAt (missing OR unparseable), default to "expired". The refresh path handles failure gracefully: - transient 5xx → fall through to the stale token, next call retries - 403 invalid_grant → clearTokens, UI prompts re-login - success → rotated pair persisted with proper expiresAt going forward Tests: 1 new case modelling the legacy stored state (auth.json with no expiresAt key); existing tests get a default future expiresAt from the writeAuthJson helper so they stay focused on what they test instead of accidentally tripping refresh. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Why
Cloud-side TTLs from
/api/v1/cli/login(cloud:packages/web/app/api/v1/cli/login/route.ts):accessToken: 1 dayrefreshToken: 7 daysPear's login flow captured
access_tokenandrefresh_tokenfrom the redirect but ignored theaccess_token_expires_atquery parameter, soStoredTokens.expiresAtwas always undefined.isTokenExpiredshort-circuited tofalse,getAccessTokenreturned the stale token verbatim, and every cloud-backed IPC handler (cloud-agent:list,cloud-agent:attach,integrations:*,whoami, etc.) 401s ~24h after login until the user manually re-logs in.What changes
Capture expiry on login. The CLI-login redirect already includes
access_token_expires_at(ISO 8601). We persist it asexpiresAtinStoredTokens.Refresh on near-expiry.
getAccessTokennow checksisTokenExpired(tokens). If near expiry, itPOSTs/api/v1/auth/token/refreshwith the stored refresh token, saves the rotated pair (refresh tokens are single-use server-side — seecloud:packages/web/app/api/v1/auth/token/refresh/route.ts), and returns the fresh access token.Failure handling.
403 invalid_grant→ refresh token itself dead (expired after 7d or revoked). We clear stored tokens sogetAuthStatusreturnsloggedIn=falseand the UI prompts re-login.Concurrent-call coalescing. App-launch IPC traffic (cloud-agent + integrations + whoami all firing in parallel) shares a single in-flight refresh promise. Without this, all N callers would race and burn through the refresh-token chain (each rotation invalidates the previous one).
Tests
5 new cases on top of the existing 8:
returns the stored access token when it is not near expiry— fast-path skip.refreshes the access token when it is near expiry and persists the rotated pair— happy path; asserts the request shape and that the rotatedrefreshToken+ newexpiresAtare written to disk.clears stored tokens on 403 invalid_grant— dead refresh token triggers logout.keeps stored tokens on transient 5xx— so the next call can retry.coalesces concurrent refreshes into a single network call— bursty IPC traffic shares the in-flight promise.Related
The user-visible symptom that motivated this PR was a
cloud-agent:list401 ~24h into a Pear session. See the failure chain context inAgentWorkforce/cloud#972(proactive box-warm-with-poll) — the box-warm failures we were chasing today couldn't be tested past the auth boundary because the access token had simply expired.🤖 Generated with Claude Code