Skip to content

[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
pragati/fix-plugin-token-rotation-reauthfrom
pragati/fix-plugin-dcr-client-reuse
Open

[Plugin] Reuse the DCR client instead of re-registering on setup and abandoned sign-ins#45
pragati-agrawal-glean wants to merge 12 commits into
pragati/fix-plugin-token-rotation-reauthfrom
pragati/fix-plugin-dcr-client-reuse

Conversation

@pragati-agrawal-glean

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

Copy link
Copy Markdown
Contributor

Stacked on #44 (uses its syncFromDisk rails).

Problem

One user accumulated 34 "Glean Claude Code Plugin" clients in their authorized-apps list. Server side, /oauth/register never dedupes (it's a NoUserAuth endpoint — no user identity in scope, always mints name[:16]_uuidv4), and StaleOAuthClientDeleter reaps 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 future invalid_client loop.

Log correlation proved the client-side triggers: every setup.configured / setup.reset was followed by a new client_id 6–9 seconds later (6 wipes → 6 clients on one day alone).

Fixes

  1. Ordinary setup reuses the DCR client. Re-running setup with the same server and no reset — the dominant trigger, fed by the [SETUP_REQUIRED] → agent-calls-setup loop — keeps the existing client_id and does not burn another registration.
  2. reset=true is 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.
  3. Abandoned sign-ins retry the existing client (abandonPendingSignIn budget) instead of invalidateCredentials("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).
  4. clientInformation() syncs from the shared store, so concurrently-starting sessions can't both register (the SDK registers whenever it returns undefined).
  5. Registrations are now logged ([auth] Registered OAuth client: ) — they were previously invisible, which is why this needed forensic log correlation to find.

Verification

Automated checks

  • 8 auth-hardening unit tests added in this branch; 224 tests passed.
  • npm run typecheck passed.
  • npm test passed.
  • npm run build passed.
  • git diff --check passed.

Existing E2E verification

Local DCR validation

The local validation used the current PR branch at commit 815f4d8, installed from the local marketplace, and an isolated store:

  • SST MCP endpoint: https://salessavvy-test-be.glean.com/mcp/gateway/proxy.
  • SST OAuth issuer: https://salessavvy-test-be.glean.com/oauth.
  • Credential store: ~/.glean-dcr-reuse-check/.
  • Initial setup registered client Glean_Claude_Cod_a1afb446-4f30-4c99-af72-fc83665b90b5.
  • Repeated setup with no reset preserved that same client ID.
  • After the implementation change, setup({reset:true}) removed mcp-credentials.json entirely.
  • The next setup registered a new client, Glean_Claude_Cod_9dc48be2-3b8d-45e5-a70d-9e55f82ff516.
  • New access and refresh tokens were present after the new setup.
  • No raw access or refresh tokens were recorded in the PR.

Local OAuth race validation

The intended race procedure compared the pre-fix baseline with this PR:

  • Baseline plugin: commit 41763ed.
  • Candidate plugin: commit 815f4d8.
  • Baseline and candidate credential stores were isolated.
  • Fresh access-token JWT claims were inspected locally: issuer, audience, scope, iat, exp, JWT lifetime, and presence of the refresh token. SST issued 120-second access tokens with stored_expires_in=120.
  • The intended procedure was to invalidate only the access token while retaining the refresh token, confirm mcp_status=401, then invoke the local find_skills tool concurrently in two independent sessions.

Current race-test limitation

  • In the final SST rerun, /oauth/revoke returned 200, but the subsequent MCP probe also returned 200; the gateway therefore did not enter the required 401 refresh path.
  • Natural JWT expiry was not a reliable trigger because the MCP gateway accepted expired JWTs.
  • Consequently, this final rerun does not claim a completed baseline or candidate race result. The remaining validation blocker is making SST revocation deterministic across the OAuth issuer and MCP gateway, including any cross-node revocation-cache propagation.

Follow-ups (not here): collapse the PLUGIN_DATA_DIR / ~/.glean store split; DCR self-recovery on invalid_client.

🤖 Generated with Claude Code

pragati-agrawal-glean and others added 4 commits July 23, 2026 18:05
…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>

@eshwar-sundar-glean eshwar-sundar-glean left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread src/index.ts Outdated
// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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
pragati-agrawal-glean force-pushed the pragati/fix-plugin-dcr-client-reuse branch from a262116 to 20a22b8 Compare July 30, 2026 10:45
…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
pragati-agrawal-glean force-pushed the pragati/fix-plugin-dcr-client-reuse branch from 20a22b8 to fc6b0f3 Compare July 30, 2026 10:46
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
pragati-agrawal-glean force-pushed the pragati/fix-plugin-token-rotation-reauth branch from 65c90a9 to 5d53b29 Compare August 13, 2026 05:59
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