Skip to content

Commit f95f487

Browse files
authored
[scenario] Add test for broken oauth client (#12)
* starting on broken auth client * switch to middleware approach * add broken example * fix failure testing * subset matching
1 parent f7bc687 commit f95f487

File tree

7 files changed

+343
-67
lines changed

7 files changed

+343
-67
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env node
2+
3+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5+
import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js';
6+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
7+
import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
8+
import {
9+
auth,
10+
UnauthorizedError
11+
} from '@modelcontextprotocol/sdk/client/auth.js';
12+
13+
export const handle401Broken = async (
14+
response: Response,
15+
provider: ConformanceOAuthProvider,
16+
next: FetchLike,
17+
serverUrl: string | URL
18+
): Promise<void> => {
19+
// BROKEN: Use root-based PRM discovery exclusively, regardless of input.
20+
const resourceMetadataUrl = new URL(
21+
'/.well-known/oauth-protected-resource',
22+
typeof serverUrl === 'string' ? serverUrl : serverUrl.origin
23+
);
24+
25+
let result = await auth(provider, {
26+
serverUrl,
27+
resourceMetadataUrl,
28+
fetchFn: next
29+
});
30+
31+
if (result === 'REDIRECT') {
32+
// Ordinarily, we'd wait for the callback to be handled here,
33+
// but in our conformance provider, we get the authorization code
34+
// during the redirect handling, so we can go straight to
35+
// retrying the auth step.
36+
// await provider.waitForCallback();
37+
38+
const authorizationCode = await provider.getAuthCode();
39+
40+
// TODO: this retry logic should be incorporated into the typescript SDK
41+
result = await auth(provider, {
42+
serverUrl,
43+
resourceMetadataUrl,
44+
authorizationCode,
45+
fetchFn: next
46+
});
47+
if (result !== 'AUTHORIZED') {
48+
throw new UnauthorizedError(
49+
`Authentication failed with result: ${result}`
50+
);
51+
}
52+
}
53+
};
54+
55+
async function main(): Promise<void> {
56+
const serverUrl = process.argv[2];
57+
58+
if (!serverUrl) {
59+
console.error('Usage: auth-test <server-url>');
60+
process.exit(1);
61+
}
62+
63+
console.log(`Connecting to MCP server at: ${serverUrl}`);
64+
65+
const client = new Client(
66+
{
67+
name: 'test-auth-client',
68+
version: '1.0.0'
69+
},
70+
{
71+
capabilities: {}
72+
}
73+
);
74+
75+
// Create a custom fetch that uses the OAuth middleware with retry logic
76+
const oauthFetch = withOAuthRetry(
77+
'test-auth-client',
78+
new URL(serverUrl),
79+
handle401Broken
80+
)(fetch);
81+
82+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
83+
fetch: oauthFetch
84+
});
85+
86+
// Connect to the server - OAuth is handled automatically by the middleware
87+
await client.connect(transport);
88+
console.log('✅ Successfully connected to MCP server');
89+
90+
await client.listTools();
91+
console.log('✅ Successfully listed tools');
92+
93+
await transport.close();
94+
console.log('✅ Connection closed successfully');
95+
96+
process.exit(0);
97+
}
98+
99+
main();

examples/clients/typescript/auth-test.ts

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
44
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5-
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
6-
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
5+
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
76

87
async function main(): Promise<void> {
98
const serverUrl = process.argv[2];
@@ -25,47 +24,19 @@ async function main(): Promise<void> {
2524
}
2625
);
2726

28-
const authProvider = new ConformanceOAuthProvider(
29-
'http://localhost:3000/callback',
30-
{
31-
client_name: 'test-auth-client',
32-
redirect_uris: ['http://localhost:3000/callback']
33-
}
34-
);
27+
// Create a custom fetch that uses the OAuth middleware with retry logic
28+
const oauthFetch = withOAuthRetry(
29+
'test-auth-client',
30+
new URL(serverUrl)
31+
)(fetch);
3532

36-
let transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
37-
authProvider
33+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
34+
fetch: oauthFetch
3835
});
3936

