-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(client/auth): propagate saveTokens errors after refresh #2053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5be12c5
25d8466
f2b8df4
22f9756
54e0f8d
7d90a12
c77fdc2
52f492b
70ba9a0
ced0544
f11b207
ab7831c
0754542
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| --- | ||
|
|
||
| Let `saveTokens` failures surface after a successful token refresh. In `auth()`, one `try` | ||
| wrapped both `refreshAuthorization()` and the `provider.saveTokens()` that persists its | ||
| result, and the `catch` deliberately swallows anything that is not an `OAuthError` — plus | ||
| `ServerError` — so that a failed refresh falls through to a fresh authorization request. | ||
| A persistence error thrown by the provider landed in that same branch: it was discarded | ||
| with no log and no rethrow, and `auth()` continued to `startAuthorization()` and returned | ||
| `'REDIRECT'`. | ||
|
|
||
| Against an authorization server that rotates refresh tokens (the OAuth 2.1 default, and | ||
| Keycloak's) this loses credentials rather than merely hiding an error. The exchange has | ||
| already succeeded server-side, so the old refresh token is invalidated at the moment the | ||
| new one is issued; dropping the new token set leaves nothing usable on either side. On a | ||
| headless or CLI client, where `redirectToAuthorization` is typically a no-op, the fallthrough | ||
| is silent and the client is left with stale tokens and no indication of why. | ||
|
|
||
| The `try`/`catch` now covers only `refreshAuthorization()`. Persisting the result happens | ||
| after it, on an unguarded path, so a provider's I/O error propagates to the caller. | ||
|
|
||
| Refresh-request failures keep their existing control flow exactly: a `ServerError` or an | ||
| unknown error still falls through to a new authorization flow, a non-`ServerError` | ||
| `OAuthError` is still rethrown, and `InsecureTokenEndpointError` is still surfaced. The | ||
| SEP-2352 `issuer` stamp written with the refreshed tokens is unchanged. | ||
|
|
||
| Those fallbacks no longer happen in silence, though. Both routes to an unexplained | ||
| re-authorization now emit a `console.warn` naming the cause: the in-place fallthrough in | ||
| the refresh block, and `auth()`'s outer recovery for `invalid_grant`, `invalid_client`, | ||
| and `unauthorized_client`, which discards stored credentials and retries. The second one | ||
| matters most in practice — an expired, revoked, or rotation-reuse-detected refresh token | ||
| is reported as `invalid_grant`, which is precisely the state a dropped token set leaves | ||
| behind for the next call. | ||
|
|
||
| Consumers whose `OAuthClientProvider.saveTokens` can reject should note that `auth()` may | ||
| now reject where it previously returned `'REDIRECT'` — that rejection is the failure that | ||
| was being discarded. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -984,6 +984,27 @@ export interface AuthOptions { | |
| forceReauthorization?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Recovering from a recoverable OAuth error discards stored credentials and silently starts a | ||
| * fresh authorization. On a headless client whose `redirectToAuthorization()` is a no-op that | ||
| * recovery is indistinguishable from nothing happening at all, so name the cause. The most | ||
| * common case is `invalid_grant` — an expired, revoked, or rotation-reuse-detected refresh | ||
| * token. See issue #2034. | ||
| */ | ||
| function warnCredentialInvalidation(provider: OAuthClientProvider, error: OAuthError, invalidated: string): void { | ||
| // `invalidateCredentials` is optional. When a provider omits it nothing is actually | ||
| // discarded, so do not claim otherwise — the stale credential is still in storage and | ||
| // will be replayed on the next call, which is the thing worth telling the operator. | ||
| const action = | ||
| provider.invalidateCredentials === undefined | ||
| ? `retrying authorization without discarding the stored ${invalidated} (provider implements no invalidateCredentials())` | ||
| : `invalidating the stored ${invalidated} and retrying authorization`; | ||
| // JSON-stringify the AS-supplied values so attacker-supplied control characters cannot | ||
| // forge log lines — the authorization server is resolved from the resource server's | ||
| // metadata, and both `code` and `message` are echoed from its response verbatim. | ||
| console.warn(`[mcp-sdk] OAuth ${JSON.stringify(error.code)} — ${action}. Cause: ${JSON.stringify(error.message)}`); | ||
| } | ||
|
|
||
| /** | ||
| * Orchestrates the full auth flow with a server. | ||
| * | ||
|
|
@@ -997,13 +1018,15 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions): | |
| // Handle recoverable error types by invalidating credentials and retrying | ||
| if (error instanceof OAuthError) { | ||
| if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) { | ||
| warnCredentialInvalidation(provider, error, 'client credentials and tokens'); | ||
| // Not 'all' — preserve discoveryState so the callback-leg gate on retry doesn't | ||
| // fire a false 'discoveryState was not available on the callback leg' AuthorizationServerMismatchError that masks the | ||
| // real invalid_client. | ||
| await provider.invalidateCredentials?.('client'); | ||
| await provider.invalidateCredentials?.('tokens'); | ||
| return await authInternal(provider, options); | ||
| } else if (error.code === OAuthErrorCode.InvalidGrant) { | ||
| warnCredentialInvalidation(provider, error, 'tokens'); | ||
| await provider.invalidateCredentials?.('tokens'); | ||
| return await authInternal(provider, options); | ||
| } | ||
|
Comment on lines
1019
to
1032
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing (in code this diff touches and now advertises via the new warns): auth()'s recoverable-error retry is not gated on the callback leg — when the error comes from the authorization-code exchange (finishAuth), invalidate-and-retry is destructive or self-defeating. The new warnCredentialInvalidation calls surface and legitimize this broken path. Extended reasoning...Scenario 1 (invalid_grant, common): user completes auth; finishAuth() exchanges the code and saveTokens persists a good token set. The browser replays the callback URL (refresh/back button) and the app calls finishAuth() again with the now-consumed code. The AS returns invalid_grant; auth()'s catch (line 1020-1023) logs the new warn, calls invalidateCredentials('tokens') — wiping the freshly saved VALID tokens — then retries authInternal with the SAME dead authorizationCode, which re-POSTs it and rejects with invalid_grant. A duplicate callback logs the user out. Scenario 2 (invalid_client, e.g. expired DCR client secret at token exchange): the catch invalidates client credentials (line 1017), retries, and the retry hits authInternal line 1238-1240 — clientInformation is now undefined while authorizationCode is set — throwing the generic 'Existing OAuth client information is required when exchanging an authorization code', masking the real invalid_client. This defeats the adjacent comment's stated goal (lines 1014-1016) of not masking 'the real invalid_client'. Fix at one place: skip Verification: pre-existing — the failure path is real and reachable, and the diff touches the exact lines. Mechanics verified: |
||
|
|
@@ -1303,19 +1326,17 @@ async function authInternal( | |
| // current token's granted scope — refreshing would not widen it (RFC 6749 | ||
| // §6), so skip straight to a fresh authorization request. | ||
| if (tokens?.refresh_token && !forceReauthorization) { | ||
| let newTokens: OAuthTokens | undefined; | ||
| try { | ||
| // Attempt to refresh the token | ||
| const newTokens = await refreshAuthorization(authorizationServerUrl, { | ||
| newTokens = await refreshAuthorization(authorizationServerUrl, { | ||
| metadata, | ||
| clientInformation, | ||
| refreshToken: tokens.refresh_token, | ||
| resource, | ||
| addClientAuthentication: provider.addClientAuthentication, | ||
| fetchFn | ||
| }); | ||
|
|
||
| await provider.saveTokens({ ...newTokens, issuer }, infoCtx); | ||
| return 'AUTHORIZED'; | ||
| } catch (error) { | ||
| // A non-TLS token endpoint is a configuration error — re-authorizing cannot | ||
| // fix it. Surface it so the consumer sees the misconfiguration instead of an | ||
|
|
@@ -1325,12 +1346,29 @@ async function authInternal( | |
| } | ||
| // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. | ||
| if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) { | ||
| // Could not refresh OAuth tokens | ||
| // Could not refresh OAuth tokens. The fallthrough to a fresh authorization | ||
| // request is deliberate, but it is invisible on a headless client whose | ||
| // redirectToAuthorization() is a no-op — so say why it happened. | ||
| // JSON-stringify the cause: on the non-OAuth-shaped path it carries the raw | ||
| // response body, so it is arbitrary attacker-supplied bytes. | ||
| console.warn( | ||
| `[mcp-sdk] Could not refresh OAuth tokens; falling back to a new authorization request. ` + | ||
| `Cause: ${JSON.stringify(error instanceof Error ? error.message : String(error))}` | ||
| ); | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } else { | ||
| // Refresh failed for another reason, re-throw | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| // Persist any newly minted tokens. Persistence failures must always | ||
| // propagate: the authorization server may have rotated the refresh | ||
| // token, so silently dropping the new tokens would leave the client | ||
| // with credentials that are already invalid server-side. | ||
| if (newTokens) { | ||
| await provider.saveTokens({ ...newTokens, issuer }, infoCtx); | ||
|
Comment on lines
+1364
to
+1369
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit, pre-existing interaction: the saveTokens rejection this PR deliberately propagates out of auth() is collapsed back into a generic UnauthorizedError by the withOAuth fetch middleware (packages/client/src/client/middleware.ts:81-86), which discards the original error object (no Extended reasoning...A consumer uses createFetchWithMiddleware/withOAuth (the SDK's documented general-purpose OAuth fetch wrapper). Their provider's saveTokens() throws 'disk full' after a successful refresh against a rotating-refresh-token AS — exactly the scenario this PR fixes. auth() rejects with the raw Error as intended, but middleware.ts:81-86 catches it ( Verification: nit — every factual element of the candidate checks out. (1) The PR makes auth() reject with the raw saveTokens error: the diff moves persistence out of the try ("if (newTokens) { await provider.saveTokens({ ...newTokens, issuer }, infoCtx); return 'AUTHORIZED'; }", packages/client/src/client/auth.ts:1360-1366), and the changeset explicitly promises "a provider's I/O error propagates to the cal |
||
| return 'AUTHORIZED'; | ||
| } | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const state = provider.state ? await provider.state() : undefined; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.