Skip to content

Refresh the Pear access token before it expires#23

Merged
khaliqgant merged 1 commit into
mainfrom
fix/auth-refresh-access-token
May 22, 2026
Merged

Refresh the Pear access token before it expires#23
khaliqgant merged 1 commit into
mainfrom
fix/auth-refresh-access-token

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

Why

Cloud-side TTLs from /api/v1/cli/login (cloud:packages/web/app/api/v1/cli/login/route.ts):

  • accessToken: 1 day
  • refreshToken: 7 days

Pear's login flow captured access_token and refresh_token from the redirect but ignored the access_token_expires_at query parameter, so StoredTokens.expiresAt was always undefined. isTokenExpired short-circuited to false, getAccessToken returned 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

  1. Capture expiry on login. The CLI-login redirect already includes access_token_expires_at (ISO 8601). We persist it as expiresAt in StoredTokens.

  2. Refresh on near-expiry. getAccessToken now checks isTokenExpired(tokens). If near expiry, it POSTs /api/v1/auth/token/refresh with the stored refresh token, saves the rotated pair (refresh tokens are single-use server-side — see cloud:packages/web/app/api/v1/auth/token/refresh/route.ts), and returns the fresh access token.

  3. Failure handling.

    • 403 invalid_grant → refresh token itself dead (expired after 7d or revoked). We clear stored tokens so getAuthStatus returns loggedIn=false and the UI prompts re-login.
    • Transient 5xx / network failure → return the stale token. Caller's cloud request will 401, but we keep the refresh token so the next call can try again instead of permanently logging the user out on a network blip.
  4. 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 rotated refreshToken + new expiresAt are 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.
src/main/auth.test.ts (13 tests) 61ms
Test Files  1 passed (1)
     Tests  13 passed (13)

Related

The user-visible symptom that motivated this PR was a cloud-agent:list 401 ~24h into a Pear session. See the failure chain context in AgentWorkforce/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

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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Warning

Rate limit exceeded

@khaliqgant has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 42 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: edebd3f4-fc99-480e-bd6f-ba0e66f377f8

📥 Commits

Reviewing files that changed from the base of the PR and between bf5ebf4 and 853c4b8.

📒 Files selected for processing (2)
  • src/main/auth.test.ts
  • src/main/auth.ts

Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands and usage tips.

@khaliqgant khaliqgant merged commit 18a8995 into main May 22, 2026
2 checks passed
khaliqgant added a commit that referenced this pull request May 22, 2026
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>
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