Skip to content

fix(auth): recover from concurrent session refreshes#213

Merged
wyattjoh merged 4 commits intomainfrom
wyattjoh/oauth-refresh-race-retry
Apr 28, 2026
Merged

fix(auth): recover from concurrent session refreshes#213
wyattjoh merged 4 commits intomainfrom
wyattjoh/oauth-refresh-race-retry

Conversation

@wyattjoh
Copy link
Copy Markdown
Contributor

@wyattjoh wyattjoh commented Apr 22, 2026

Summary

Follow-up to #205. When two separate clerk processes start from the same expired OAuth session, they can both race into refreshStoredSession(). If one process refreshes successfully and rotates the refresh token, the loser sees invalid_grant and, in the current implementation, deletes the credentials that the winner just rewrote.

This PR keeps the existing refresh flow but hardens the invalid_grant path so a losing process re-reads the credential store before treating the session as dead. If another process already stored a newer session, the loser reuses that session instead of deleting credentials and forcing a re-auth.

Fix

  • Add a small recovery path for invalid_grant in credential-store.ts.
  • Capture the failing session's refresh token, then re-read the store with a short retry window (25ms, 50ms, 100ms) before deleting anything. A different refresh token implies a sibling process already rotated the session.
  • If a newer session appears during that window, treat it as the winner of the race and continue with its access token instead of clearing auth state.
  • Keep the existing cleanup behavior for the real expired-session case where the store never changes.
  • Add regression coverage for both paths and a patch changeset for the follow-up fix.

Test plan

  • bunx tsc -p packages/cli-core/tsconfig.json --noEmit
  • bun test packages/cli-core/src/lib/credential-store.test.ts
  • bun test packages/cli-core/src/lib/plapi.test.ts packages/cli-core/src/lib/token-exchange.test.ts packages/cli-core/src/commands/auth/login.test.ts packages/cli-core/src/commands/doctor/doctor.test.ts packages/cli-core/src/commands/whoami/index.test.ts
  • Added a regression test that simulates another process winning the refresh race and verifies the loser reuses the newer stored session.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 22, 2026

🦋 Changeset detected

Latest commit: 9573196

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
clerk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@wyattjoh wyattjoh force-pushed the wyattjoh/oauth-refresh-race-retry branch from 25bf8c6 to 708db0c Compare April 22, 2026 18:37
Base automatically changed from wyattjoh/oauth-refresh-token-support to main April 22, 2026 18:39
@wyattjoh wyattjoh force-pushed the wyattjoh/oauth-refresh-race-retry branch from 708db0c to 6ecad3e Compare April 22, 2026 18:41
@wyattjoh wyattjoh marked this pull request as ready for review April 22, 2026 18:41
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 39af30aa-7498-43c3-aa84-17d55a15adc3

📥 Commits

Reviewing files that changed from the base of the PR and between 0d2667c and 9573196.

📒 Files selected for processing (2)
  • packages/cli-core/src/lib/credential-store.test.ts
  • packages/cli-core/src/lib/credential-store.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli-core/src/lib/credential-store.ts

📝 Walkthrough

Walkthrough

Introduces getValidAccessToken to centralize token expiry checks and moves expiry/refresh decision logic out of getValidToken. Enhances refresh failure handling: on invalid_grant the code polls the credential store with short delays, re-reads the stored session, and if a newer non-expired session is found (detected via a session fingerprint mismatch) it returns that session's access token; otherwise stored credentials are deleted and a session-expired error is thrown. Adds a test that simulates a concurrent refresh where another process writes a refreshed session during recovery. A Changeset file was added.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Detailed explanation

  • Adds getValidAccessToken to centralize expiry detection and refresh behavior; getValidToken delegates to it.
  • Introduces sessionFingerprint() to detect changes between reads of the stored session.
  • Implements recoverFromInvalidGrant() which performs short delays, re-reads stored credentials, and if a fingerprint mismatch reveals a newer valid session, returns that session's access token.
  • Modifies refreshStoredSession to invoke recovery on invalid_grant before deleting stored credentials and throwing a session-expired error.
  • Adds a test that simulates an expired stored session, a refresh call failing with invalid_grant, and a concurrent writer that persists a refreshed session; asserts the resolved token and stored session match the newer session.
  • Adds a Changeset metadata file to trigger a patch release documenting the concurrency behavior for refreshed OAuth credentials.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(auth): recover from concurrent session refreshes' directly and clearly summarizes the main change—hardening the invalid_grant handling to recover from race conditions when multiple processes refresh the same expired session.
