Skip to content

Commit b296034

Browse files
fix: defer response body consumption; require onUnauthorized on OAuthClientProvider
Addresses two review comments from the second claude[bot] pass: 1. response.text() was called before passing response to onUnauthorized(), so custom implementations calling ctx.response.text()/.json() got empty results. Moved text() consumption after the onUnauthorized branch in all three 401-handling sites — it's only needed for the fallthrough error message. 2. onUnauthorized() was optional on OAuthClientProvider (inherited from AuthProvider), but OAuth providers that omit it lose ALL 401 recovery — no token refresh, no redirect. The migration docs said 'optional but recommended' which understates the footgun. Now required on OAuthClientProvider (still optional on base AuthProvider). TypeScript enforces at compile time; the delegation to handleOAuthUnauthorized is a one-liner.
1 parent e3e112b commit b296034

File tree

8 files changed

+34
-12
lines changed

8 files changed

+34
-12
lines changed

docs/migration-SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ Transport `authProvider` options now accept the minimal `AuthProvider` interface
217217
| N/A | `isOAuthClientProvider(provider)` — type guard |
218218
| N/A | `UnauthorizedContext``{ response, serverUrl, fetchFn }` |
219219

220-
**For custom `OAuthClientProvider` implementations**, add:
220+
**For custom `OAuthClientProvider` implementations**, add both methods (both required — TypeScript enforces this):
221221

222222
```typescript
223223
async token(): Promise<string | undefined> {

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ class MyProvider implements OAuthClientProvider {
667667
return (await this.tokens())?.access_token;
668668
}
669669

670-
// Optional but recommended: runs the OAuth flow on 401
670+
// Required: runs the OAuth flow on 401 — without this, 401 throws with no recovery
671671
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
672672
await handleOAuthUnauthorized(this, ctx);
673673
}

packages/client/src/client/auth.examples.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
import type { AuthorizationServerMetadata } from '@modelcontextprotocol/core';
1111

12-
import type { OAuthClientProvider } from './auth.js';
13-
import { fetchToken } from './auth.js';
12+
import type { OAuthClientProvider, UnauthorizedContext } from './auth.js';
13+
import { fetchToken, handleOAuthUnauthorized } from './auth.js';
1414

1515
/**
1616
* Base class providing no-op implementations of required OAuthClientProvider methods.
@@ -32,6 +32,9 @@ abstract class MyProviderBase implements OAuthClientProvider {
3232
async token(): Promise<string | undefined> {
3333
return undefined;
3434
}
35+
async onUnauthorized(ctx: UnauthorizedContext): Promise<void> {
36+
await handleOAuthUnauthorized(this, ctx);
37+
}
3538
saveTokens() {
3639
return Promise.resolve();
3740
}

packages/client/src/client/auth.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,20 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx
117117
* code verifiers should not cross different sessions.
118118
*
119119
* Extends {@linkcode AuthProvider} — implementations must provide `token()`
120-
* (typically `return (await this.tokens())?.access_token`) and may provide
121-
* `onUnauthorized()` (typically `return handleOAuthUnauthorized(this, ctx)`).
120+
* (typically `return (await this.tokens())?.access_token`) and `onUnauthorized()`
121+
* (typically `return handleOAuthUnauthorized(this, ctx)`). Without `onUnauthorized()`,
122+
* 401 responses throw immediately with no token refresh or reauth.
122123
*/
123124
export interface OAuthClientProvider extends AuthProvider {
125+
/**
126+
* Runs the OAuth re-authentication flow on 401. Required on `OAuthClientProvider`
127+
* (optional on the base `AuthProvider`) because OAuth providers that omit this lose
128+
* all 401 recovery — no token refresh, no redirect to authorization.
129+
*
130+
* Most implementations should delegate: `return handleOAuthUnauthorized(this, ctx)`.
131+
*/
132+
onUnauthorized(ctx: UnauthorizedContext): Promise<void>;
133+
124134
/**
125135
* The URL to redirect the user agent to after authorization.
126136
* Return `undefined` for non-interactive flows that don't require user interaction

packages/client/src/client/sse.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,6 @@ export class SSEClientTransport implements Transport {
255255

256256
const response = await (this._fetch ?? fetch)(this._endpoint, init);
257257
if (!response.ok) {
258-
const text = await response.text?.().catch(() => null);
259-
260258
if (response.status === 401) {
261259
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
262260
this._resourceMetadataUrl = resourceMetadataUrl;
@@ -273,10 +271,12 @@ export class SSEClientTransport implements Transport {
273271
return this.send(message);
274272
}
275273
if (this._authProvider) {
274+
await response.text?.().catch(() => {});
276275
throw new UnauthorizedError();
277276
}
278277
}
279278

279+
const text = await response.text?.().catch(() => null);
280280
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`);
281281
}
282282

