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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/step-up-new-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

The step-up of a `403 insufficient_scope` now starts a new authorization when the token lacks a challenged scope, instead of refreshing the token. A refresh cannot add scopes (RFC 6749 §6), so the retry got the same 403 and failed with "Server returned 403 after trying
upscoping". The new authorization asks for the challenged scopes and for the scopes requested and granted before. `auth()` accepts a new `forceReauthorization` option. Fixes #2255.
39 changes: 37 additions & 2 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,12 @@ export async function auth(
scope?: string;
resourceMetadataUrl?: URL;
fetchFn?: FetchLike;
/**
* Starts a new authorization even when a refresh token is available. A refresh
* cannot add scopes (RFC 6749 §6), so the step-up of a `403 insufficient_scope`
* sets this when the requested scope is not covered by the granted one.
*/
forceReauthorization?: boolean;
}
): Promise<AuthResult> {
try {
Expand All @@ -485,13 +491,15 @@ async function authInternal(
authorizationCode,
scope,
resourceMetadataUrl,
fetchFn
fetchFn,
forceReauthorization
}: {
serverUrl: string | URL;
authorizationCode?: string;
scope?: string;
resourceMetadataUrl?: URL;
fetchFn?: FetchLike;
forceReauthorization?: boolean;
}
): Promise<AuthResult> {
// Check if the provider has cached discovery state to skip discovery
Expand Down Expand Up @@ -665,7 +673,7 @@ async function authInternal(
}

// Handle token refresh or new authorization
if (tokens?.refresh_token) {
if (tokens?.refresh_token && !forceReauthorization) {
try {
// Attempt to refresh the token
const newTokens = await refreshAuthorization(authorizationServerUrl, {
Expand Down Expand Up @@ -758,6 +766,33 @@ export async function selectResourceURL(
return new URL(resourceMetadata.resource);
}

/**
* Returns the union of space-delimited scope strings (RFC 6749 §3.3), each scope once, in first-seen order.
*
* The step-up of a `403 insufficient_scope` requests this union of the granted and the challenged scopes,
* so that the new token keeps the permissions granted before.
*/
export function computeScopeUnion(...scopes: ReadonlyArray<string | undefined>): string | undefined {
const seen = new Set<string>();
for (const scope of scopes) {
for (const token of scope?.split(/\s+/) ?? []) {
if (token) seen.add(token);
}
}
return seen.size > 0 ? [...seen].join(' ') : undefined;
}

/**
* Whether `union` contains a scope that `current` doesn't, both being space-delimited scope strings.
*
* An absent `current` is the empty set: an authorization server may omit the `scope` of a token
* when it equals the requested one (RFC 6749 §5.1), and refreshing would then risk dropping the new scope.
*/
export function isStrictScopeSuperset(union: string | undefined, current: string | undefined): boolean {
const currentSet = new Set((current ?? '').split(/\s+/).filter(Boolean));
return (union ?? '').split(/\s+/).some(token => token !== '' && !currentSet.has(token));
}

/**
* Extract resource_metadata, scope, and error from WWW-Authenticate header.
*/
Expand Down
18 changes: 14 additions & 4 deletions src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ import {
unfollowedRedirect
} from '../shared/transport.js';
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
import {
auth,
AuthResult,
computeScopeUnion,
extractWWWAuthenticateParams,
isStrictScopeSuperset,
OAuthClientProvider,
UnauthorizedError
} from './auth.js';
import { EventSourceParserStream } from 'eventsource-parser/stream';

// Default reconnection options for StreamableHTTP connections
Expand Down Expand Up @@ -562,9 +570,9 @@ export class StreamableHTTPClientTransport implements Transport {
throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping');
}

if (scope) {
this._scope = scope;
}
// Keep the scopes requested and granted before, so that the new token doesn't lose them.
const tokens = await this._authProvider.tokens();
this._scope = computeScopeUnion(this._scope, tokens?.scope, scope);

if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
Expand All @@ -576,6 +584,8 @@ export class StreamableHTTPClientTransport implements Transport {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
// a refresh cannot add the scopes that the token lacks (RFC 6749 §6)
forceReauthorization: isStrictScopeSuperset(this._scope, tokens?.scope),
fetchFn: this._fetch
});

Expand Down
96 changes: 95 additions & 1 deletion test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
type OAuthClientProvider,
type OAuthDiscoveryState,
selectClientAuthMethod,
isHttpsUrl
isHttpsUrl,
computeScopeUnion,
isStrictScopeSuperset
} from '../../src/client/auth.js';
import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js';
import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js';
Expand Down Expand Up @@ -118,6 +120,38 @@ describe('OAuth Authorization', () => {
});
});

describe('computeScopeUnion', () => {
it.each([
{ inputs: [undefined], expected: undefined },
{ inputs: [undefined, undefined], expected: undefined },
{ inputs: ['', ' '], expected: undefined },
{ inputs: ['read'], expected: 'read' },
{ inputs: ['read', undefined], expected: 'read' },
{ inputs: ['read write', 'write admin'], expected: 'read write admin' },
{ inputs: ['read', 'read'], expected: 'read' },
{ inputs: [' read write ', 'admin'], expected: 'read write admin' },
{ inputs: ['a b', 'c', 'b d'], expected: 'a b c d' }
])('union of $inputs is $expected', ({ inputs, expected }) => {
expect(computeScopeUnion(...inputs)).toBe(expected);
});
});

describe('isStrictScopeSuperset', () => {
it.each([
{ union: undefined, current: undefined, expected: false },
{ union: undefined, current: 'read', expected: false },
{ union: 'read', current: undefined, expected: true },
{ union: 'read', current: '', expected: true },
{ union: 'read', current: 'read', expected: false },
{ union: 'read write', current: 'read', expected: true },
{ union: 'read write', current: 'read write', expected: false },
{ union: 'read write', current: 'write read admin', expected: false },
{ union: 'read', current: 'read write', expected: false }
])('isStrictScopeSuperset($union, $current) is $expected', ({ union, current, expected }) => {
expect(isStrictScopeSuperset(union, current)).toBe(expected);
});
});

describe('discoverOAuthProtectedResourceMetadata', () => {
const validMetadata = {
resource: 'https://resource.example.com',
Expand Down Expand Up @@ -2773,6 +2807,66 @@ describe('OAuth Authorization', () => {
expect(body.get('code')).toBe('auth-code-123');
});

it('starts a new authorization instead of refreshing the token when forceReauthorization is set', async () => {
mockFetch.mockImplementation(url => {
const urlString = url.toString();

if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://api.example.com/mcp-server',
authorization_servers: ['https://auth.example.com']
})
});
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
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']
})
});
} else if (urlString.includes('/token')) {
// a refresh grant cannot add scopes (RFC 6749 section 6)
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ access_token: 'refreshed-access', token_type: 'Bearer', scope: 'read' })
});
}

