@@ -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