Skip to content

Commit 5c15113

Browse files
authored
[auth] Scenarios for scope selection (#36)
* wip scope change * rm useless bool * fix-me: package changes for linking * negative test * negative test for multiple scopes * fix displays of warnings * wip step-up * negative test for step up auth * move warning into main request flow * clean up more checks * cleanup step-up scenario * cleanup and simplify * cleanup middleware * inline negative tests * inline the other test * refactor logging and inlining for standalone execution too * skip pending tests
1 parent 253ede0 commit 5c15113

22 files changed

+1074
-186
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 {
6+
auth,
7+
UnauthorizedError
8+
} from '@modelcontextprotocol/sdk/client/auth.js';
9+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
10+
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
11+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
12+
import { runAsCli } from './helpers/cliRunner.js';
13+
import { logger } from './helpers/logger.js';
14+
15+
/**
16+
* Broken client that always uses root-based PRM discovery.
17+
* BUG: Ignores the resource_metadata URL from WWW-Authenticate header.
18+
*/
19+
export async function runClient(serverUrl: string): Promise<void> {
20+
const handle401Broken = async (
21+
response: Response,
22+
provider: ConformanceOAuthProvider,
23+
next: FetchLike,
24+
serverUrl: string | URL
25+
): Promise<void> => {
26+
// BUG: Use root-based PRM discovery exclusively
27+
const resourceMetadataUrl = new URL(
28+
'/.well-known/oauth-protected-resource',
29+
typeof serverUrl === 'string' ? serverUrl : serverUrl.origin
30+
);
31+
32+
let result = await auth(provider, {
33+
serverUrl,
34+
resourceMetadataUrl,
35+
fetchFn: next
36+
});
37+
38+
if (result === 'REDIRECT') {
39+
const authorizationCode = await provider.getAuthCode();
40+
result = await auth(provider, {
41+
serverUrl,
42+
resourceMetadataUrl,
43+
authorizationCode,
44+
fetchFn: next
45+
});
46+
if (result !== 'AUTHORIZED') {
47+
throw new UnauthorizedError(
48+
`Authentication failed with result: ${result}`
49+
);
50+
}
51+
}
52+
};
53+
54+
const client = new Client(
55+
{ name: 'test-auth-client-broken', version: '1.0.0' },
56+
{ capabilities: {} }
57+
);
58+
59+
const oauthFetch = withOAuthRetry(
60+
'test-auth-client-broken',
61+
new URL(serverUrl),
62+
handle401Broken
63+
)(fetch);
64+
65+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
66+
fetch: oauthFetch
67+
});
68+
69+
await client.connect(transport);
70+
logger.debug('✅ Successfully connected to MCP server');
71+
72+
await client.listTools();
73+
logger.debug('✅ Successfully listed tools');
74+
75+
await transport.close();
76+
logger.debug('✅ Connection closed successfully');
77+
}
78+
79+
runAsCli(runClient, import.meta.url, 'auth-test-bad-prm <server-url>');

examples/clients/typescript/auth-test-broken1.ts

