Skip to content

Commit 3b625e0

Browse files
committed
fix(client): preserve accumulated OAuth scope on 401 reauth
1 parent 731ffae commit 3b625e0

5 files changed

Lines changed: 110 additions & 6 deletions

File tree

packages/client/src/client/auth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface UnauthorizedContext {
4646
serverUrl: URL;
4747
/** Fetch function configured with the transport's `requestInit`, for making auth requests. */
4848
fetchFn: FetchLike;
49+
/** The merged scope accumulated across prior 401/403 challenges, when available. */
50+
accumulatedScope?: string;
4951
}
5052

5153
/**
@@ -105,7 +107,7 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx
105107
const result = await auth(provider, {
106108
serverUrl: ctx.serverUrl,
107109
resourceMetadataUrl,
108-
scope,
110+
scope: ctx.accumulatedScope ?? scope,
109111
fetchFn: ctx.fetchFn
110112
});
111113
if (result !== 'AUTHORIZED') {

packages/client/src/client/sse.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ export class SSEClientTransport implements Transport {
160160
const response = this._last401Response;
161161
this._last401Response = undefined;
162162
this._eventSource?.close();
163-
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
163+
this._authProvider
164+
.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit, accumulatedScope: this._scope })
165+
.then(
164166
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
165167
() => this._startOrAuth().then(resolve, reject),
166168
// onUnauthorized failed → not yet reported.
@@ -289,7 +291,8 @@ export class SSEClientTransport implements Transport {
289291
await this._authProvider.onUnauthorized({
290292
response,
291293
serverUrl: this._url,
292-
fetchFn: this._fetchWithInit
294+
fetchFn: this._fetchWithInit,
295+
accumulatedScope: this._scope
293296
});
294297
await response.text?.().catch(() => {});
295298
// Purposely _not_ awaited, so we don't call onerror twice

packages/client/src/client/streamableHttp.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ export class StreamableHTTPClientTransport implements Transport {
232232
await this._authProvider.onUnauthorized({
233233
response,
234234
serverUrl: this._url,
235-
fetchFn: this._fetchWithInit
235+
fetchFn: this._fetchWithInit,
236+
accumulatedScope: this._scope
236237
});
237238
await response.text?.().catch(() => {});
238239
// Purposely _not_ awaited, so we don't call onerror twice
@@ -527,7 +528,8 @@ export class StreamableHTTPClientTransport implements Transport {
527528
await this._authProvider.onUnauthorized({
528529
response,
529530
serverUrl: this._url,
530-
fetchFn: this._fetchWithInit
531+
fetchFn: this._fetchWithInit,
532+
accumulatedScope: this._scope
531533
});
532534
await response.text?.().catch(() => {});
533535
// Purposely _not_ awaited, so we don't call onerror twice

packages/client/test/client/auth.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
discoverOAuthServerInfo,
1414
exchangeAuthorization,
1515
extractWWWAuthenticateParams,
16+
handleOAuthUnauthorized,
1617
isHttpsUrl,
1718
refreshAuthorization,
1819
registerClient,
@@ -2059,6 +2060,66 @@ describe('OAuth Authorization', () => {
20592060
vi.clearAllMocks();
20602061
});
20612062

2063+
it('prefers accumulated scope when handling interactive 401 re-authorization', async () => {
2064+
mockFetch.mockImplementation(url => {
2065+
const urlString = url.toString();
2066+
2067+
if (urlString === 'https://api.example.com/.well-known/oauth-protected-resource') {
2068+
return Promise.resolve({
2069+
ok: true,
2070+
status: 200,
2071+
json: async () => ({
2072+
resource: 'https://api.example.com/mcp-server',
2073+
authorization_servers: ['https://auth.example.com']
2074+
})
2075+
});
2076+
}
2077+
2078+
if (urlString === 'https://auth.example.com/.well-known/oauth-authorization-server') {
2079+
return Promise.resolve({
2080+
ok: true,
2081+
status: 200,
2082+
json: async () => ({
2083+
issuer: 'https://auth.example.com',
2084+
authorization_endpoint: 'https://auth.example.com/authorize',
2085+
token_endpoint: 'https://auth.example.com/token',
2086+
response_types_supported: ['code'],
2087+
code_challenge_methods_supported: ['S256']
2088+
})
2089+
});
2090+
}
2091+
2092+
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
2093+
});
2094+
2095+
vi.mocked(mockProvider.clientInformation).mockResolvedValue({
2096+
client_id: 'test-client',
2097+
client_secret: 'test-secret'
2098+
});
2099+
vi.mocked(mockProvider.tokens).mockResolvedValue(undefined);
2100+
vi.mocked(mockProvider.saveCodeVerifier).mockResolvedValue(undefined);
2101+
vi.mocked(mockProvider.redirectToAuthorization).mockResolvedValue(undefined);
2102+
2103+
await expect(
2104+
handleOAuthUnauthorized(mockProvider, {
2105+
response: new Response(null, {
2106+
status: 401,
2107+
headers: {
2108+
'WWW-Authenticate':
2109+
'Bearer scope="read:op2", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
2110+
}
2111+
}),
2112+
serverUrl: new URL('https://api.example.com/mcp-server'),
2113+
fetchFn: mockFetch,
2114+
accumulatedScope: 'read:op1 read:op2'
2115+
})
2116+
).rejects.toThrow('Unauthorized');
2117+
2118+
const redirectCall = vi.mocked(mockProvider.redirectToAuthorization).mock.calls[0]?.[0];
2119+
expect(redirectCall).toBeInstanceOf(URL);
2120+
expect(redirectCall?.searchParams.get('scope')?.split(' ').toSorted()).toEqual(['read:op1', 'read:op2']);
2121+
});
2122+
20622123
it('performs client_credentials with private_key_jwt when provider has addClientAuthentication', async () => {
20632124
// Arrange: metadata discovery for PRM and AS
20642125
mockFetch.mockImplementation(url => {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'
22
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
33
import type { Mock, Mocked, MockInstance } from 'vitest';
44

5-
import type { OAuthClientProvider } from '../../src/client/auth.js';
5+
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth.js';
66
import { UnauthorizedError } from '../../src/client/auth.js';
77
import type { StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js';
88
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
@@ -690,6 +690,42 @@ describe('StreamableHTTPClientTransport', () => {
690690
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
691691
});
692692

693+
it('passes accumulated scope to onUnauthorized for 401 retries', async () => {
694+
const onUnauthorized = vi.fn().mockResolvedValue(undefined);
695+
const authProvider: AuthProvider = {
696+
token: vi.fn().mockResolvedValue(undefined),
697+
onUnauthorized
698+
};
699+
700+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
701+
authProvider
702+
});
703+
704+
Reflect.set(transport, '_scope', 'read:op1');
705+
706+
const fetchMock = vi.mocked(globalThis.fetch);
707+
fetchMock
708+
.mockResolvedValueOnce(
709+
new Response('Unauthorized', {
710+
status: 401,
711+
headers: {
712+
'WWW-Authenticate': 'Bearer scope="read:op2"'
713+
}
714+
})
715+
)
716+
.mockResolvedValueOnce(new Response(null, { status: 202 }));
717+
718+
await transport.send({
719+
jsonrpc: '2.0',
720+
method: 'test',
721+
params: {},
722+
id: 'test-id'
723+
});
724+
725+
const ctx = vi.mocked(onUnauthorized).mock.calls[0]?.[0];
726+
expect(ctx?.accumulatedScope?.split(' ').toSorted()).toEqual(['read:op1', 'read:op2']);
727+
});
728+
693729
it('attempts upscoping on 403 with WWW-Authenticate header', async () => {
694730
const message: JSONRPCMessage = {
695731
jsonrpc: '2.0',

0 commit comments

Comments
 (0)