40-
// Try to connect - handle OAuth if needed
41-
try {
42-
await client.connect(transport);
43-
console.log('✅ Successfully connected to MCP server');
44-
} catch (error) {
45-
if (error instanceof UnauthorizedError) {
46-
console.log('🔐 OAuth required - handling authorization...');
47-
48-
// The provider will automatically fetch the auth code
49-
const authCode = await authProvider.getAuthCode();
50-
51-
// Complete the auth flow
52-
await transport.finishAuth(authCode);
53-
54-
// Close the old transport
55-
await transport.close();
56-
57-
// Create a new transport with the authenticated provider
58-
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
59-
authProvider: authProvider
60-
});
61-
62-
// Connect with the new transport
63-
await client.connect(transport);
64-
console.log('✅ Successfully connected with authentication');
65-
} else {
66-
throw error;
67-
}
68-
}
37+
// Connect to the server - OAuth is handled automatically by the middleware
38+
await client.connect(transport);
39+
console.log('✅ Successfully connected to MCP server');
6940

7041
await client.listTools();
7142
console.log('✅ Successfully listed tools');
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
auth,
3+
extractResourceMetadataUrl,
4+
UnauthorizedError
5+
} from '@modelcontextprotocol/sdk/client/auth.js';
6+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
7+
import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js';
8+
import { ConformanceOAuthProvider } from './ConformanceOAuthProvider';
9+
10+
export const handle401 = async (
11+
response: Response,
12+
provider: ConformanceOAuthProvider,
13+
next: FetchLike,
14+
serverUrl: string | URL
15+
): Promise<void> => {
16+
const resourceMetadataUrl = extractResourceMetadataUrl(response);
17+
18+
let result = await auth(provider, {
19+
serverUrl,
20+
resourceMetadataUrl,
21+
fetchFn: next
22+
});
23+
24+
if (result === 'REDIRECT') {
25+
// Ordinarily, we'd wait for the callback to be handled here,
26+
// but in our conformance provider, we get the authorization code
27+
// during the redirect handling, so we can go straight to
28+
// retrying the auth step.
29+
// await provider.waitForCallback();
30+
31+
const authorizationCode = await provider.getAuthCode();
32+
33+
// TODO: this retry logic should be incorporated into the typescript SDK
34+
result = await auth(provider, {
35+
serverUrl,
36+
resourceMetadataUrl,
37+
authorizationCode,
38+
fetchFn: next
39+
});
40+
if (result !== 'AUTHORIZED') {
41+
throw new UnauthorizedError(
42+
`Authentication failed with result: ${result}`
43+
);
44+
}
45+
}
46+
};
47+
/**
48+
* Creates a fetch wrapper that handles OAuth authentication with retry logic.
49+
*
50+
* Unlike the SDK's withOAuth, this version:
51+
* - Automatically handles authorization redirects by retrying with fresh tokens
52+
* - Does not throw UnauthorizedError on redirect, but instead retries
53+
* - Calls next() instead of throwing for redirect-based auth
54+
*
55+
* @param provider - OAuth client provider for authentication
56+
* @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain)
57+
* @returns A fetch middleware function
58+
*/
59+
export const withOAuthRetry = (
60+
clientName: string,
61+
baseUrl?: string | URL,
62+
handle401Fn: typeof handle401 = handle401
63+
): Middleware => {
64+
const provider = new ConformanceOAuthProvider(
65+
'http://localhost:3000/callback',
66+
{
67+
client_name: clientName,
68+
redirect_uris: ['http://localhost:3000/callback']
69+
}
70+
);
71+
return (next: FetchLike) => {
72+
return async (
73+
input: string | URL,
74+
init?: RequestInit
75+
): Promise<Response> => {
76+
const makeRequest = async (): Promise<Response> => {
77+
const headers = new Headers(init?.headers);
78+
79+
// Add authorization header if tokens are available
80+
const tokens = await provider.tokens();
81+
if (tokens) {
82+
headers.set('Authorization', `Bearer ${tokens.access_token}`);
83+
}
84+
85+
return await next(input, { ...init, headers });
86+
};
87+
88+
let response = await makeRequest();
89+
90+
// Handle 401 responses by attempting re-authentication
91+
if (response.status === 401) {
92+
const serverUrl =
93+
baseUrl ||
94+
(typeof input === 'string' ? new URL(input).origin : input.origin);
95+
await handle401Fn(response, provider, next, serverUrl);
96+
97+
response = await makeRequest();
98+
}
99+
100+
// If we still have a 401 after re-auth attempt, throw an error
101+
if (response.status === 401) {
102+
const url = typeof input === 'string' ? input : input.toString();
103+
throw new UnauthorizedError(`Authentication failed for ${url}`);
104+
}
105+
106+
return response;
107+
};
108+
};
109+
};
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, test } from '@jest/globals';
2-
import { runClientAgainstScenario } from './helpers/testClient.js';
2+
import {
3+
runClientAgainstScenario,
4+
SpawnedClientRunner
5+
} from './test_helpers/testClient.js';
36
import path from 'path';
47

