Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sep-2350-scope-accumulation-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Preserve previously requested OAuth scopes across Streamable HTTP, legacy SSE, automatic 401 handling, scope step-up, and `withOAuth`. Step-up unions explicit token, transport, and challenged scopes; when prior scope is unavailable, it reconstructs the initial protected-resource-metadata/provider fallback without over-requesting both.
13 changes: 9 additions & 4 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ hierarchy also exposes the same check as an explicit static guard
TypeScript — use whichever style your codebase prefers; both read the same brand.
Fine print (applies equally to `instanceof` and `isInstance`):

- **Version skew** — matching needs *both* copies at a brand-aware release; against an
- **Version skew** — matching needs _both_ copies at a brand-aware release; against an
older copy, behavior degrades to plain prototype `instanceof` (false across bundles).
During mixed-version rollouts, recognize errors without class identity: match
`error.name` plus the class's discriminant field (`code`, `status`), or reconstruct
Expand Down Expand Up @@ -1230,9 +1230,14 @@ TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains you

`StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'`
(default `'reauthorize'`). On `'reauthorize'` the transport re-authorizes with the
**union** of the previously-requested and challenged scope (`computeScopeUnion`); when
that union strictly exceeds the current token's granted scope (`isStrictScopeSuperset`),
the SDK bypasses the refresh-token branch and forces a fresh authorization request. On
**union** of the token grant, transport-tracked scope, and challenged scope
(`computeScopeUnion`). If the token response omitted `scope` and the transport has no
recorded request, it reconstructs the SDK's initial selection fallback — PRM
`scopes_supported`, then provider `clientMetadata.scope` — without adding both.
Automatic 401 handling and legacy SSE preserve that accumulated scope and the most
recent resource-metadata URL. When the union strictly exceeds the
current token's granted scope (`isStrictScopeSuperset`), the SDK bypasses the
refresh-token branch and forces a fresh authorization request. On
`'throw'` the transport raises `InsufficientScopeError` and does not re-authorize — set
this for `client_credentials` / m2m clients where re-authorization can't widen scope, or
to gate the consent prompt behind UX. Step-up retries are hard-capped per send
Expand Down
14 changes: 11 additions & 3 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
serverUrl: URL;
/** Fetch function configured with the transport's `requestInit`, for making auth requests. */
fetchFn: FetchLike;
/** Accumulated OAuth scope from previous challenges, if the transport has one. */
scope?: string;
/** Resource metadata URL from previous challenges, if the transport has one. */
resourceMetadataUrl?: URL;
}