packages/client/src/client/streamableHttp.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,6 @@ export class StreamableHTTPClientTransport implements Transport {
204204
});
205205

206206
if (!response.ok) {
207-
await response.text?.().catch(() => {});
208-
209207
if (response.status === 401) {
210208
if (this._authProvider?.onUnauthorized && !this._authRetryInFlight) {
211209
this._authRetryInFlight = true;
@@ -221,10 +219,13 @@ export class StreamableHTTPClientTransport implements Transport {
221219
}
222220
}
223221
if (this._authProvider) {
222+
await response.text?.().catch(() => {});
224223
throw new UnauthorizedError();
225224
}
226225
}
227226

227+
await response.text?.().catch(() => {});
228+
228229
// 405 indicates that the server does not offer an SSE stream at GET endpoint
229230
// This is an expected case that should not trigger an error
230231
if (response.status === 405) {
@@ -481,8 +482,6 @@ export class StreamableHTTPClientTransport implements Transport {
481482
}
482483

483484
if (!response.ok) {
484-
const text = await response.text?.().catch(() => null);
485-
486485
if (response.status === 401) {
487486
// Store WWW-Authenticate params for interactive finishAuth() path
488487
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
@@ -500,10 +499,13 @@ export class StreamableHTTPClientTransport implements Transport {
500499
return this.send(message);
501500
}
502501
if (this._authProvider) {
502+
await response.text?.().catch(() => {});
503503
throw new UnauthorizedError();
504504
}
505505
}
506506

507+
const text = await response.text?.().catch(() => null);
508+
507509
if (response.status === 403 && isOAuthClientProvider(this._authProvider)) {
508510
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
509511

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,7 @@ describe('OAuth Authorization', () => {
10391039
}),
10401040
tokens: vi.fn().mockResolvedValue(undefined),
10411041
token: vi.fn(async () => undefined),
1042+
onUnauthorized: vi.fn(async () => {}),
10421043
saveTokens: vi.fn(),
10431044
redirectToAuthorization: vi.fn(),
10441045
saveCodeVerifier: vi.fn(),
@@ -1985,6 +1986,7 @@ describe('OAuth Authorization', () => {
19851986
clientInformation: vi.fn(),
19861987
tokens: vi.fn(),
19871988
token: vi.fn(async () => undefined),
1989+
onUnauthorized: vi.fn(async () => {}),
19881990
saveTokens: vi.fn(),
19891991
redirectToAuthorization: vi.fn(),
19901992
saveCodeVerifier: vi.fn(),
@@ -2059,6 +2061,7 @@ describe('OAuth Authorization', () => {
20592061
}),
20602062
tokens: vi.fn().mockResolvedValue(undefined),
20612063
token: vi.fn(async () => undefined),
2064+
onUnauthorized: vi.fn(async () => {}),
20622065
saveTokens: vi.fn().mockResolvedValue(undefined),
20632066
redirectToAuthorization: vi.fn(),
20642067
saveCodeVerifier: vi.fn(),
@@ -2975,6 +2978,7 @@ describe('OAuth Authorization', () => {
29752978
}),
29762979
tokens: vi.fn().mockResolvedValue(undefined),
29772980
token: vi.fn(async () => undefined),
2981+
onUnauthorized: vi.fn(async () => {}),
29782982
saveTokens: vi.fn(),
29792983
redirectToAuthorization: vi.fn(),
29802984
saveCodeVerifier: vi.fn(),
@@ -3429,6 +3433,7 @@ describe('OAuth Authorization', () => {
34293433
saveClientInformation: vi.fn().mockResolvedValue(undefined),
34303434
tokens: vi.fn().mockResolvedValue(undefined),
34313435
token: vi.fn(async () => undefined),
3436+
onUnauthorized: vi.fn(async () => {}),
34323437
saveTokens: vi.fn().mockResolvedValue(undefined),
34333438
redirectToAuthorization: vi.fn().mockResolvedValue(undefined),
34343439
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),

packages/client/test/client/middleware.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe('withOAuth', () => {
3434
},
3535
tokens: vi.fn(),
3636
token: vi.fn(async () => undefined),
37+
onUnauthorized: vi.fn(async () => {}),
3738
saveTokens: vi.fn(),
3839
clientInformation: vi.fn(),
3940
redirectToAuthorization: vi.fn(),
@@ -761,6 +762,7 @@ describe('Integration Tests', () => {
761762
},
762763
tokens: vi.fn(),
763764
token: vi.fn(async () => undefined),
765+
onUnauthorized: vi.fn(async () => {}),
764766
saveTokens: vi.fn(),
765767
clientInformation: vi.fn(),
766768
redirectToAuthorization: vi.fn(),

0 commit comments

Comments
 (0)