58
describe('PRM Path-Based Discovery', () => {
@@ -8,6 +11,19 @@ describe('PRM Path-Based Discovery', () => {
811
process.cwd(),
912
'examples/clients/typescript/auth-test.ts'
1013
);
11-
await runClientAgainstScenario(clientPath, 'auth/basic-dcr');
14+
const runner = new SpawnedClientRunner(clientPath);
15+
await runClientAgainstScenario(runner, 'auth/basic-dcr');
16+
});
17+
18+
test('bad client requests root PRM location', async () => {
19+
const clientPath = path.join(
20+
process.cwd(),
21+
'examples/clients/typescript/auth-test-broken1.ts'
22+
);
23+
const runner = new SpawnedClientRunner(clientPath);
24+
await runClientAgainstScenario(runner, 'auth/basic-dcr', [
25+
// There will be other failures, but this is the one that matters
26+
'prm-priority-order'
27+
]);
1228
});
1329
});

src/scenarios/client/auth/basic-dcr.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ScenarioUrls } from '../../../types.js';
33
import { createAuthServer } from './helpers/createAuthServer.js';
44
import { createServer } from './helpers/createServer.js';
55
import { ServerLifecycle } from './helpers/serverLifecycle.js';
6+
import { Request, Response } from 'express';
67

78
export class AuthBasicDCRScenario implements Scenario {
89
name = 'auth-basic-dcr';
@@ -25,6 +26,38 @@ export class AuthBasicDCRScenario implements Scenario {
2526
() => this.baseUrl,
2627
() => this.authBaseUrl
2728
);
29+
30+
// For this scenario, reject PRM requests at root location since we have the path-based PRM.
31+
app.get(
32+
'/.well-known/oauth-protected-resource',
33+
(req: Request, res: Response) => {
34+
this.checks.push({
35+
id: 'prm-priority-order',
36+
name: 'PRM Priority Order',
37+
description:
38+
'Client requested PRM metadata at root location on a server with path-based PRM',
39+
status: 'FAILURE',
40+
timestamp: new Date().toISOString(),
41+
specReferences: [
42+
{
43+
id: 'mcp-authorization-prm',
44+
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements'
45+
}
46+
],
47+
details: {
48+
url: req.url,
49+
path: req.path
50+
}
51+
});
52+
53+
// Return 404 to indicate PRM is not available at root location
54+
res.status(404).json({
55+
error: 'not_found',
56+
error_description: 'PRM metadata not available at root location'
57+
});
58+
}
59+
);
60+
2861
this.baseUrl = await this.server.start(app);
2962

3063
return { serverUrl: `${this.baseUrl}/mcp` };
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, test } from '@jest/globals';
2-
import { runClientAgainstScenario } from './helpers/testClient.js';
2+
import {
3+
runClientAgainstScenario,
4+
SpawnedClientRunner
5+
} from './test_helpers/testClient.js';
36
import path from 'path';
47

58
describe('OAuth Metadata at OpenID Configuration Path', () => {
@@ -8,6 +11,7 @@ describe('OAuth Metadata at OpenID Configuration Path', () => {
811
process.cwd(),
912
'examples/clients/typescript/auth-test.ts'
1013
);
11-
await runClientAgainstScenario(clientPath, 'auth/basic-metadata-var1');
14+
const runner = new SpawnedClientRunner(clientPath);
15+
await runClientAgainstScenario(runner, 'auth/basic-metadata-var1');
1216
});
1317
});

0 commit comments

Comments
 (0)