/**
Expand Down Expand Up @@ -179,11 +183,15 @@
ctx: UnauthorizedContext,
extraAuthOptions?: Pick<AuthOptions, 'skipIssuerMetadataValidation'>
): Promise<void> {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response);
const challenge = extractWWWAuthenticateParams(ctx.response);
const tokens = await provider.tokens();
const unionScope = computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope);
const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope);
const result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: unionScope,
...(forceReauthorization ? { forceReauthorization } : {}),

Check failure on line 194 in packages/client/src/client/auth.ts

View check run for this annotation

Claude / Claude Code Review

forceReauthorization over-triggers when token response omitted scope, bypassing refresh on routine 401

handleOAuthUnauthorized computes forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope) with no guard for the case where the stored token has no scope field — which RFC 6749 §5.1 permits (and many ASes do) when granted == requested. Since the transports now thread accumulated _scope/challenge scope into ctx.scope on every 401, an ordinary access-token expiry then bypasses the working refresh token and forces interactive re-authorization (or throws UnauthorizedError), a regressio
Comment on lines +186 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 handleOAuthUnauthorized computes forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope) with no guard for the case where the stored token has no scope field — which RFC 6749 §5.1 permits (and many ASes do) when granted == requested. Since the transports now thread accumulated _scope/challenge scope into ctx.scope on every 401, an ordinary access-token expiry then bypasses the working refresh token and forces interactive re-authorization (or throws UnauthorizedError), a regression from the pre-PR silent-refresh behavior. Mirror the guard this same PR adds in withOAuth (lastTokenScope !== undefined && isStrictScopeSuperset(...)), or reconstruct the prior requested scope the way _stepUpAuthorize does before comparing.

Extended reasoning...

What the bug is

handleOAuthUnauthorized (auth.ts:186-194) now computes:

const unionScope = computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope);
const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope);

isStrictScopeSuperset (auth.ts:638-645) treats an undefined/absent current scope as the empty set, so it returns true whenever unionScope is non-empty and the stored token record has no scope field. Per RFC 6749 §5.1, authorization servers commonly omit scope from the token response when the granted scope equals the requested scope, and exchangeAuthorization/saveTokens store exactly what the AS returned — so tokens?.scope === undefined is the normal steady state for many providers, not an edge case.

The code path that triggers it

This PR newly threads the transports' accumulated _scope (and resourceMetadataUrl) into ctx.scope on every 401 for Streamable HTTP _send/_startOrAuthSse and legacy SSE, all of which route through adaptOAuthProviderhandleOAuthUnauthorized. So the trigger only requires that any earlier WWW-Authenticate challenge carried a scope param (e.g. the very first pre-token 401 that kicked off authorization — RFC 6750 servers routinely include one), or that the current 401 does.

Step-by-step regression

  1. Initial connection: server returns 401 with WWW-Authenticate: Bearer scope=\"read\" → transport records _scope = 'read', user completes interactive auth.
  2. Token response omits scope (granted == requested, per §5.1) → stored tokens are { access_token, refresh_token } with no scope field.
  3. Later, the access token expires; the server returns a routine 401.
  4. Transport calls onUnauthorized with ctx.scope = 'read' (new in this PR) → unionScope = 'read', tokens?.scope = undefinedisStrictScopeSuperset('read', undefined)trueforceReauthorization: true.
  5. authInternal (auth.ts:1313) skips the refresh_token branch entirely when forceReauthorization is set and starts a fresh authorization request; for interactive providers that calls redirectToAuthorization and returns 'REDIRECT', so handleOAuthUnauthorized throws UnauthorizedError and the user is bounced through the consent flow on every ordinary token expiry.

Before this commit, handleOAuthUnauthorized never passed forceReauthorization, so step 3–5 was handled by a silent refresh + retry. This is therefore a concrete behavioral regression introduced here, not a pre-existing quirk.

Why the existing code doesn't prevent it

The isStrictScopeSuperset doc comment describes its "conservative" treatment of an absent token scope, but that reasoning was written for the 403 insufficient_scope step-up path, where the server has explicitly said the current grant is insufficient and forcing a fresh authorization is the right call. A plain 401 is overwhelmingly token expiry, where the refresh token is exactly the right tool. The same PR demonstrates awareness of this distinction in the sibling paths: withOAuth (middleware.ts:66) guards with lastTokenScope !== undefined && isStrictScopeSuperset(...), and _stepUpAuthorize reconstructs the previously-requested scope from discoveryState/clientMetadata before comparing precisely so an omitted token scope isn't treated as "no prior grant". handleOAuthUnauthorized does neither, so the two 401 paths this PR touches are inconsistent.

Impact

For any provider whose token responses omit scope and any deployment where a challenge scope has been observed (or the transport accumulated one), every access-token expiry becomes an interactive consent bounce (browser redirect) or a hard UnauthorizedError for non-interactive clients — instead of the silent refresh that worked before this PR. This affects Streamable HTTP, legacy SSE, and the raw handleOAuthUnauthorized helper.

How to fix

Gate the force the same way withOAuth does in this PR:

const forceReauthorization = tokens?.scope !== undefined && isStrictScopeSuperset(unionScope, tokens.scope);

or reconstruct the previously-requested scope (transport ctx.scope, then PRM scopes_supported, then clientMetadata.scope) the way _stepUpAuthorize does and compare the union against that, so a genuinely widened challenge still forces re-authorization while a routine expiry keeps using the refresh token.

fetchFn: ctx.fetchFn,
...extraAuthOptions
});
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';

import type { OAuthClientProvider } from './auth';
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth';
import { auth, computeScopeUnion, extractWWWAuthenticateParams, isStrictScopeSuperset, UnauthorizedError } from './auth';

/**
* Middleware function that wraps and enhances fetch functionality.
Expand Down Expand Up @@ -39,11 +39,13 @@ export const withOAuth =
(provider: OAuthClientProvider, baseUrl?: string | URL): Middleware =>
next => {
return async (input, init) => {
let lastTokenScope: string | undefined;
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);

// Add authorization header if tokens are available
const tokens = await provider.tokens();
lastTokenScope = tokens?.scope;
if (tokens) {
headers.set('Authorization', `Bearer ${tokens.access_token}`);
}
Expand All @@ -60,11 +62,14 @@ export const withOAuth =

// Use provided baseUrl or extract from request URL
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
const unionScope = computeScopeUnion(lastTokenScope, scope);
const forceReauthorization = lastTokenScope !== undefined && isStrictScopeSuperset(unionScope, lastTokenScope);

const result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
scope: unionScope,
...(forceReauthorization ? { forceReauthorization } : {}),
fetchFn: next
});

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand Down Expand Up @@ -193,8 +194,8 @@ export class SSEClientTransport implements Transport {
this._last401Response = response;
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
this._scope = computeScopeUnion(this._scope, scope);
}
}

Expand All @@ -209,15 +210,23 @@ export class SSEClientTransport implements Transport {
const response = this._last401Response;
this._last401Response = undefined;
this._eventSource?.close();
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
() => this._startOrAuth().then(resolve, reject),
// onUnauthorized failed → not yet reported.
error => {
this.onerror?.(error);
reject(error);
}
);
this._authProvider
.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope
})
.then(
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
() => this._startOrAuth().then(resolve, reject),
// onUnauthorized failed → not yet reported.
error => {
this.onerror?.(error);
reject(error);
}
);
return;
}
const error = new UnauthorizedError();
Expand Down Expand Up @@ -357,15 +366,17 @@ export class SSEClientTransport implements Transport {
if (response.status === 401 && this._authProvider) {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
this._scope = computeScopeUnion(this._scope, scope);
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
fetchFn: this._fetchWithInit,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope
});
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
Expand Down
36 changes: 24 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,13 @@ export type StreamableHTTPClientTransportOptions = {
* `WWW-Authenticate: Bearer error="insufficient_scope"`.
*
* - `'reauthorize'` (default): the transport runs the step-up authorization
* flow — computes the union of the previously-requested scope and the
* challenged scope, calls {@linkcode index.auth | auth()} (forcing a
* fresh authorization request when the union strictly exceeds the current
* token's granted scope, since refresh cannot widen scope per RFC 6749
* §6), and retries the request once. Retries are bounded by
* flow — computes the union of the current token's granted scope, the
* previously requested scope, and the challenged scope. If no prior scope
* was recorded, it reconstructs the initial PRM/provider fallback before
* calling {@linkcode index.auth | auth()} (forcing a fresh authorization request
* when the union strictly exceeds the current token's granted scope, since
* refresh cannot widen scope per RFC 6749 §6), and retries the request once.
* Retries are bounded by
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand Down Expand Up @@ -398,10 +400,16 @@ export class StreamableHTTPClientTransport implements Transport {
this._resourceMetadataUrl = challenge.resourceMetadataUrl;
}

// Spec step-up: union of previously-requested scope and challenged scope,
// so previously-granted permissions are not lost on re-authorization.
// Spec step-up: preserve the explicit prior scope from the token and transport.
// Some authorization servers omit `scope` when it equals the request; if neither
// source records it, reconstruct the SDK's initial-selection fallback (PRM first,
// then provider default) rather than adding both and over-requesting permissions.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const explicitPreviousScope = computeScopeUnion(tokens?.scope, this._scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const previousScope = explicitPreviousScope ?? (resourceScope || this._oauthProvider.clientMetadata.scope);
const unionScope = computeScopeUnion(previousScope, challenge.scope);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand Down Expand Up @@ -535,7 +543,7 @@ export class StreamableHTTPClientTransport implements Transport {
if (response.status === 401 && this._authProvider) {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
// Preserve any union accumulated by `_stepUpAuthorize` so a 401
// mid-chain does not narrow `_scope` back to the challenge value.
this._scope = computeScopeUnion(this._scope, scope);
Expand All @@ -545,7 +553,9 @@ export class StreamableHTTPClientTransport implements Transport {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
fetchFn: this._fetchWithInit,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope
});
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
Expand Down Expand Up @@ -984,7 +994,7 @@ export class StreamableHTTPClientTransport implements Transport {
// Store WWW-Authenticate params for interactive finishAuth() path
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
// Preserve any union accumulated by `_stepUpAuthorize` so a 401
// mid-chain does not narrow `_scope` back to the challenge value.
this._scope = computeScopeUnion(this._scope, scope);
Expand All @@ -994,7 +1004,9 @@ export class StreamableHTTPClientTransport implements Transport {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
fetchFn: this._fetchWithInit,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope
});
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
Expand Down
82 changes: 82 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand Down Expand Up @@ -215,6 +216,87 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('forces reauthorization with the full scope union instead of refreshing a narrower token', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
const tokenEndpoint = 'https://auth.example.com/token';
const provider: OAuthClientProvider = {
get redirectUrl() {
return 'http://localhost:3000/callback';
},
get clientMetadata() {
return {
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
};
},
clientInformation: vi.fn().mockResolvedValue({ client_id: 'test-client' }),
tokens: vi.fn().mockResolvedValue({
access_token: 'old-token',
token_type: 'Bearer',
refresh_token: 'refresh-token',
scope: 'openid read'
}),
saveTokens: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
redirectToAuthorization: vi.fn()
};
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer scope="write"' }
});

mockFetch.mockImplementation(url => {
const urlString = url.toString();
if (urlString === resourceMetadataUrl.toString()) {
return Promise.resolve(
Response.json({
resource: 'https://api.example.com/mcp',
authorization_servers: ['https://auth.example.com']
})
);
}
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve(
Response.json({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: tokenEndpoint,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
if (urlString === tokenEndpoint) {
return Promise.resolve(
Response.json({
access_token: 'refreshed-token',
token_type: 'Bearer',
scope: 'openid read'
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
});

await expect(
handleOAuthUnauthorized(provider, {
response,
serverUrl: new URL('https://api.example.com/mcp'),
fetchFn: mockFetch,
resourceMetadataUrl,
scope: 'read'
})
).rejects.toBeInstanceOf(UnauthorizedError);

expect(mockFetch.mock.calls[0]?.[0].toString()).toBe(resourceMetadataUrl.toString());
expect(mockFetch.mock.calls.some(([url]) => url.toString() === tokenEndpoint)).toBe(false);
const authorizationUrl = (provider.redirectToAuthorization as Mock).mock.calls[0]?.[0] as URL;
expect(authorizationUrl.searchParams.get('scope')).toBe('openid read write');
});
});

describe('isStrictScopeSuperset', () => {
it.each([
{ union: undefined, current: undefined, expected: false },
Expand Down
Loading
Loading