Skip to content

Commit 8edfd2c

Browse files
committed
add example implementation
1 parent 16ea277 commit 8edfd2c

File tree

3 files changed

+178
-5
lines changed

3 files changed

+178
-5
lines changed

src/examples/client/simpleOAuthClient.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider {
3232
constructor(
3333
private readonly _redirectUrl: string | URL,
3434
private readonly _clientMetadata: OAuthClientMetadata,
35-
onRedirect?: (url: URL) => void
35+
onRedirect?: (url: URL) => void,
36+
private readonly _clientMetadataUrl?: string | URL,
3637
) {
3738
this._onRedirect = onRedirect || ((url) => {
3839
console.log(`Redirect to: ${url.toString()}`);
@@ -50,6 +51,12 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider {
5051
}
5152

5253
clientInformation(): OAuthClientInformation | undefined {
54+
if (this._clientMetadataUrl) {
55+
console.log("Using client ID metadata URL");
56+
return {
57+
client_id: this._clientMetadataUrl.toString(),
58+
}
59+
}
5360
return this._clientInformation;
5461
}
5562

@@ -231,7 +238,8 @@ class InteractiveOAuthClient {
231238
console.log(`📌 OAuth redirect handler called - opening browser`);
232239
console.log(`Opening browser to: ${redirectUrl.toString()}`);
233240
this.openBrowser(redirectUrl.toString());
234-
}
241+
},
242+
'https://pcarleton--c1073a0b670949da87f3911be7feb5d5.web.val.run/mcp.json',
235243
);
236244
console.log('🔐 OAuth provider created');
237245

@@ -419,4 +427,4 @@ async function main(): Promise<void> {
419427
main().catch((error) => {
420428
console.error('Unhandled error:', error);
421429
process.exit(1);
422-
});
430+
});

src/server/auth/clientIdHelpers.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import dns from 'node:dns';
2+
import { OAuthClientInformationFull } from 'src/shared/auth.js';
3+
4+
/**
5+
* Reads a limited amount of data from a fetch response, closes the stream, and returns the parsed JSON result.
6+
* Throws an error if the response contains more data than the limit.
7+
*
8+
* @param response The fetch response object
9+
* @param options Configuration options
10+
* @returns Parsed JSON data
11+
*/
12+
async function readLimitedJson<T>(
13+
response: Response,
14+
options: { maxSizeInBytes?: number } = {}
15+
): Promise<T> {
16+
const maxSize = options.maxSizeInBytes || 1024 * 1024; // Default to 1MB
17+
18+
const reader = response.body?.getReader();
19+
if (!reader) {
20+
throw new Error('Response body is null or undefined');
21+
}
22+
23+
let receivedLength = 0;
24+
const chunks: Uint8Array[] = [];
25+
26+
while (true) {
27+
const { done, value } = await reader.read();
28+
29+
if (done) {
30+
break;
31+
}
32+
33+
if (value) {
34+
chunks.push(value);
35+
receivedLength += value.length;
36+
37+
if (receivedLength > maxSize) {
38+
// Cancel the stream and throw error if we exceed the limit
39+
await reader.cancel();
40+
throw new Error(`Response exceeded size limit of ${maxSize} bytes`);
41+
}
42+
}
43+
}
44+
45+
// Concatenate chunks into a single Uint8Array
46+
const allChunks = new Uint8Array(receivedLength);
47+
let position = 0;
48+
for (const chunk of chunks) {
49+
allChunks.set(chunk, position);
50+
position += chunk.length;
51+
}
52+
53+
// Convert to text and parse as JSON
54+
const text = new TextDecoder().decode(allChunks);
55+
return JSON.parse(text);
56+
}
57+
58+
/**
59+
* Validates if a URL is using HTTPS and doesn't resolve to an IP in the denylist.
60+
* By default, the denylist includes private IP ranges.
61+
*
62+
* @param url The URL to validate
63+
* @param options Configuration options
64+
* @returns Promise that resolves when validation is successful
65+
* @throws Error if validation fails
66+
*/
67+
async function validatePublicUrl(
68+
url: URL,
69+
options: {
70+
denylist?: RegExp[];
71+
requireHttps?: boolean;
72+
} = {}
73+
): Promise<void> {
74+
// Default options
75+
const requireHttps = options.requireHttps ?? true;
76+
const denylist = options.denylist ?? [
77+
// Private IP ranges
78+
/^10\./, // 10.0.0.0/8
79+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
80+
/^192\.168\./, // 192.168.0.0/16
81+
/^127\./, // 127.0.0.0/8
82+
/^169\.254\./, // 169.254.0.0/16 (link-local)
83+
/^::1/, // localhost in IPv6
84+
/^f[cd][0-9a-f]{2}:/i, // IPv6 unique local addresses (fc00::/7)
85+
/^fe80:/i // IPv6 link-local (fe80::/10)
86+
];
87+
88+
// Check if it's HTTPS when required
89+
if (requireHttps && url.protocol !== 'https:') {
90+
throw new Error('URL must use HTTPS protocol');
91+
}
92+
93+
// Resolve DNS name to IPv4 addresses
94+
const addresses = await dns.promises.resolve4(url.hostname);
95+
96+
// Check if any resolved IP is in the denylist
97+
for (const ip of addresses) {
98+
if (denylist.some(pattern => pattern.test(ip))) {
99+
throw new Error('URL resolves to a denied IP address');
100+
}
101+
}
102+
103+
// Could also check IPv6 addresses if needed
104+
const ipv6Addresses = await dns.promises.resolve6(url.hostname);
105+
for (const ip of ipv6Addresses) {
106+
if (denylist.some(pattern => pattern.test(ip))) {
107+
throw new Error('URL resolves to a denied IP address');
108+
}
109+
}
110+
}
111+
112+
export async function fetchClientMetadata(client_id: string): Promise<OAuthClientInformationFull> {
113+
// Check that client_id is a string
114+
if (typeof client_id !== 'string') {
115+
throw new Error('Client ID must be a string');
116+
}
117+
118+
// Check if client_id is a URL
119+
let url: URL;
120+
try {
121+
url = new URL(client_id);
122+
123+
// Check that the URL is https, and a public IP etc.
124+
await validatePublicUrl(url);
125+
126+
// Fetch the URL
127+
// TODO: outbound rate limit
128+
const response = await fetch(client_id);
129+
if (!response.ok) {
130+
throw new Error(`Failed to fetch client metadata: ${response.status}`);
131+
}
132+
133+
const maxSize = 1024 * 1024; // 1MB
134+
const contentLength = parseInt(response.headers.get('content-length') || '0');
135+
if (contentLength > maxSize) {
136+
throw new Error('Client metadata response too large');
137+
}
138+
139+
return readLimitedJson(response, { maxSizeInBytes: maxSize });
140+
} catch (error) {
141+
if (error instanceof TypeError) {
142+
throw new Error('Client ID must be a valid URL');
143+
}
144+
throw error;
145+
}
146+
}

src/server/auth/handlers/authorize.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
TooManyRequestsError,
1313
OAuthError
1414
} from "../errors.js";
15+
import { fetchClientMetadata } from "../clientIdHelpers.js";
1516