Lines changed: 0 additions & 99 deletions
This file was deleted.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 {
6+
auth,
7+
extractWWWAuthenticateParams,
8+
UnauthorizedError
9+
} from '@modelcontextprotocol/sdk/client/auth.js';
10+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
11+
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
12+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
13+
import { runAsCli } from './helpers/cliRunner.js';
14+
import { logger } from './helpers/logger.js';
15+
16+
/**
17+
* Broken client that only responds to 401, not 403.
18+
* BUG: Ignores 403 responses which are used for step-up auth.
19+
*/
20+
export async function runClient(serverUrl: string): Promise<void> {
21+
const handle401Broken = async (
22+
response: Response,
23+
provider: ConformanceOAuthProvider,
24+
next: FetchLike,
25+
serverUrl: string | URL
26+
): Promise<void> => {
27+
// BUG: Only respond to 401, not 403
28+
if (response.status !== 401) {
29+
return;
30+
}
31+
32+
const { resourceMetadataUrl, scope } =
33+
extractWWWAuthenticateParams(response);
34+
let result = await auth(provider, {
35+
serverUrl,
36+
resourceMetadataUrl,
37+
scope,
38+
fetchFn: next
39+
});
40+
41+
if (result === 'REDIRECT') {
42+
const authorizationCode = await provider.getAuthCode();
43+
result = await auth(provider, {
44+
serverUrl,
45+
resourceMetadataUrl,
46+
scope,
47+
authorizationCode,
48+
fetchFn: next
49+
});
50+
if (result !== 'AUTHORIZED') {
51+
throw new UnauthorizedError(
52+
`Authentication failed with result: ${result}`
53+
);
54+
}
55+
}
56+
};
57+
58+
const client = new Client(
59+
{ name: 'test-auth-client-broken', version: '1.0.0' },
60+
{ capabilities: {} }
61+
);
62+
63+
const oauthFetch = withOAuthRetry(
64+
'test-auth-client-broken',
65+
new URL(serverUrl),
66+
handle401Broken
67+
)(fetch);
68+
69+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
70+
fetch: oauthFetch
71+
});
72+
73+
await client.connect(transport);
74+
logger.debug('✅ Successfully connected to MCP server');
75+
76+
await client.listTools();
77+
logger.debug('✅ Successfully listed tools');
78+
79+
// Call tool to trigger step-up auth
80+
await client.callTool({ name: 'test-tool', arguments: {} });
81+
logger.debug('✅ Successfully called tool');
82+
83+
await transport.close();
84+
logger.debug('✅ Connection closed successfully');
85+
}
86+
87+
runAsCli(runClient, import.meta.url, 'auth-test-ignore-403 <server-url>');
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 {
6+
auth,
7+
extractWWWAuthenticateParams,
8+
UnauthorizedError
9+
} from '@modelcontextprotocol/sdk/client/auth.js';
10+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
11+
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
12+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
13+
import { runAsCli } from './helpers/cliRunner.js';
14+
import { logger } from './helpers/logger.js';
15+
16+
/**
17+
* Broken client that ignores the scope from WWW-Authenticate header.
18+
* BUG: Doesn't pass the scope parameter from the 401 response.
19+
*/
20+
export async function runClient(serverUrl: string): Promise<void> {
21+
const handle401Broken = async (
22+
response: Response,
23+
provider: ConformanceOAuthProvider,
24+
next: FetchLike,
25+
serverUrl: string | URL
26+
): Promise<void> => {
27+
// BUG: Don't read the scope from the header
28+
const { resourceMetadataUrl } = extractWWWAuthenticateParams(response);
29+
let result = await auth(provider, {
30+
serverUrl,
31+
resourceMetadataUrl,
32+
// scope deliberately omitted
33+
fetchFn: next
34+
});
35+
36+
if (result === 'REDIRECT') {
37+
const authorizationCode = await provider.getAuthCode();
38+
result = await auth(provider, {
39+
serverUrl,
40+
resourceMetadataUrl,
41+
authorizationCode,
42+
fetchFn: next
43+
});
44+
if (result !== 'AUTHORIZED') {
45+
throw new UnauthorizedError(
46+
`Authentication failed with result: ${result}`
47+
);
48+
}
49+
}
50+
};
51+
52+
const client = new Client(
53+
{ name: 'test-auth-client-broken', version: '1.0.0' },
54+
{ capabilities: {} }
55+
);
56+
57+
const oauthFetch = withOAuthRetry(
58+
'test-auth-client-broken',
59+
new URL(serverUrl),
60+
handle401Broken
61+
)(fetch);
62+
63+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
64+
fetch: oauthFetch
65+
});
66+
67+
await client.connect(transport);
68+
logger.debug('✅ Successfully connected to MCP server');
69+
70+
await client.listTools();
71+
logger.debug('✅ Successfully listed tools');
72+
73+
await transport.close();
74+
logger.debug('✅ Connection closed successfully');
75+
}
76+
77+
runAsCli(runClient, import.meta.url, 'auth-test-ignore-scope <server-url>');

0 commit comments

Comments
 (0)