Skip to content

Commit 40c1d84

Browse files
fix(client/streamableHttp): retry sendMessage on 401
After receiving a 401, attempt to `auth`. Whether the authorization works immediately or causes a redirect, retry sending the message.
1 parent 7e18c70 commit 40c1d84

File tree

2 files changed

+100
-14
lines changed

2 files changed

+100
-14
lines changed

src/client/streamableHttp.test.ts

+98-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js";
22
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
3-
import { JSONRPCMessage } from "../types.js";
3+
import { JSONRPCMessage, LATEST_PROTOCOL_VERSION } from "../types.js";
4+
import { OAuthTokens } from "src/shared/auth.js";
45

56

67
describe("StreamableHTTPClientTransport", () => {
@@ -517,19 +518,106 @@ describe("StreamableHTTPClientTransport", () => {
517518
id: "test-id"
518519
};
519520

521+
const clientInfo = {
522+
"issuer": "http://localhost:1234",
523+
"authorization_endpoint": "http://localhost:1234/authorize",
524+
"token_endpoint": "http://localhost:1234/token",
525+
"revocation_endpoint": "http://localhost:1234/revoke",
526+
"scopes_supported": [
527+
'wow',
528+
],
529+
"grant_types_supported": [
530+
"authorization_code",
531+
"refresh_token"
532+
],
533+
"token_endpoint_auth_methods_supported": [
534+
"client_secret_basic",
535+
"client_secret_post"
536+
],
537+
"code_challenge_methods_supported": [
538+
"S256"
539+
],
540+
"registration_endpoint": "http://localhost:1234/register",
541+
"response_types_supported": [
542+
"code"
543+
],
544+
"response_modes_supported": [
545+
"query",
546+
"fragment"
547+
]
548+
};
549+
520550
(global.fetch as jest.Mock)
521-
.mockResolvedValueOnce({
522-
ok: false,
551+
.mockResolvedValueOnce(new Response("{}", {
523552
status: 401,
524-
statusText: "Unauthorized",
525553
headers: new Headers()
554+
}))
555+
.mockImplementationOnce(async (url: URL | string, init?: RequestInit) => {
556+
return new Response(JSON.stringify(clientInfo), {
557+
status: 200,
558+
headers: new Headers({ 'content-type': 'application/json' })
559+
})
526560
})
527-
.mockResolvedValue({
528-
ok: false,
529-
status: 404
530-
});
561+
.mockImplementationOnce(async (url: URL | string, init?: RequestInit) => {
562+
return new Response(JSON.stringify(clientInfo), {
563+
status: 200,
564+
headers: new Headers({ 'content-type': 'application/json' })
565+
})
566+
})
567+
.mockImplementationOnce(async (url: URL | string, init?: RequestInit) => {
568+
expect(init).toBeDefined()
569+
expect(init!.body).toBeDefined()
570+
expect(new URLSearchParams(init!.body! as string).get('code')).toBe('any code')
571+
return new Response(JSON.stringify({
572+
"access_token": "anything",
573+
"token_type": "Bearer",
574+
"expires_at": new Date(Date.now() + 5000),
575+
"scope": "anything",
576+
"refresh_token": "something else"
577+
}), {
578+
status: 200,
579+
headers: new Headers({ 'content-type': 'application/json' })
580+
})
581+
})
582+
.mockImplementationOnce(async (url: URL | string, init?: RequestInit) => {
583+
expect(init).toBeDefined()
584+
expect(init!.body).toBeDefined()
585+
expect(init!.headers).toBeDefined()
586+
expect(new Headers(init!.headers).get('authorization')).toBe(`Bearer anything`)
587+
const body = JSON.parse(init!.body! as string)
588+
return new Response(JSON.stringify({
589+
jsonrpc: '2.0',
590+
id: body.id,
591+
result: {
592+
protocolVersion: LATEST_PROTOCOL_VERSION,
593+
capabilities: {},
594+
serverInfo: {
595+
name: "test",
596+
version: "1.0",
597+
},
598+
},
599+
}), {
600+
status: 200,
601+
headers: new Headers({ 'content-type': 'application/json' })
602+
})
603+
})
604+
605+
606+
let tokens: OAuthTokens
607+
mockAuthProvider.tokens = jest.fn(() => {
608+
return tokens!
609+
})
610+
mockAuthProvider.saveTokens = jest.fn((t: OAuthTokens) => {
611+
tokens = t
612+
})
613+
614+
mockAuthProvider.redirectToAuthorization = jest.fn(async (redirectUrl: URL) => {
615+
await transport.finishAuth('any code')
616+
})
531617

532-
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
533-
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
618+
await transport.send(message)
619+
expect(mockAuthProvider.redirectToAuthorization.mock.calls.length).toBe(1)
620+
expect(mockAuthProvider.saveTokens.mock.calls.length).toBe(1)
621+
expect(mockAuthProvider.tokens.mock.calls.length).toBe(3)
534622
});
535623
});

src/client/streamableHttp.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,8 @@ export class StreamableHTTPClientTransport implements Transport {
401401

402402
if (!response.ok) {
403403
if (response.status === 401 && this._authProvider) {
404-
const result = await auth(this._authProvider, { serverUrl: this._url });
405-
if (result !== "AUTHORIZED") {
406-
throw new UnauthorizedError();
407-
}
404+
// Whether this is REDIRECT or AUTHORIZED, retry sending the message.
405+
await auth(this._authProvider, { serverUrl: this._url });
408406

409407
// Purposely _not_ awaited, so we don't call onerror twice
410408
return this.send(message);

0 commit comments

Comments
 (0)