Skip to content

Commit 3924de9

Browse files
SAY-5KKonstantinov
andauthored
fix(client/auth): propagate saveTokens errors after refresh (#2053)
Signed-off-by: SAY-5 <say.apm35@gmail.com> Signed-off-by: Sai Asish Y <say.apm35@gmail.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <konstantin@mach5technology.com>
1 parent a81ef34 commit 3924de9

4 files changed

Lines changed: 293 additions & 5 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Let `saveTokens` failures surface after a successful token refresh. In `auth()`, one `try`
6+
wrapped both `refreshAuthorization()` and the `provider.saveTokens()` that persists its
7+
result, and the `catch` deliberately swallows anything that is not an `OAuthError` — plus
8+
`ServerError` — so that a failed refresh falls through to a fresh authorization request.
9+
A persistence error thrown by the provider landed in that same branch: it was discarded
10+
with no log and no rethrow, and `auth()` continued to `startAuthorization()` and returned
11+
`'REDIRECT'`.
12+
13+
Against an authorization server that rotates refresh tokens (the OAuth 2.1 default, and
14+
Keycloak's) this loses credentials rather than merely hiding an error. The exchange has
15+
already succeeded server-side, so the old refresh token is invalidated at the moment the
16+
new one is issued; dropping the new token set leaves nothing usable on either side. On a
17+
headless or CLI client, where `redirectToAuthorization` is typically a no-op, the fallthrough
18+
is silent and the client is left with stale tokens and no indication of why.
19+
20+
The `try`/`catch` now covers only `refreshAuthorization()`. Persisting the result happens
21+
after it, on an unguarded path, so a provider's I/O error propagates to the caller.
22+
23+
Refresh-request failures keep their existing control flow exactly: a `ServerError` or an
24+
unknown error still falls through to a new authorization flow, a non-`ServerError`
25+
`OAuthError` is still rethrown, and `InsecureTokenEndpointError` is still surfaced. The
26+
SEP-2352 `issuer` stamp written with the refreshed tokens is unchanged.
27+
28+
Those fallbacks no longer happen in silence, though. Both routes to an unexplained
29+
re-authorization now emit a `console.warn` naming the cause: the in-place fallthrough in
30+
the refresh block, and `auth()`'s outer recovery for `invalid_grant`, `invalid_client`,
31+
and `unauthorized_client`, which discards stored credentials and retries. The second one
32+
matters most in practice — an expired, revoked, or rotation-reuse-detected refresh token
33+
is reported as `invalid_grant`, which is precisely the state a dropped token set leaves
34+
behind for the next call.
35+
36+
Consumers whose `OAuthClientProvider.saveTokens` can reject should note that `auth()` may
37+
now reject where it previously returned `'REDIRECT'` — that rejection is the failure that
38+
was being discarded.

docs/migration/upgrade-to-v2.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,22 @@ OAuth `onUnauthorized` behavior, for composing your own adapter).
11161116
discovery state so the callback-leg check on retry does not mask the original error.
11171117
A provider whose `invalidateCredentials()` implementation special-cases the `'all'`
11181118
scope must handle the split calls.
1119+
- **Token persistence failures after a refresh now propagate.** v1 wrapped both
1120+
`refreshAuthorization()` and the `saveTokens()` that persists its result in one
1121+
`try`/`catch`, so a provider's persistence error was discarded alongside AS-side refresh
1122+
failures and `auth()` fell through to a fresh authorization request, returning
1123+
`'REDIRECT'`. Only the refresh call is guarded now — persisting runs after it and rejects
1124+
to the caller. Against an AS that rotates refresh tokens this was destructive rather than
1125+
merely quiet: the exchange has already succeeded, so the old refresh token is invalidated
1126+
server-side the moment the new one is issued, and dropping the new token set leaves
1127+
nothing usable on either side. A provider whose `saveTokens()` can throw (transient
1128+
storage errors, file-lock contention) must handle the rejection from `auth()` — and from
1129+
the transport 401-retry paths built on it — where v1 silently re-authorized. Refresh
1130+
failures themselves keep their control flow: a `ServerError` or an unknown error still
1131+
falls through to a new authorization request, and `invalid_grant` / `invalid_client` /
1132+
`unauthorized_client` are still recovered by discarding stored credentials and retrying.
1133+
Both routes now emit a `console.warn` naming the cause, so an unexplained re-auth prompt
1134+
can be traced to the failure that triggered it.
11191135
11201136
#### OAuth client flow errors (new)
11211137

packages/client/src/client/auth.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,27 @@ export interface AuthOptions {
984984
forceReauthorization?: boolean;
985985
}
986986

987+
/**
988+
* Recovering from a recoverable OAuth error discards stored credentials and silently starts a
989+
* fresh authorization. On a headless client whose `redirectToAuthorization()` is a no-op that
990+
* recovery is indistinguishable from nothing happening at all, so name the cause. The most
991+
* common case is `invalid_grant` — an expired, revoked, or rotation-reuse-detected refresh
992+
* token. See issue #2034.
993+
*/
994+
function warnCredentialInvalidation(provider: OAuthClientProvider, error: OAuthError, invalidated: string): void {
995+
// `invalidateCredentials` is optional. When a provider omits it nothing is actually
996+
// discarded, so do not claim otherwise — the stale credential is still in storage and
997+
// will be replayed on the next call, which is the thing worth telling the operator.
998+
const action =
999+
provider.invalidateCredentials === undefined
1000+
? `retrying authorization without discarding the stored ${invalidated} (provider implements no invalidateCredentials())`
1001+
: `invalidating the stored ${invalidated} and retrying authorization`;
1002+
// JSON-stringify the AS-supplied values so attacker-supplied control characters cannot
1003+
// forge log lines — the authorization server is resolved from the resource server's
1004+
// metadata, and both `code` and `message` are echoed from its response verbatim.
1005+
console.warn(`[mcp-sdk] OAuth ${JSON.stringify(error.code)}${action}. Cause: ${JSON.stringify(error.message)}`);
1006+
}
1007+
9871008
/**
9881009
* Orchestrates the full auth flow with a server.
9891010
*
@@ -997,13 +1018,15 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions):
9971018
// Handle recoverable error types by invalidating credentials and retrying
9981019
if (error instanceof OAuthError) {
9991020
if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) {
1021+
warnCredentialInvalidation(provider, error, 'client credentials and tokens');
10001022
// Not 'all' — preserve discoveryState so the callback-leg gate on retry doesn't
10011023
// fire a false 'discoveryState was not available on the callback leg' AuthorizationServerMismatchError that masks the
10021024
// real invalid_client.
10031025
await provider.invalidateCredentials?.('client');
10041026
await provider.invalidateCredentials?.('tokens');
10051027
return await authInternal(provider, options);
10061028
} else if (error.code === OAuthErrorCode.InvalidGrant) {
1029+
warnCredentialInvalidation(provider, error, 'tokens');
10071030
await provider.invalidateCredentials?.('tokens');
10081031
return await authInternal(provider, options);
10091032
}
@@ -1303,19 +1326,17 @@ async function authInternal(
13031326
// current token's granted scope — refreshing would not widen it (RFC 6749
13041327
// §6), so skip straight to a fresh authorization request.
13051328
if (tokens?.refresh_token && !forceReauthorization) {
1329+
let newTokens: OAuthTokens | undefined;
13061330
try {
13071331
// Attempt to refresh the token
1308-
const newTokens = await refreshAuthorization(authorizationServerUrl, {
1332+
newTokens = await refreshAuthorization(authorizationServerUrl, {
13091333
metadata,
13101334
clientInformation,
13111335
refreshToken: tokens.refresh_token,
13121336
resource,
13131337
addClientAuthentication: provider.addClientAuthentication,
13141338
fetchFn
13151339
});
1316-
1317-
await provider.saveTokens({ ...newTokens, issuer }, infoCtx);
1318-
return 'AUTHORIZED';
13191340
} catch (error) {
13201341
// A non-TLS token endpoint is a configuration error — re-authorizing cannot
13211342
// fix it. Surface it so the consumer sees the misconfiguration instead of an
@@ -1325,12 +1346,29 @@ async function authInternal(
13251346
}
13261347
// 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.
13271348
if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) {
1328-
// Could not refresh OAuth tokens
1349+
// Could not refresh OAuth tokens. The fallthrough to a fresh authorization
1350+
// request is deliberate, but it is invisible on a headless client whose
1351+
// redirectToAuthorization() is a no-op — so say why it happened.
1352+
// JSON-stringify the cause: on the non-OAuth-shaped path it carries the raw
1353+
// response body, so it is arbitrary attacker-supplied bytes.
1354+
console.warn(
1355+
`[mcp-sdk] Could not refresh OAuth tokens; falling back to a new authorization request. ` +
1356+
`Cause: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`
1357+
);
13291358
} else {
13301359
// Refresh failed for another reason, re-throw
13311360
throw error;
13321361
}
13331362
}
1363+
1364+
// Persist any newly minted tokens. Persistence failures must always
1365+
// propagate: the authorization server may have rotated the refresh
1366+
// token, so silently dropping the new tokens would leave the client
1367+
// with credentials that are already invalid server-side.
1368+
if (newTokens) {
1369+
await provider.saveTokens({ ...newTokens, issuer }, infoCtx);
1370+
return 'AUTHORIZED';
1371+
}
13341372
}
13351373

13361374
const state = provider.state ? await provider.state() : undefined;

0 commit comments

Comments
 (0)