Skip to content

[glean-vnext] Fix intermittent re-auth from cross-process refresh-token rotation - #4

Open
pragati-agrawal-glean wants to merge 5 commits into
mainfrom
pragati/fix-plugin-token-rotation-reauth
Open

[glean-vnext] Fix intermittent re-auth from cross-process refresh-token rotation#4
pragati-agrawal-glean wants to merge 5 commits into
mainfrom
pragati/fix-plugin-token-rotation-reauth

Conversation

@pragati-agrawal-glean

@pragati-agrawal-glean pragati-agrawal-glean commented Jul 29, 2026

Copy link
Copy Markdown

Port of gleanwork/glean-plugins-vnext#44 to the vendored source under shared/glean/mcp/.

Problem

Each host session runs its own plugin process while sharing one credential file. The Glean OAuth server rotates refresh tokens on every refresh and invalidates the presented refresh token. When one process refreshes, a sibling with a stale in-memory grant can receive invalid_grant or invalid_request and incorrectly trigger re-authentication for the shared store.

Fix

  1. Treat the credential file as the source of truth. tokens() reloads credentials from disk on every access through syncTokensFromDisk(). Correctness no longer depends on file mtime, and there is no mtime-based synchronization or environment override.
  2. Wait before clearing tokens. invalidateCredentials("tokens") waits for a sibling's changed access token for a fixed two-second grace period, polling every 100 ms. It adopts a sibling's new grant when present and clears tokens only when no sibling refresh appears.
  3. Retry connect-level refresh failures. createRemoteClient() retries once when a newer access token appears on disk. It also handles structured OAuth errors with errorCode invalid_request or invalid_grant, instead of matching human-readable error messages.
  4. Keep retries bounded. The retry requires an actually changed token and is limited to one attempt, so unrelated OAuth errors are not retried indefinitely.
  5. Harden atomic credential writes. Credential updates remain temp-file-plus-rename writes, with chmodSync(tmpPath, FILE_MODE) applied before rename so temporary files are also 0600.

Agent-plugins-specific behavior preserved

  • withConnectLock connection serialization.
  • CSRF state and callback handling.
  • Agent-plugins version plumbing and bundle layout.
  • The source remains under shared/glean/mcp/; this PR does not introduce a separate sources/glean-vnext/ tree.

Validation

  • Bundle typecheck passed.
  • Bundle test suite passed: 207 tests.
  • Bundle build passed.
  • Diff validation passed.

The live two-process refresh-race verification was performed against the equivalent vNext candidate using the non-production SST endpoint and a two-minute access-token lifetime. The agent-plugins port is covered by its bundle typecheck, test, and build validation; no raw credentials are included here.

— sent via Glean Desktop

…en rotation

Port of gleanwork/glean-plugins-vnext#44 (squashed; full history and E2E
evidence there).

Each host session runs its own plugin process sharing one credentials
file. The Glean OAuth server rotates refresh tokens on every refresh with
no grace period, so when one session refreshes, every other session's
in-memory copy is revoked; their next refresh gets invalid_grant, the SDK
wipes the SHARED store, and the user sees [SETUP_REQUIRED] — plus every
other live session dies with them.

Fixes (E2E-verified on an experimental pod against real prod /oauth —
bug reproduced on demand with the old build, silent recovery in both
race shapes with this change):

- tokens()/syncTokensFromDisk: mtime-guarded re-read of the shared store
  so a sibling's rotated grant is picked up before the SDK refreshes.
- invalidateCredentials('tokens'): adopt a newer on-disk token instead of
  wiping — with a grace-window poll (GLEAN_ROTATION_GRACE_MS, 2s) because
  the loser's invalid_grant usually lands milliseconds before the
  winner's write.
- Connect-level sibling-refresh retry: concurrent refreshes of the same
  grant make fosite fail the loser with invalid_request (not
  invalid_grant — observed live), which the SDK rethrows raw; recognize
  refresh-shaped failures, wait out the grace window, retry once.
- saveCredentials: temp-file + rename so concurrent writers can't leave
  a torn store that parses as wiped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pragati-agrawal-glean
pragati-agrawal-glean force-pushed the pragati/fix-plugin-token-rotation-reauth branch from c640b64 to 10b5eaa Compare July 30, 2026 10:49
Comment thread shared/glean/mcp/src/remote-client.ts Outdated
Comment on lines +263 to +268
// Match broadly; the caller's disk re-check gates the actual retry.
function isLikelyRefreshFailure(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
return /refresh|invalid_grant|invalid_request|oauth/i.test(msg);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

3/5 (strong opinion: non-blocking)

Do we actually need to use heuristics like this?

We should be getting back responses like

{
  "error": "invalid_grant",
  "error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
}

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