Description check ✅ Passed The description is well-detailed and clearly related to the changeset, explaining the problem, the fix strategy, implementation details, and test coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Comment @coderabbitai help to get the list of available commands and usage tips.

@wyattjoh wyattjoh requested review from jfoshee and rafa-thayto April 22, 2026 18:49
@wyattjoh wyattjoh force-pushed the wyattjoh/oauth-refresh-race-retry branch from 6ecad3e to 38431d4 Compare April 23, 2026 20:52
Copy link
Copy Markdown
Contributor

@rafa-thayto rafa-thayto left a comment

Choose a reason for hiding this comment

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

LGTM with two suggestions below. The approach is solid and well-scoped. The fingerprint + retry strategy is a clean way to handle this race.

Comment thread packages/cli-core/src/lib/credential-store.ts Outdated
Comment thread packages/cli-core/src/lib/credential-store.ts Outdated
Prevent unbounded recursion when a newer stored session is itself
expired, and ensure recovery failures still fall through to the
delete + session_expired path so the user gets the friendly re-auth
prompt instead of a raw error.
@wyattjoh wyattjoh requested a review from rafa-thayto April 23, 2026 21:03
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli-core/src/lib/credential-store.ts`:
- Around line 31-32: The timed-polling invalid_grant fallback currently clears
the credential store unconditionally after the retry budget
(INVALID_GRANT_RETRY_DELAYS_MS) and can delete a freshly written session; change
the fallback to be non-destructive by verifying the store still contains the
original fingerprint before deleting or by avoiding deletion entirely and
instead marking the local attempt as failed. Concretely: capture the original
fingerprint value when you start the retry loop, then before calling the code
that clears the store (the branch that deletes credentials on invalid_grant),
re-read the credential store and compare its current fingerprint to the captured
original; only perform the destructive clear if they match, otherwise skip
deletion (or set a non-destructive failure flag/log). Ensure this check is
applied to the invalid_grant branch that references
INVALID_GRANT_RETRY_DELAYS_MS so the race is eliminated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c9ff5427-60ad-42a6-a67d-b3f4650d0923

📥 Commits

Reviewing files that changed from the base of the PR and between 38431d4 and 0d2667c.

📒 Files selected for processing (1)
  • packages/cli-core/src/lib/credential-store.ts

Comment thread packages/cli-core/src/lib/credential-store.ts
Comment thread packages/cli-core/src/lib/credential-store.ts Outdated
Comment thread packages/cli-core/src/lib/credential-store.test.ts Outdated
Comment thread packages/cli-core/src/lib/credential-store.ts Outdated
Rename recoverFromInvalidGrant to awaitConcurrentRefresh and add a JSDoc
covering the race window, polling budget, and detection rationale. Drop
the sessionFingerprint helper in favor of comparing refresh tokens
directly, since the OAuth server rotates them on every successful
exchange. Update the test description to make the concurrent-refresh
framing explicit.
@wyattjoh wyattjoh requested a review from jfoshee April 28, 2026 15:19
@wyattjoh wyattjoh merged commit 2e6d03b into main Apr 28, 2026
10 checks passed
@wyattjoh wyattjoh deleted the wyattjoh/oauth-refresh-race-retry branch April 28, 2026 20:00
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.

3 participants