Skip to content

Commit 7146eed

Browse files
arjunkmrmviniciuscsouzaTylerLeonhardtblustAIEugene
authored
fix: prevent infinite recursion when server throws 401 after successful authentication (#945)
Co-authored-by: Vinicius Costa <viniciuscsouza@yahoo.com.br> Co-authored-by: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Co-authored-by: Blust.AI <159488814+blustAI@users.noreply.github.com> Co-authored-by: Eugene <eugene@blust.ai>
1 parent 058b87c commit 7146eed

File tree

2 files changed

+75
-0
lines changed

2 files changed

+75
-0
lines changed

src/client/streamableHttp.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,4 +1001,69 @@ describe("StreamableHTTPClientTransport", () => {
10011001
expect(global.fetch).not.toHaveBeenCalled();
10021002
});
10031003
});
1004+
1005+
describe("prevent infinite recursion when server returns 401 after successful auth", () => {
1006+
it("should throw error when server returns 401 after successful auth", async () => {
1007+
const message: JSONRPCMessage = {
1008+
jsonrpc: "2.0",
1009+
method: "test",
1010+
params: {},
1011+
id: "test-id"
1012+
};
1013+
1014+
// Mock provider with refresh token to enable token refresh flow
1015+
mockAuthProvider.tokens.mockResolvedValue({
1016+
access_token: "test-token",
1017+
token_type: "Bearer",
1018+
refresh_token: "refresh-token",
1019+
});
1020+
1021+
const unauthedResponse = {
1022+
ok: false,
1023+
status: 401,
1024+
statusText: "Unauthorized",
1025+
headers: new Headers()
1026+
};
1027+
1028+
(global.fetch as jest.Mock)
1029+
// First request - 401, triggers auth flow
1030+
.mockResolvedValueOnce(unauthedResponse)
1031+
// Resource discovery, path aware
1032+
.mockResolvedValueOnce(unauthedResponse)
1033+
// Resource discovery, root
1034+
.mockResolvedValueOnce(unauthedResponse)
1035+
// OAuth metadata discovery
1036+
.mockResolvedValueOnce({
1037+
ok: true,
1038+
status: 200,
1039+
json: async () => ({
1040+
issuer: "http://localhost:1234",
1041+
authorization_endpoint: "http://localhost:1234/authorize",
1042+
token_endpoint: "http://localhost:1234/token",
1043+
response_types_supported: ["code"],
1044+
code_challenge_methods_supported: ["S256"],
1045+
}),
1046+
})
1047+
// Token refresh succeeds
1048+
.mockResolvedValueOnce({
1049+
ok: true,
1050+
status: 200,
1051+
json: async () => ({
1052+
access_token: "new-access-token",
1053+
token_type: "Bearer",
1054+
expires_in: 3600,
1055+
}),
1056+
})
1057+
// Retry the original request - still 401 (broken server)
1058+
.mockResolvedValueOnce(unauthedResponse);
1059+
1060+
await expect(transport.send(message)).rejects.toThrow("Server returned 401 after successful authentication");
1061+
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
1062+
access_token: "new-access-token",
1063+
token_type: "Bearer",
1064+
expires_in: 3600,
1065+
refresh_token: "refresh-token", // Refresh token is preserved
1066+
});
1067+
});
1068+
});
10041069
});

src/client/streamableHttp.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export class StreamableHTTPClientTransport implements Transport {
131131
private _sessionId?: string;
132132
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
133133
private _protocolVersion?: string;
134+
private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401
134135

135136
onclose?: () => void;
136137
onerror?: (error: Error) => void;
@@ -437,6 +438,10 @@ export class StreamableHTTPClientTransport implements Transport {
437438

438439
if (!response.ok) {
439440
if (response.status === 401 && this._authProvider) {
441+
// Prevent infinite recursion when server returns 401 after successful auth
442+
if (this._hasCompletedAuthFlow) {
443+
throw new StreamableHTTPError(401, "Server returned 401 after successful authentication");
444+
}
440445

441446
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
442447

@@ -445,6 +450,8 @@ export class StreamableHTTPClientTransport implements Transport {
445450
throw new UnauthorizedError();
446451
}
447452

453+
// Mark that we completed auth flow
454+
this._hasCompletedAuthFlow = true;
448455
// Purposely _not_ awaited, so we don't call onerror twice
449456
return this.send(message);
450457
}
@@ -455,6 +462,9 @@ export class StreamableHTTPClientTransport implements Transport {
455462
);
456463
}
457464

465+
// Reset auth loop flag on successful response
466+
this._hasCompletedAuthFlow = false;
467+
458468
// If the response is 202 Accepted, there's no body to process
459469
if (response.status === 202) {
460470
// if the accepted notification is initialized, we start the SSE stream

0 commit comments

Comments
 (0)