Skip to content

Commit 45c5a0c

Browse files
author
Kripa Dev
committed
client: persist interactive OAuth metadata across redirects
1 parent e86b183 commit 45c5a0c

4 files changed

Lines changed: 277 additions & 1 deletion

File tree

packages/client/src/client/sse.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export class SSEClientTransport implements Transport {
7878
private _fetchWithInit: FetchLike;
7979
private _protocolVersion?: string;
8080

81+
private static readonly _INTERACTIVE_AUTH_STATE_PREFIX = 'mcp:oauth:interactive:sse:';
82+
8183
onclose?: () => void;
8284
onerror?: (error: Error) => void;
8385
onmessage?: (message: JSONRPCMessage) => void;
@@ -96,6 +98,64 @@ export class SSEClientTransport implements Transport {
9698
}
9799
this._fetch = opts?.fetch;
98100
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
101+
102+
this._loadInteractiveAuthState();
103+
}
104+
105+
private _interactiveAuthStateKey(): string {
106+
return `${SSEClientTransport._INTERACTIVE_AUTH_STATE_PREFIX}${this._url.toString()}`;
107+
}
108+
109+
private _saveInteractiveAuthState(): void {
110+
if (typeof sessionStorage === 'undefined') {
111+
return;
112+
}
113+
114+
const state = {
115+
resourceMetadataUrl: this._resourceMetadataUrl?.toString(),
116+
scope: this._scope
117+
};
118+
119+
try {
120+
sessionStorage.setItem(this._interactiveAuthStateKey(), JSON.stringify(state));
121+
} catch {
122+
// Ignore storage failures (e.g. quota exceeded)
123+
}
124+
}
125+
126+
private _loadInteractiveAuthState(): void {
127+
if (typeof sessionStorage === 'undefined') {
128+
return;
129+
}
130+
131+
try {
132+
const raw = sessionStorage.getItem(this._interactiveAuthStateKey());
133+
if (!raw) {
134+
return;
135+
}
136+
137+
const parsed = JSON.parse(raw) as { resourceMetadataUrl?: string; scope?: string };
138+
if (parsed.resourceMetadataUrl) {
139+
this._resourceMetadataUrl = new URL(parsed.resourceMetadataUrl);
140+
}
141+
if (parsed.scope) {
142+
this._scope = parsed.scope;
143+
}
144+
} catch {
145+
// Ignore malformed persisted state
146+
}
147+
}
148+
149+
private _clearInteractiveAuthState(): void {
150+
if (typeof sessionStorage === 'undefined') {
151+
return;
152+
}
153+
154+
try {
155+
sessionStorage.removeItem(this._interactiveAuthStateKey());
156+
} catch {
157+
// Ignore storage failures
158+
}
99159
}
100160

101161
private _last401Response?: Response;
@@ -137,6 +197,7 @@ export class SSEClientTransport implements Transport {
137197
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
138198
this._resourceMetadataUrl = resourceMetadataUrl;
139199
this._scope = scope;
200+
this._saveInteractiveAuthState();
140201
}
141202
}
142203

@@ -237,6 +298,8 @@ export class SSEClientTransport implements Transport {
237298
if (result !== 'AUTHORIZED') {
238299
throw new UnauthorizedError('Failed to authorize');
239300
}
301+
302+
this._clearInteractiveAuthState();
240303
}
241304

