Skip to content

Commit 359728a

Browse files
Treat redirected cross-app token responses as terminal
1 parent 8ac6652 commit 359728a

4 files changed

Lines changed: 61 additions & 4 deletions

File tree

.changeset/token-registration-redirects-terminal.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@modelcontextprotocol/client': patch
33
---
44

5-
Token and client registration requests no longer follow HTTP redirects. Token responses are terminal (RFC 6749 §5), so a 3xx answer from the token or registration endpoint now rejects with an error instead of being re-sent to the redirect target.
5+
Token and client registration requests no longer follow HTTP redirects — including the cross-app access token exchanges. Token responses are terminal (RFC 6749 §5), so a 3xx answer from a token or registration endpoint now rejects with an error instead of being re-sent to the redirect target.

docs/migration/upgrade-to-v2.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1172,7 +1172,8 @@ OAuth `onUnauthorized` behavior, for composing your own adapter).
11721172
sets the new `oauthRequestInit` transport option.
11731173
- **Token and registration endpoint redirects are not followed.** The token-exchange
11741174
and client-registration POSTs (`exchangeAuthorization()` / `refreshAuthorization()` /
1175-
`fetchToken()` / `registerClient()`, transitively `auth()`) are issued with
1175+
`fetchToken()` / `registerClient()`, transitively `auth()`, and the cross-app access
1176+
token exchanges `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()`) are issued with
11761177
`redirect: 'manual'`; a 3xx answer rejects with an error instead of re-sending the
11771178
request to the redirect target. Token responses are terminal (RFC 6749 §5) — an
11781179
authorization server that redirects these requests must be addressed at its final

packages/client/src/client/crossAppAccess.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,16 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
152152
headers: {
153153
'Content-Type': 'application/x-www-form-urlencoded'
154154
},
155-
body: params.toString()
155+
body: params.toString(),
156+
redirect: 'manual'
156157
});
157158

159+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
160+
throw new Error(
161+
`Token endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); token responses are terminal`
162+
);
163+
}
164+
158165
if (!response.ok) {
159166
const errorBody = await response.json().catch(() => ({}));
160167

@@ -279,9 +286,16 @@ export async function exchangeJwtAuthGrant(options: {
279286
const response = await fetchFn(tokenUrl, {
280287
method: 'POST',
281288
headers,
282-
body: params.toString()
289+
body: params.toString(),
290+
redirect: 'manual'
283291
});
284292

293+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
294+
throw new Error(
295+
`Token endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); token responses are terminal`
296+
);
297+
}
298+
285299
if (!response.ok) {
286300
const errorBody = await response.json().catch(() => ({}));
287301

packages/client/test/client/crossAppAccess.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,48 @@ describe('crossAppAccess', () => {
2222
expect(mockFetch).not.toHaveBeenCalled();
2323
});
2424

25+
it('does not follow a token endpoint redirect and rejects', async () => {
26+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
27+
ok: false,
28+
status: 307,
29+
headers: new Headers({ location: 'https://elsewhere.example.com/token' }),
30+
json: async () => ({})
31+
} as Response);
32+
await expect(
33+
requestJwtAuthorizationGrant({
34+
tokenEndpoint: 'https://idp.example.com/token',
35+
audience: 'https://auth.chat.example/',
36+
resource: 'https://mcp.chat.example/',
37+
idToken: 'id-token',
38+
clientId: 'client',
39+
clientSecret: 'secret',
40+
fetchFn: mockFetch
41+
})
42+
).rejects.toThrow(/redirect/);
43+
expect(mockFetch).toHaveBeenCalledTimes(1);
44+
expect(mockFetch.mock.calls[0]?.[1]?.redirect).toBe('manual');
45+
});
46+
47+
it('rejects a token response the runtime filtered as an opaque redirect', async () => {
48+
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
49+
ok: false,
50+
status: 0,
51+
type: 'opaqueredirect',
52+
headers: new Headers(),
53+
json: async () => ({})
54+
} as Response);
55+
await expect(
56+
exchangeJwtAuthGrant({
57+
tokenEndpoint: 'https://auth.chat.example/token',
58+
jwtAuthGrant: 'jag',
59+
clientId: 'client',
60+
clientSecret: 'secret',
61+
fetchFn: mockFetch
62+
})
63+
).rejects.toThrow(/redirect/);
64+
expect(mockFetch).toHaveBeenCalledTimes(1);
65+
});
66+
2567
it('permits a loopback http token endpoint (SEP-2207 exemption)', async () => {
2668
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
2769
ok: true,

0 commit comments

Comments
 (0)