Skip to content

Commit 5c8b73e

Browse files
Treat redirected token and registration responses as terminal
The token-exchange and client-registration POSTs followed HTTP redirects, re-sending the request body to whatever target the Location header named. Token responses are terminal (RFC 6749 §5): these requests are now issued with redirect: 'manual', and a 3xx answer (or a redirect the runtime filtered as opaque) rejects with an error instead of being re-sent.
1 parent 78c714c commit 5c8b73e

4 files changed

Lines changed: 94 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
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.

docs/migration/upgrade-to-v2.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,13 @@ OAuth `onUnauthorized` behavior, for composing your own adapter).
11201120
than the MCP server, so connection-level headers do not carry over. A deployment that
11211121
needs extra headers on those requests (e.g. gateway headers on well-known endpoints)
11221122
sets the new `oauthRequestInit` transport option.
1123+
- **Token and registration endpoint redirects are not followed.** The token-exchange
1124+
and client-registration POSTs (`exchangeAuthorization()` / `refreshAuthorization()` /
1125+
`fetchToken()` / `registerClient()`, transitively `auth()`) are issued with
1126+
`redirect: 'manual'`; a 3xx answer rejects with an error instead of re-sending the
1127+
request to the redirect target. Token responses are terminal (RFC 6749 §5) — an
1128+
authorization server that redirects these requests must be addressed at its final
1129+
endpoint URL (via its metadata document).
11231130
- **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The
11241131
`auth()` retry for these errors now issues two scoped calls —
11251132
`invalidateCredentials('client')` then `invalidateCredentials('tokens')` — instead of

packages/client/src/client/auth.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2090,9 +2090,17 @@ export async function executeTokenRequest(
20902090
const response = await (fetchFn ?? fetch)(tokenUrl, {
20912091
method: 'POST',
20922092
headers,
2093-
body: tokenRequestParams
2093+
body: tokenRequestParams,
2094+
// Token responses are terminal (RFC 6749 §5): a redirect is not followed.
2095+
redirect: 'manual'
20942096
});
20952097

2098+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
2099+
throw new Error(
2100+
`Token endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); token responses are terminal`
2101+
);
2102+
}
2103+
20962104
if (!response.ok) {
20972105
throw await parseErrorResponse(response);
20982106
}
@@ -2365,9 +2373,17 @@ export async function registerClient(
23652373
headers: {
23662374
'Content-Type': 'application/json'
23672375
},
2368-
body: JSON.stringify(submittedMetadata)
2376+
body: JSON.stringify(submittedMetadata),
2377+
// Registration responses are terminal (RFC 7591 §3.2): a redirect is not followed.
2378+
redirect: 'manual'
23692379
});
23702380

2381+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
2382+
throw new Error(
2383+
`Registration endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); registration responses are terminal`
2384+
);
2385+
}
2386+
23712387
if (!response.ok) {
23722388
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });
23732389
}

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1965,6 +1965,50 @@ describe('OAuth Authorization', () => {
19651965
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
19661966
});
19671967

1968+
it('does not follow a token endpoint redirect and rejects', async () => {
1969+
mockFetch.mockResolvedValueOnce({
1970+
ok: false,
1971+
status: 307,
1972+
headers: new Headers({ location: 'https://elsewhere.example.com/token' }),
1973+
text: async () => ''
1974+
});
1975+
1976+
await expect(
1977+
exchangeAuthorization('https://auth.example.com', {
1978+
clientInformation: validClientInfo,
1979+
authorizationCode: 'code123',
1980+
codeVerifier: 'verifier123',
1981+
redirectUri: 'http://localhost:3000/callback'
1982+
})
1983+
).rejects.toThrow(/redirect/);
1984+
1985+
// The redirect target is never requested — a single POST was issued,
1986+
// with redirects disabled.
1987+
expect(mockFetch).toHaveBeenCalledTimes(1);
1988+
expect(mockFetch.mock.calls[0]![1]).toMatchObject({ method: 'POST', redirect: 'manual' });
1989+
});
1990+
1991+
it('rejects a token response the runtime filtered as an opaque redirect', async () => {
1992+
mockFetch.mockResolvedValueOnce({
1993+
ok: false,
1994+
status: 0,
1995+
type: 'opaqueredirect',
1996+
headers: new Headers(),
1997+
text: async () => ''
1998+
});
1999+
2000+
await expect(
2001+
exchangeAuthorization('https://auth.example.com', {
2002+
clientInformation: validClientInfo,
2003+
authorizationCode: 'code123',
2004+
codeVerifier: 'verifier123',
2005+
redirectUri: 'http://localhost:3000/callback'
2006+
})
2007+
).rejects.toThrow(/redirect/);
2008+
2009+
expect(mockFetch).toHaveBeenCalledTimes(1);
2010+
});
2011+
19682012
it('allows for string "expires_in" values', async () => {
19692013
mockFetch.mockResolvedValueOnce({
19702014
ok: true,
@@ -2330,6 +2374,26 @@ describe('OAuth Authorization', () => {
23302374
});
23312375
});
23322376

2377+
it('does not follow a registration endpoint redirect and rejects', async () => {
2378+
mockFetch.mockResolvedValueOnce({
2379+
ok: false,
2380+
status: 307,
2381+
headers: new Headers({ location: 'https://elsewhere.example.com/register' }),
2382+
text: async () => ''
2383+
});
2384+
2385+
await expect(
2386+
registerClient('https://auth.example.com', {
2387+
clientMetadata: validClientMetadata
2388+
})
2389+
).rejects.toThrow(/redirect/);
2390+
2391+
// The redirect target is never requested — a single POST was issued,
2392+
// with redirects disabled.
2393+
expect(mockFetch).toHaveBeenCalledTimes(1);
2394+
expect(mockFetch.mock.calls[0]![1]).toMatchObject({ method: 'POST', redirect: 'manual' });
2395+
});
2396+
23332397
it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => {
23342398
const clientMetadataWithScope: OAuthClientMetadata = {
23352399
...validClientMetadata,

0 commit comments

Comments
 (0)