1617
export type AuthorizationHandlerOptions = {
1718
provider: OAuthServerProvider;
@@ -76,7 +77,25 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
7677

7778
client = await provider.clientsStore.getClient(client_id);
7879
if (!client) {
79-
throw new InvalidClientError("Invalid client_id");
80+
// Check if client_id is a URL
81+
if (!client_id.startsWith('https://')) {
82+
throw new InvalidClientError("Invalid client_id");
83+
}
84+
// If it's a URL, fetch its metadata
85+
try {
86+
// Fetch client metadata from the URL using client ID helpers
87+
client = await fetchClientMetadata(client_id);
88+
89+
// Store the fetched metadata, to prevent needing to refetch
90+
// TODO: handle expiring metadata
91+
// TODO: make this registration independent of DCR
92+
if (provider.clientsStore.registerClient) {
93+
await provider.clientsStore.registerClient(client);
94+
}
95+
96+
} catch (fetchError) {
97+
throw new InvalidClientError(`Failed to fetch client metadata: ${fetchError instanceof Error ? fetchError.message : fetchError}`);
98+
}
8099
}
81100

82101
if (redirect_uri !== undefined) {
@@ -168,4 +187,4 @@ function createErrorRedirect(redirectUri: string, error: OAuthError, state?: str
168187
errorUrl.searchParams.set("state", state);
169188
}
170189
return errorUrl.href;
171-
}
190+
}

0 commit comments

Comments
 (0)