return Promise.resolve({ ok: false, status: 404 });
});

(mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' });
(mockProvider.tokens as Mock).mockResolvedValue({
access_token: 'old-access',
token_type: 'Bearer',
refresh_token: 'refresh123',
scope: 'read',
issuer: 'https://auth.example.com'
});

const result = await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server',
scope: 'read write',
forceReauthorization: true
});

expect(result).toBe('REDIRECT');
expect(mockFetch.mock.calls.find(call => call[0].toString().includes('/token'))).toBeUndefined();

const authorizationUrl: URL = (mockProvider.redirectToAuthorization as Mock).mock.calls[0][0];
expect(authorizationUrl.searchParams.get('scope')).toBe('read write');
expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server');
});

it('excludes resource parameter in token refresh when Protected Resource Metadata is not present', async () => {
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
mockFetch.mockImplementation(url => {
Expand Down
9 changes: 8 additions & 1 deletion test/client/redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,14 @@ describe('redirects in the HTTP client transports', () => {
const saved: unknown[] = [];
const authProvider: OAuthClientProvider = {
...createProvider(saved, discoveredAt(endpoint)),
tokens: () => ({ access_token: 'access-0', token_type: 'Bearer', refresh_token: 'refresh-1', issuer: endpoint.url.origin })
// the token has the challenged scope, so the step-up refreshes it
tokens: () => ({
access_token: 'access-0',
token_type: 'Bearer',
refresh_token: 'refresh-1',
scope: 'tools',
issuer: endpoint.url.origin
})
};
transport = new StreamableHTTPClientTransport(new URL('/mcp', endpoint.url), { ...options, authProvider });

Expand Down
51 changes: 50 additions & 1 deletion test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,18 +661,67 @@ describe('StreamableHTTPClientTransport', () => {
// Verify fetch was called twice
expect(fetchMock).toHaveBeenCalledTimes(2);

// Verify auth was called with the new scope
// Verify auth was called with the new scope, and without a token to refresh
expect(authSpy).toHaveBeenCalledWith(
mockAuthProvider,
expect.objectContaining({
scope: 'new_scope',
forceReauthorization: true,
resourceMetadataUrl: new URL('http://example.com/resource')
})
);

authSpy.mockRestore();
});

it.each([
{ granted: 'openid', challenged: 'tools:write', scope: 'openid tools:write', forceReauthorization: true },
{ granted: 'openid tools:write', challenged: 'tools:write', scope: 'openid tools:write', forceReauthorization: false }
])(
'upscopes from $granted to $challenged with scope "$scope" and forceReauthorization $forceReauthorization',
async ({ granted, challenged, scope, forceReauthorization }) => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};

mockAuthProvider.tokens.mockResolvedValue({
access_token: 'access-token',
token_type: 'Bearer',
refresh_token: 'refresh-token',
scope: granted
});

const fetchMock = global.fetch as Mock;
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({ 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${challenged}"` }),
text: () => Promise.resolve('Insufficient scope')
})
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});

const authModule = await import('../../src/client/auth.js');
const authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');

await transport.send(message);

// a refresh cannot add scopes (RFC 6749 section 6), and the scopes granted before are kept
expect(authSpy).toHaveBeenCalledWith(mockAuthProvider, expect.objectContaining({ scope, forceReauthorization }));

authSpy.mockRestore();
}
);

it('prevents infinite upscoping on repeated 403', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
Expand Down
14 changes: 14 additions & 0 deletions test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2066,6 +2066,20 @@ export const REQUIREMENTS: Record<string, Requirement> = {
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:stepup:scope-union': {
source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow',
behavior:
'On 403 insufficient_scope the transport re-authorizes with the union of its previously-requested scope and the challenged scope (computeScopeUnion); the union is a plain string-set dedup with no hierarchical collapse.',
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:stepup:refresh-bypass-on-superset': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow',
behavior:
"On 403 insufficient_scope step-up: when the union scope is a strict superset of the current token's granted scope, auth() bypasses the refresh-token branch (forceReauthorization) and forces a fresh authorization request so the widened scope reaches the AS; when the token already covers the union, refresh is used.",
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:as-metadata-discovery:priority-order': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery',
behavior:
Expand Down
Loading
Loading