Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) by mattzcarey · Pull Request #2265 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/client': patch
---

Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of
previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes, using the existing exported `computeScopeUnion` helper.

The 401 re-authorization path now preserves accumulated `scope` and `resourceMetadataUrl` context too: `UnauthorizedContext` exposes optional `scope` / `resourceMetadataUrl` fields for custom `AuthProvider.onUnauthorized` handlers, and `handleOAuthUnauthorized` folds that context into the next `auth()` call.
Comment thread
mattzcarey marked this conversation as resolved.

The `withOAuth` fetch middleware likewise unions the stored token scope with the 401 challenge scope when re-authenticating.
8 changes: 5 additions & 3 deletions docs/migration/upgrade-to-v2.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1217,9 +1217,11 @@ 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 current token's granted scope, the previously-requested scope, protected
resource metadata `scopes_supported`, the provider-configured default scope, and the
newly 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
`'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
11 changes: 8 additions & 3 deletions packages/client/src/client/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,10 @@ export interface UnauthorizedContext {
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;
}
Comment thread
mattzcarey marked this conversation as resolved.

/**
Expand DownExpand Up@@ -177,11 +181,12 @@ export async function handleOAuthUnauthorized(
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 result = await auth(provider, {
serverUrl: ctx.serverUrl,
resourceMetadataUrl,
scope,
resourceMetadataUrl: challenge.resourceMetadataUrl ?? ctx.resourceMetadataUrl,
scope: computeScopeUnion(tokens?.scope, ctx.scope, challenge.scope),
fetchFn: ctx.fetchFn,
...extraAuthOptions
Comment thread
mattzcarey marked this conversation as resolved.
});
Comment thread
mattzcarey marked this conversation as resolved.
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/client/middleware.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 } : {}),
Comment thread
mattzcarey marked this conversation as resolved.
fetchFn: next
});
Comment thread
mattzcarey marked this conversation as resolved.

Expand Down
39 changes: 25 additions & 14 deletions packages/client/src/client/sse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import type { AuthProvider, OAuthClientProvider } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
Expand DownExpand Up@@ -164,8 +165,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);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All@@ -180,15 +181,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 DownExpand Up@@ -328,15 +337,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
39 changes: 27 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -212,11 +212,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, protected resource metadata scopes, provider
* default scope, and the challenged scope, then 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
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
Expand DownExpand Up@@ -397,10 +399,19 @@ 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: union the current token grant, the previously requested
// scope, PRM scopes_supported, provider default scope, and challenged
// scope so permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
const discoveryState = await this._oauthProvider.discoveryState?.();
const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' ');
const unionScope = computeScopeUnion(
tokens?.scope,
this._scope,
resourceScope,
this._oauthProvider.clientMetadata.scope,
challenge.scope
);
this._scope = unionScope;

// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
Expand DownExpand Up@@ -534,7 +545,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@@ -544,7 +555,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 DownExpand Up@@ -983,7 +996,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@@ -993,7 +1006,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
66 changes: 66 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
discoverOAuthServerInfo,
exchangeAuthorization,
extractWWWAuthenticateParams,
handleOAuthUnauthorized,
InsecureTokenEndpointError,
isHttpsUrl,
isStrictScopeSuperset,
Expand DownExpand Up@@ -215,6 +216,71 @@ describe('OAuth Authorization', () => {
});
});

describe('handleOAuthUnauthorized', () => {
it('uses stored token scope, accumulated context scope, and resource metadata when reauthorizing', async () => {
const resourceMetadataUrl = new URL('https://resource.example.com/custom-prm');
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', 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: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
);
}
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());
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
36 changes: 36 additions & 0 deletions packages/client/test/client/middleware.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,42 @@ describe('withOAuth', () => {
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
});

it('should union stored token scope with 401 challenge scope when re-authenticating', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
access_token: 'old-token',
token_type: 'Bearer',
scope: 'read write'
})
.mockResolvedValueOnce({
access_token: 'new-token',
token_type: 'Bearer',
scope: 'read write admin'
});

const unauthorizedResponse = new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer realm="oauth", scope="admin"' }
});
const successResponse = new Response('success', { status: 200 });

mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'admin' });
mockAuth.mockResolvedValue('AUTHORIZED');

const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);

await enhancedFetch('https://api.example.com/data');

expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
serverUrl: 'https://api.example.com',
resourceMetadataUrl: undefined,
scope: 'read write admin',
forceReauthorization: true,
fetchFn: mockFetch
});
});

it('should retry request after successful auth on 401 response (without baseUrl)', async () => {
mockProvider.tokens
.mockResolvedValueOnce({
Expand Down
Loading
Loading