Skip to content

Commit 57c7c85

Browse files
committed
feat(client): add getAuthorizationCode() to OAuthClientProvider for headless OAuth flows
- Add optional `getAuthorizationCode()` method to `OAuthClientProvider` interface - Update `withOAuth` middleware to automatically complete the authorization code exchange when the provider implements `getAuthorizationCode()` after a REDIRECT - Handle 403 responses the same as 401 in `withOAuth` (upscoping) - Update conformance `ConformanceOAuthProvider` to implement `getAuthorizationCode()` - Update conformance `withOAuthRetry` to use the new method name and remove TODO Closes #1370
1 parent 108f2f3 commit 57c7c85

File tree

6 files changed

+183
-16
lines changed

6 files changed

+183
-16
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Add `getAuthorizationCode()` to `OAuthClientProvider` for headless OAuth flows
6+
7+
The `withOAuth` middleware now supports completing the authorization code exchange
8+
automatically when the provider implements the new optional `getAuthorizationCode()`
9+
method. This enables headless environments (CI, test harnesses, CLI tools) where the
10+
OAuth redirect can be intercepted programmatically.
11+
12+
Additionally, `withOAuth` now handles `403` responses the same as `401`, since a 403
13+
can indicate the server requires a broader scope (upscoping).

packages/client/src/client/auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ export interface OAuthClientProvider {
146146
*/
147147
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;
148148

149+
/**
150+
* If implemented, returns the authorization code obtained after the user completes
151+
* the OAuth authorization flow. This is called by {@linkcode withOAuth} immediately
152+
* after {@linkcode OAuthClientProvider.redirectToAuthorization | redirectToAuthorization()}
153+
* resolves, allowing the middleware to automatically complete the token exchange without
154+
* requiring manual intervention.
155+
*
156+
* **Ordering contract:** The authorization code *must* be available by the time this
157+
* method is called. Because `redirectToAuthorization()` is awaited first, the typical
158+
* pattern is to capture and store the code inside `redirectToAuthorization()` so that
159+
* it is ready to return here. Throwing from this method will propagate as an
160+
* {@linkcode UnauthorizedError}.
161+
*
162+
* Implement this when your provider handles the authorization callback inline —
163+
* for example, by starting a local HTTP server that catches the redirect, or by
164+
* fetching the authorization URL directly in a headless environment.
165+
*
166+
* If not implemented, {@linkcode withOAuth} will throw an {@linkcode UnauthorizedError}
167+
* when a redirect is required, leaving it to the caller to manage the auth code flow.
168+
*/
169+
getAuthorizationCode?(): string | Promise<string>;
170+
149171
/**
150172
* Prepares grant-specific parameters for a token request.
151173
*

packages/client/src/client/middleware.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,38 @@ export const withOAuth =
5353

5454
let response = await makeRequest();
5555

56-
// Handle 401 responses by attempting re-authentication
57-
if (response.status === 401) {
56+
// Handle 401/403 responses by attempting re-authentication.
57+
// 403 may indicate the server requires a broader scope (upscoping).
58+
if (response.status === 401 || response.status === 403) {
5859
try {
5960
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
6061

6162
// Use provided baseUrl or extract from request URL
6263
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
6364

64-
const result = await auth(provider, {
65+
let result = await auth(provider, {
6566
serverUrl,
6667
resourceMetadataUrl,
6768
scope,
6869
fetchFn: next
6970
});
7071

7172
if (result === 'REDIRECT') {
72-
throw new UnauthorizedError('Authentication requires user authorization - redirect initiated');
73+
// If the provider can supply the authorization code inline (e.g., a
74+
// headless provider that handles the callback itself), complete the
75+
// token exchange automatically instead of throwing.
76+
if (typeof provider.getAuthorizationCode === 'function') {
77+
const authorizationCode = await provider.getAuthorizationCode();
78+
result = await auth(provider, {
79+
serverUrl,
80+
resourceMetadataUrl,
81+
scope,
82+
authorizationCode,
83+
fetchFn: next
84+
});
85+
} else {
86+
throw new UnauthorizedError('Authentication requires user authorization - redirect initiated');
87+
}
7388
}
7489

7590
if (result !== 'AUTHORIZED') {
@@ -86,8 +101,8 @@ export const withOAuth =
86101
}
87102
}
88103

89-
// If we still have a 401 after re-auth attempt, throw an error
90-
if (response.status === 401) {
104+
// If we still have a 401/403 after re-auth attempt, throw an error
105+
if (response.status === 401 || response.status === 403) {
91106
const url = typeof input === 'string' ? input : input.toString();
92107
throw new UnauthorizedError(`Authentication failed for ${url}`);
93108
}

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,128 @@ describe('withOAuth', () => {
370370
fetchFn: mockFetch
371371
});
372372
});
373+
374+
it('should retry request after successful auth on 403 response', async () => {
375+
mockProvider.tokens
376+
.mockResolvedValueOnce({
377+
access_token: 'old-token',
378+
token_type: 'Bearer',
379+
expires_in: 3600
380+
})
381+
.mockResolvedValueOnce({
382+
access_token: 'new-token',
383+
token_type: 'Bearer',
384+
expires_in: 3600
385+
});
386+
387+
const forbiddenResponse = new Response('Forbidden', {
388+
status: 403,
389+
headers: { 'www-authenticate': 'Bearer realm="oauth" scope="write"' }
390+
});
391+
const successResponse = new Response('success', { status: 200 });
392+
393+
mockFetch.mockResolvedValueOnce(forbiddenResponse).mockResolvedValueOnce(successResponse);
394+
395+
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'write' });
396+
mockAuth.mockResolvedValue('AUTHORIZED');
397+
398+
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
399+
400+
const result = await enhancedFetch('https://api.example.com/data');
401+
402+
expect(result).toBe(successResponse);
403+
expect(mockFetch).toHaveBeenCalledTimes(2);
404+
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
405+
serverUrl: 'https://api.example.com',
406+
resourceMetadataUrl: undefined,
407+
scope: 'write',
408+
fetchFn: mockFetch
409+
});
410+
});
411+
412+
it('should throw UnauthorizedError on persistent 403 after re-auth', async () => {
413+
mockProvider.tokens.mockResolvedValue({
414+
access_token: 'test-token',
415+
token_type: 'Bearer',
416+
expires_in: 3600
417+
});
418+
419+
mockFetch.mockResolvedValue(new Response('Forbidden', { status: 403 }));
420+
mockExtractWWWAuthenticateParams.mockReturnValue({});
421+
mockAuth.mockResolvedValue('AUTHORIZED');
422+
423+
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
424+
425+
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow(
426+
'Authentication failed for https://api.example.com/data'
427+
);
428+
429+
expect(mockFetch).toHaveBeenCalledTimes(2);
430+
});
431+
432+
it('should complete auth code flow when provider implements getAuthorizationCode', async () => {
433+
mockProvider.tokens
434+
.mockResolvedValueOnce({
435+
access_token: 'old-token',
436+
token_type: 'Bearer',
437+
expires_in: 3600
438+
})
439+
.mockResolvedValueOnce({
440+
access_token: 'fresh-token',
441+
token_type: 'Bearer',
442+
expires_in: 3600
443+
});
444+
445+
const unauthorizedResponse = new Response('Unauthorized', { status: 401 });
446+
const successResponse = new Response('success', { status: 200 });
447+
448+
mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse);
449+
450+
mockExtractWWWAuthenticateParams.mockReturnValue({ scope: 'read' });
451+
// First auth() call returns REDIRECT; second (with auth code) returns AUTHORIZED
452+
mockAuth.mockResolvedValueOnce('REDIRECT').mockResolvedValueOnce('AUTHORIZED');
453+
454+
// Provider that can supply the authorization code after the redirect
455+
const providerWithCode = {
456+
...mockProvider,
457+
getAuthorizationCode: vi.fn().mockResolvedValue('auth-code-123')
458+
};
459+
460+
const enhancedFetch = withOAuth(providerWithCode, 'https://api.example.com')(mockFetch);
461+
462+
const result = await enhancedFetch('https://api.example.com/data');
463+
464+
expect(result).toBe(successResponse);
465+
expect(providerWithCode.getAuthorizationCode).toHaveBeenCalledTimes(1);
466+
expect(mockAuth).toHaveBeenCalledTimes(2);
467+
// Second auth() call should include the authorization code
468+
expect(mockAuth).toHaveBeenNthCalledWith(2, providerWithCode, {
469+
serverUrl: 'https://api.example.com',
470+
resourceMetadataUrl: undefined,
471+
scope: 'read',
472+
authorizationCode: 'auth-code-123',
473+
fetchFn: mockFetch
474+
});
475+
expect(mockFetch).toHaveBeenCalledTimes(2);
476+
});
477+
478+
it('should throw UnauthorizedError when auth returns REDIRECT and provider has no getAuthorizationCode', async () => {
479+
mockProvider.tokens.mockResolvedValue({
480+
access_token: 'test-token',
481+
token_type: 'Bearer',
482+
expires_in: 3600
483+
});
484+
485+
mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 }));
486+
mockExtractWWWAuthenticateParams.mockReturnValue({});
487+
mockAuth.mockResolvedValue('REDIRECT');
488+
489+
const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch);
490+
491+
await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow(
492+
'Authentication requires user authorization - redirect initiated'
493+
);
494+
});
373495
});
374496

375497
describe('withLogging', () => {

test/conformance/src/helpers/conformanceOAuthProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
7373
}
7474
}
7575

76-
async getAuthCode(): Promise<string> {
76+
async getAuthorizationCode(): Promise<string> {
7777
if (this._authCode) {
7878
return this._authCode;
7979
}

test/conformance/src/helpers/withOAuthRetry.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,10 @@ export const handle401 = async (
1818
});
1919

2020
if (result === 'REDIRECT') {
21-
// Ordinarily, we'd wait for the callback to be handled here,
22-
// but in our conformance provider, we get the authorization code
23-
// during the redirect handling, so we can go straight to
24-
// retrying the auth step.
25-
// await provider.waitForCallback();
26-
27-
const authorizationCode = await provider.getAuthCode();
28-
29-
// TODO: this retry logic should be incorporated into the typescript SDK
21+
// The conformance provider captures the authorization code during
22+
// redirectToAuthorization(), so we can retrieve it immediately and
23+
// complete the token exchange via a second auth() call.
24+
const authorizationCode = await provider.getAuthorizationCode();
3025
result = await auth(provider, {
3126
serverUrl,
3227
resourceMetadataUrl,

0 commit comments

Comments
 (0)