[Plugin] Reuse the DCR client instead of re-registering on setup and abandoned sign-ins - #45
Open
pragati-agrawal-glean wants to merge 12 commits into
Conversation
…tation MCP servers are spawned per session, and the OAuth provider reads credentials from disk once at startup, then serves tokens from that in-memory snapshot. With Ory single-use refresh-token rotation, when one process refreshes it persists a new refresh token and invalidates the old one that every other live process still holds in memory. The next process to hit a 401 refreshes with its now-dead token -> invalid_grant -> full re-auth -> [SETUP_REQUIRED]. This is the intermittent "why is it asking me to auth again", not the (intentional, unchanged) 7-day access-token TTL. Two fixes, both independent of the PLUGIN_DATA_DIR store split: 1. tokens() re-reads the credentials file when its mtime advances, so a process picks up a sibling's freshly-rotated grant before the SDK's auth flow reads tokens and attempts a refresh. mtime-guarded so the steady state is a single stat(). Conservative on removal: a missing file or a tokens-less rewrite does not evict the in-memory token. 2. createRemoteClient retries connect once when, after an auth failure, a newer access token has appeared on disk (sibling refresh) -- turning the rotation race into a silent reconnect instead of a re-auth. Bounded to a single retry. Tests: provider adopts a sibling's newer token, keeps its token when the file vanishes, ignores a tokens-less rewrite; credentialsMtimeMs probe; connect retry fires only when the on-disk token changed. Follow-ups (not in this PR): collapse the PLUGIN_DATA_DIR / ~/.glean store split to one canonical path so surfaces share one DCR client; reuse the DCR client across re-auths to stop orphaned-token pile-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… invalid_grant
The SDK's auth() calls invalidateCredentials("tokens") when a refresh returns
invalid_grant, which wrote {tokens: undefined} to the SHARED credentials file.
But the plugin's OAuth server (legacy /oauth, fosite) rotates refresh tokens
with NO grace period (RevokeRefreshTokenMaybeGracePeriod -> immediate delete),
so invalid_grant is exactly what a sibling's rotation looks like: the sibling
already minted a fresh grant and persisted it, and we only failed because we
refreshed with the now-revoked old token.
Blindly clearing then (a) forced a needless re-auth and (b) clobbered the fresh
token every other session on that store depends on -- one stale session poisoned
the well for all of them. syncFromDisk alone couldn't cover this: the wipe runs
inside connect() (SDK auth() catch), before the connect-retry could re-read.
Now invalidateCredentials("tokens") first checks whether disk holds a token
newer than the one we failed with (mtime-guarded, access_token differs). If so
it adopts that token and keeps it on disk instead of clearing; the SDK's own
post-invalidation retry (authInternal -> refreshAuthorization) then refreshes
with the fresh token and succeeds -- no re-auth, no poisoning. A genuine
invalidation (nothing newer on disk) still clears as before.
Tests: adopt-newer-token-instead-of-wipe (store preserved); clear-when-nothing-
newer. Full suite green, typecheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut the comments added for the cross-process rotation fix down to the reason/use-case, dropping restatements of what the code already shows. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… retry, atomic writes
E2E-verified on an experimental pod against the real prod /oauth (3-min
token-age gate; two plugin sessions sharing one store):
- OLD build: the session losing a refresh race gets 400, wipes the shared
store, and surfaces [SETUP_REQUIRED] — reproduced on demand.
- With these changes: the loser recovers silently in both race shapes.
Three additions on top of the existing rails:
1. invalidateCredentials('tokens') now polls the store briefly (2s,
GLEAN_ROTATION_GRACE_MS) before the destructive clear — the loser's
invalid_grant usually arrives milliseconds BEFORE the winner's write
lands, and clearing immediately poisons the shared store for every
session. Skipped when no refresh token was held (no race possible).
2. Connect-level sibling-refresh retry: when two sessions refresh the same
grant simultaneously, fosite fails the loser with invalid_request (NOT
invalid_grant — observed live), which the SDK rethrows raw without
touching invalidateCredentials. createRemoteClient now recognizes
refresh-shaped failures, waits out the same grace window for the
sibling's token, and retries once.
3. saveCredentials writes temp-file + rename so concurrent sibling writers
can never leave a torn store that parses as wiped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pragati-agrawal-glean
force-pushed
the
pragati/fix-plugin-dcr-client-reuse
branch
from
July 29, 2026 07:51
e6527b6 to
a262116
Compare
pragati-agrawal-glean
marked this pull request as ready for review
July 29, 2026 09:27
pragati-agrawal-glean
requested review from
eshwar-sundar-glean,
garvit-scio,
mohit-gupta-glean and
swarup-padhi-glean
as code owners
July 29, 2026 09:27
eshwar-sundar-glean
approved these changes
Jul 29, 2026
eshwar-sundar-glean
left a comment
Collaborator
There was a problem hiding this comment.
Abandoned sign in approach makes sense, but I am not sure about tieing setup to instance URL for reset, what if the user wants to just change email? This might not be a common scenario, but better to check with Roshan
| // a fresh client registration that permanently piles up in the user's | ||
| // authorized-apps list. The on-disk remote-tool cache for the previous | ||
| // URL is left intact either way (so switching back is instant). | ||
| const urlChanged = previousUrl !== normalized; |
Collaborator
There was a problem hiding this comment.
This might not be common but what if they want to clear setup for logging in as a different email ?
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pragati-agrawal-glean
force-pushed
the
pragati/fix-plugin-dcr-client-reuse
branch
from
July 30, 2026 10:45
a262116 to
20a22b8
Compare
…abandoned sign-ins
One user accumulated 34 'Glean Claude Code Plugin' clients in their
authorized-apps list. The server never dedupes registrations (the DCR
endpoint has no user identity in scope), and a 7-day sweeper deletes
never-used clients — so every needless client-side re-registration either
piles up as a UI row or becomes a future invalid_client. Log correlation
showed every setup.configured/setup.reset was followed by a fresh
registration 6-9s later.
Client-side causes fixed:
1. setup({email}/{server_url}) wiped credentials (including clientInfo)
even when the resolved URL was IDENTICAL to the configured one — the
dominant trigger, one client per call. Now only an actual URL change
clears auth state.
2. An abandoned sign-in (browser closed, elicitation timeout) forced
invalidateCredentials('all') → fresh DCR. Now the existing registration
is retried first (abandonPendingSignIn budget); a fresh DCR is the
escalation path after two consecutive failures with the same client —
the pattern of a client actually rejected server-side.
3. clientInformation() now syncs from the shared store, so two sessions
starting concurrently no longer both register (the SDK registers
whenever it returns undefined).
4. Every registration is now logged ([auth] Registered OAuth client) —
previously invisible.
E2E note: one client survived a full day of race testing on an exp pod —
sign-in, a bug-induced token wipe, re-sign-in, and six refresh rotations —
with zero new registrations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pragati-agrawal-glean
force-pushed
the
pragati/fix-plugin-dcr-client-reuse
branch
from
July 30, 2026 10:46
20a22b8 to
fc6b0f3
Compare
pragati-agrawal-glean
added a commit
to gleanwork/agent-plugins
that referenced
this pull request
Jul 30, 2026
… and abandoned sign-ins Port of gleanwork/glean-plugins-vnext#45 (stacked on the rotation fix). One user accumulated 34 registered clients: the server's DCR endpoint never dedupes and a 7-day sweeper reaps never-used clients (turning them into later invalid_client failures), while the plugin discarded its registration far too eagerly. Fixes: - setup({email}/{server_url}) only clears credentials when the resolved URL actually changed — re-running setup with the same instance was the dominant trigger (one new client per call). - Abandoned sign-ins keep the registration and retry it (abandonPendingSignIn budget); full re-registration only after two consecutive failures with the same client. - clientInformation() syncs from the shared store so concurrently starting sessions don't both register. - Registrations are logged ([auth] Registered OAuth client). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pragati-agrawal-glean
force-pushed
the
pragati/fix-plugin-token-rotation-reauth
branch
from
August 13, 2026 05:59
65c90a9 to
5d53b29
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #44 (uses its
syncFromDiskrails).Problem
One user accumulated 34 "Glean Claude Code Plugin" clients in their authorized-apps list. Server side,
/oauth/registernever dedupes (it's a NoUserAuth endpoint — no user identity in scope, always mintsname[:16]_uuidv4), andStaleOAuthClientDeleterreaps clients with no token issuance after 7 days — so every needless client-side registration either piles up as a UI row (consent lives 180d) or becomes a futureinvalid_clientloop.Log correlation proved the client-side triggers: every
setup.configured/setup.resetwas followed by a new client_id 6–9 seconds later (6 wipes → 6 clients on one day alone).Fixes
[SETUP_REQUIRED]→ agent-calls-setup loop — keeps the existingclient_idand does not burn another registration.reset=trueis an explicit full reset. It clears the saved URL, grant, remote-tool cache, and registered DCR client. The next setup intentionally registers a new client. This matches reset semantics for a clean reconfiguration.abandonPendingSignInbudget) instead ofinvalidateCredentials("all")→ fresh DCR. Full re-registration remains the escalation path after repeated failures with the same client — the signature of a client actually rejected server-side (e.g. reaped as never-used).clientInformation()syncs from the shared store, so concurrently-starting sessions can't both register (the SDK registers whenever it returnsundefined).[auth] Registered OAuth client:) — they were previously invisible, which is why this needed forensic log correlation to find.Verification
Automated checks
npm run typecheckpassed.npm testpassed.npm run buildpassed.git diff --checkpassed.Existing E2E verification
…0ae0d4a0) survived a full day of refresh-race testing — sign-in, a bug-induced token wipe, re-sign-in, six refresh rotations — zero new registrations (see [Plugin] Fix intermittent re-auth from cross-process refresh-token rotation #44 for the race E2E itself).Local DCR validation
The local validation used the current PR branch at commit
815f4d8, installed from the local marketplace, and an isolated store:https://salessavvy-test-be.glean.com/mcp/gateway/proxy.https://salessavvy-test-be.glean.com/oauth.~/.glean-dcr-reuse-check/.Glean_Claude_Cod_a1afb446-4f30-4c99-af72-fc83665b90b5.setup({reset:true})removedmcp-credentials.jsonentirely.Glean_Claude_Cod_9dc48be2-3b8d-45e5-a70d-9e55f82ff516.Local OAuth race validation
The intended race procedure compared the pre-fix baseline with this PR:
41763ed.815f4d8.iat,exp, JWT lifetime, and presence of the refresh token. SST issued 120-second access tokens withstored_expires_in=120.mcp_status=401, then invoke the localfind_skillstool concurrently in two independent sessions.Current race-test limitation
/oauth/revokereturned200, but the subsequent MCP probe also returned200; the gateway therefore did not enter the required401refresh path.Follow-ups (not here): collapse the
PLUGIN_DATA_DIR/~/.gleanstore split; DCR self-recovery oninvalid_client.🤖 Generated with Claude Code