242305
async close(): Promise<void> {
@@ -272,6 +335,7 @@ export class SSEClientTransport implements Transport {
272335
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
273336
this._resourceMetadataUrl = resourceMetadataUrl;
274337
this._scope = scope;
338+
this._saveInteractiveAuthState();
275339
}
276340

277341
if (this._authProvider.onUnauthorized && !isAuthRetry) {

packages/client/src/client/streamableHttp.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export class StreamableHTTPClientTransport implements Transport {
152152
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
153153
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
154154

155+
private static readonly _INTERACTIVE_AUTH_STATE_PREFIX = 'mcp:oauth:interactive:streamable-http:';
156+
155157
onclose?: () => void;
156158
onerror?: (error: Error) => void;
157159
onmessage?: (message: JSONRPCMessage) => void;
@@ -172,6 +174,64 @@ export class StreamableHTTPClientTransport implements Transport {
172174
this._sessionId = opts?.sessionId;
173175
this._protocolVersion = opts?.protocolVersion;
174176
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
177+
178+
this._loadInteractiveAuthState();
179+
}
180+
181+
private _interactiveAuthStateKey(): string {
182+
return `${StreamableHTTPClientTransport._INTERACTIVE_AUTH_STATE_PREFIX}${this._url.toString()}`;
183+
}
184+
185+
private _saveInteractiveAuthState(): void {
186+
if (typeof sessionStorage === 'undefined') {
187+
return;
188+
}
189+
190+
const state = {
191+
resourceMetadataUrl: this._resourceMetadataUrl?.toString(),
192+
scope: this._scope
193+
};
194+
195+
try {
196+
sessionStorage.setItem(this._interactiveAuthStateKey(), JSON.stringify(state));
197+
} catch {
198+
// Ignore storage failures (e.g. quota exceeded)
199+
}
200+
}
201+
202+
private _loadInteractiveAuthState(): void {
203+
if (typeof sessionStorage === 'undefined') {
204+
return;
205+
}
206+
207+
try {
208+
const raw = sessionStorage.getItem(this._interactiveAuthStateKey());
209+
if (!raw) {
210+
return;
211+
}
212+
213+
const parsed = JSON.parse(raw) as { resourceMetadataUrl?: string; scope?: string };
214+
if (parsed.resourceMetadataUrl) {
215+
this._resourceMetadataUrl = new URL(parsed.resourceMetadataUrl);
216+
}
217+
if (parsed.scope) {
218+
this._scope = parsed.scope;
219+
}
220+
} catch {
221+
// Ignore malformed persisted state
222+
}
223+
}
224+
225+
private _clearInteractiveAuthState(): void {
226+
if (typeof sessionStorage === 'undefined') {
227+
return;
228+
}
229+
230+
try {
231+
sessionStorage.removeItem(this._interactiveAuthStateKey());
232+
} catch {
233+
// Ignore storage failures
234+
}
175235
}
176236

177237
private async _commonHeaders(): Promise<Headers> {
@@ -223,6 +283,7 @@ export class StreamableHTTPClientTransport implements Transport {
223283
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
224284
this._resourceMetadataUrl = resourceMetadataUrl;
225285
this._scope = scope;
286+
this._saveInteractiveAuthState();
226287
}
227288

228289
if (this._authProvider.onUnauthorized && !isAuthRetry) {
@@ -455,6 +516,8 @@ export class StreamableHTTPClientTransport implements Transport {
455516
if (result !== 'AUTHORIZED') {
456517
throw new UnauthorizedError('Failed to authorize');
457518
}
519+
520+
this._clearInteractiveAuthState();
458521
}
459522

460523
async close(): Promise<void> {
@@ -516,6 +579,7 @@ export class StreamableHTTPClientTransport implements Transport {
516579
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
517580
this._resourceMetadataUrl = resourceMetadataUrl;
518581
this._scope = scope;
582+
this._saveInteractiveAuthState();
519583
}
520584

521585
if (this._authProvider.onUnauthorized && !isAuthRetry) {

packages/client/test/client/sse.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ describe('SSEClientTransport', () => {
3636
let sendServerMessage: ((message: string) => void) | null = null;
3737

3838
beforeEach(async () => {
39+
const sessionStorageStore = new Map<string, string>();
40+
Object.defineProperty(globalThis, 'sessionStorage', {
41+
configurable: true,
42+
value: {
43+
getItem: (key: string) => sessionStorageStore.get(key) ?? null,
44+
setItem: (key: string, value: string) => {
45+
sessionStorageStore.set(key, value);
46+
},
47+
removeItem: (key: string) => {
48+
sessionStorageStore.delete(key);
49+
}
50+
}
51+
});
52+
3953
// Reset state
4054
lastServerRequest = null as unknown as IncomingMessage;
4155
sendServerMessage = null;
@@ -111,6 +125,7 @@ describe('SSEClientTransport', () => {
111125
await authServer.close();
112126

113127
vi.clearAllMocks();
128+
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
114129
});
115130

116131
describe('connection handling', () => {
@@ -1527,6 +1542,50 @@ describe('SSEClientTransport', () => {
15271542
// Global fetch should never have been called
15281543
expect(globalFetchSpy).not.toHaveBeenCalled();
15291544
});
1545+
1546+
it('persists interactive auth metadata across transport recreation before finishAuth', async () => {
1547+
const authProviderWithCode = createMockAuthProvider({
1548+
clientRegistered: true,
1549+
authorizationCode: 'test-auth-code'
1550+
});
1551+
1552+
const unauthorizedResponse = new Response(null, {
1553+
status: 401,
1554+
headers: {
1555+
'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource", scope="calendar.read"`
1556+
}
1557+
});
1558+
1559+
const firstAuthProvider = {
1560+
token: vi.fn(async () => undefined)
1561+
};
1562+
1563+
const firstTransport = new SSEClientTransport(resourceBaseUrl, {
1564+
authProvider: firstAuthProvider,
1565+
fetch: vi.fn().mockResolvedValue(unauthorizedResponse)
1566+
});
1567+
1568+
// Skip EventSource startup; directly exercise POST 401 path where metadata is captured.
1569+
(firstTransport as unknown as { _endpoint: URL })._endpoint = new URL(resourceBaseUrl.href);
1570+
await expect(firstTransport.send({ jsonrpc: '2.0', method: 'ping', params: {}, id: '1' })).rejects.toThrow(UnauthorizedError);
1571+
await firstTransport.close();
1572+
1573+
const secondTransport = new SSEClientTransport(resourceBaseUrl, {
1574+
authProvider: authProviderWithCode,
1575+
fetch: customFetch
1576+
});
1577+
1578+
await secondTransport.finishAuth('test-auth-code');
1579+
1580+
expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({
1581+
access_token: 'new-access-token',
1582+
token_type: 'Bearer',
1583+
expires_in: 3600,
1584+
refresh_token: 'new-refresh-token'
1585+
});
1586+
1587+
await secondTransport.close();
1588+
});
15301589
});
15311590

15321591
describe('minimal AuthProvider (non-OAuth)', () => {

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

Lines changed: 90 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 } 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';
@@ -12,6 +12,20 @@ describe('StreamableHTTPClientTransport', () => {
1212
let mockAuthProvider: Mocked<OAuthClientProvider>;
1313

1414
beforeEach(() => {
15+
const sessionStorageStore = new Map<string, string>();
16+
Object.defineProperty(globalThis, 'sessionStorage', {
17+
configurable: true,
18+
value: {
19+
getItem: (key: string) => sessionStorageStore.get(key) ?? null,
20+
setItem: (key: string, value: string) => {
21+
sessionStorageStore.set(key, value);
22+
},
23+
removeItem: (key: string) => {
24+
sessionStorageStore.delete(key);
25+
}
26+
}
27+
});
28+
1529
mockAuthProvider = {
1630
get redirectUrl() {
1731
return 'http://localhost/callback';
@@ -34,6 +48,7 @@ describe('StreamableHTTPClientTransport', () => {
3448
afterEach(async () => {
3549
await transport.close().catch(() => {});
3650
vi.clearAllMocks();
51+
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
3752
});
3853

3954
it('should send JSON-RPC messages via POST', async () => {
@@ -1441,6 +1456,80 @@ describe('StreamableHTTPClientTransport', () => {
14411456
// Global fetch should never have been called
14421457
expect(globalThis.fetch).not.toHaveBeenCalled();
14431458
});
1459+
1460+
it('persists interactive auth metadata across transport recreation before finishAuth', async () => {
1461+
const customFetch = vi
1462+
.fn()
1463+
// First transport send -> 401 with auth metadata
1464+
.mockResolvedValueOnce(
1465+
new Response(null, {
1466+
status: 401,
1467+
headers: {
1468+
'WWW-Authenticate':
1469+
'Bearer resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource", scope="calendar.read"'
1470+
}
1471+
})
1472+
)
1473+
// Second transport finishAuth -> resource metadata discovery
1474+
.mockResolvedValueOnce(
1475+
Response.json({
1476+
authorization_servers: ['http://localhost:1234'],
1477+
resource: 'http://localhost:1234/mcp'
1478+
})
1479+
)
1480+
// auth server metadata discovery
1481+
.mockResolvedValueOnce(
1482+
Response.json({
1483+
issuer: 'http://localhost:1234',
1484+
authorization_endpoint: 'http://localhost:1234/authorize',
1485+
token_endpoint: 'http://localhost:1234/token',
1486+
response_types_supported: ['code'],
1487+
code_challenge_methods_supported: ['S256']
1488+
})
1489+
)
1490+
// authorization code exchange
1491+
.mockResolvedValueOnce(
1492+
Response.json({
1493+
access_token: 'new-access-token',
1494+
refresh_token: 'new-refresh-token',
1495+
token_type: 'Bearer',
1496+
expires_in: 3600
1497+
})
1498+
);
1499+
1500+
const firstAuthProvider: AuthProvider = {
1501+
token: vi.fn(async () => undefined)
1502+
};
1503+
1504+
const firstTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1505+
authProvider: firstAuthProvider,
1506+
fetch: customFetch
1507+
});
1508+
1509+
await firstTransport.start();
1510+
1511+
await expect(firstTransport.send({ jsonrpc: '2.0', method: 'ping', params: {}, id: '1' } as JSONRPCMessage)).rejects.toThrow(
1512+
UnauthorizedError
1513+
);
1514+
1515+
await firstTransport.close();
1516+
1517+
const secondTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
1518+
authProvider: mockAuthProvider,
1519+
fetch: customFetch
1520+
});
1521+
1522+
await secondTransport.finishAuth('auth-code-after-redirect');
1523+
1524+
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
1525+
access_token: 'new-access-token',
1526+
token_type: 'Bearer',
1527+
expires_in: 3600,
1528+
refresh_token: 'new-refresh-token'
1529+
});
1530+
1531+
await secondTransport.close();
1532+
});
14441533
});
14451534

14461535
describe('SSE retry field handling', () => {

0 commit comments

Comments
 (0)