Skip to content

[prototype] add example implementation of client id metadata documents (SEP-991) #839

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/examples/client/simpleOAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider {
constructor(
private readonly _redirectUrl: string | URL,
private readonly _clientMetadata: OAuthClientMetadata,
onRedirect?: (url: URL) => void
onRedirect?: (url: URL) => void,
private readonly _clientMetadataUrl?: string | URL,
) {
this._onRedirect = onRedirect || ((url) => {
console.log(`Redirect to: ${url.toString()}`);
Expand All @@ -50,6 +51,12 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider {
}

clientInformation(): OAuthClientInformation | undefined {
if (this._clientMetadataUrl) {
console.log("Using client ID metadata URL");
return {
client_id: this._clientMetadataUrl.toString(),
}
}
return this._clientInformation;
}

Expand Down Expand Up @@ -231,7 +238,8 @@ class InteractiveOAuthClient {
console.log(`📌 OAuth redirect handler called - opening browser`);
console.log(`Opening browser to: ${redirectUrl.toString()}`);
this.openBrowser(redirectUrl.toString());
}
},
'https://pcarleton--c1073a0b670949da87f3911be7feb5d5.web.val.run/mcp.json',
);
console.log('🔐 OAuth provider created');

Expand Down Expand Up @@ -419,4 +427,4 @@ async function main(): Promise<void> {
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});
});
146 changes: 146 additions & 0 deletions src/server/auth/clientIdHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import dns from 'node:dns';
import { OAuthClientInformationFull } from 'src/shared/auth.js';

/**
* Reads a limited amount of data from a fetch response, closes the stream, and returns the parsed JSON result.
* Throws an error if the response contains more data than the limit.
*
* @param response The fetch response object
* @param options Configuration options
* @returns Parsed JSON data
*/
async function readLimitedJson<T>(
response: Response,
options: { maxSizeInBytes?: number } = {}
): Promise<T> {
const maxSize = options.maxSizeInBytes || 1024 * 1024; // Default to 1MB

const reader = response.body?.getReader();
if (!reader) {
throw new Error('Response body is null or undefined');
}

let receivedLength = 0;
const chunks: Uint8Array[] = [];

while (true) {
const { done, value } = await reader.read();

if (done) {
break;
}

if (value) {
chunks.push(value);
receivedLength += value.length;

if (receivedLength > maxSize) {
// Cancel the stream and throw error if we exceed the limit
await reader.cancel();
throw new Error(`Response exceeded size limit of ${maxSize} bytes`);
}
}
}

// Concatenate chunks into a single Uint8Array
const allChunks = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}

// Convert to text and parse as JSON
const text = new TextDecoder().decode(allChunks);
return JSON.parse(text);
}

/**
* Validates if a URL is using HTTPS and doesn't resolve to an IP in the denylist.
* By default, the denylist includes private IP ranges.
*
* @param url The URL to validate
* @param options Configuration options
* @returns Promise that resolves when validation is successful
* @throws Error if validation fails
*/
async function validatePublicUrl(
url: URL,
options: {
denylist?: RegExp[];
requireHttps?: boolean;
} = {}
): Promise<void> {
// Default options
const requireHttps = options.requireHttps ?? true;
const denylist = options.denylist ?? [
// Private IP ranges
/^10\./, // 10.0.0.0/8
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
/^192\.168\./, // 192.168.0.0/16
/^127\./, // 127.0.0.0/8
/^169\.254\./, // 169.254.0.0/16 (link-local)
/^::1/, // localhost in IPv6
/^f[cd][0-9a-f]{2}:/i, // IPv6 unique local addresses (fc00::/7)
/^fe80:/i // IPv6 link-local (fe80::/10)
];

// Check if it's HTTPS when required
if (requireHttps && url.protocol !== 'https:') {
throw new Error('URL must use HTTPS protocol');
}

// Resolve DNS name to IPv4 addresses
const addresses = await dns.promises.resolve4(url.hostname);

// Check if any resolved IP is in the denylist
for (const ip of addresses) {
if (denylist.some(pattern => pattern.test(ip))) {
throw new Error('URL resolves to a denied IP address');
}
}

// Could also check IPv6 addresses if needed
const ipv6Addresses = await dns.promises.resolve6(url.hostname);
for (const ip of ipv6Addresses) {
if (denylist.some(pattern => pattern.test(ip))) {
throw new Error('URL resolves to a denied IP address');
}
}
}

export async function fetchClientMetadata(client_id: string): Promise<OAuthClientInformationFull> {
// Check that client_id is a string
if (typeof client_id !== 'string') {
throw new Error('Client ID must be a string');
}

// Check if client_id is a URL
let url: URL;
try {
url = new URL(client_id);

// Check that the URL is https, and a public IP etc.
await validatePublicUrl(url);

// Fetch the URL
// TODO: outbound rate limit
const response = await fetch(client_id);
if (!response.ok) {
throw new Error(`Failed to fetch client metadata: ${response.status}`);
}

const maxSize = 1024 * 1024; // 1MB
const contentLength = parseInt(response.headers.get('content-length') || '0');
if (contentLength > maxSize) {
throw new Error('Client metadata response too large');
}

return readLimitedJson(response, { maxSizeInBytes: maxSize });
} catch (error) {
if (error instanceof TypeError) {
throw new Error('Client ID must be a valid URL');
}
throw error;
}
}
23 changes: 21 additions & 2 deletions src/server/auth/handlers/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TooManyRequestsError,
OAuthError
} from "../errors.js";
import { fetchClientMetadata } from "../clientIdHelpers.js";

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

client = await provider.clientsStore.getClient(client_id);
if (!client) {
throw new InvalidClientError("Invalid client_id");
// Check if client_id is a URL
if (!client_id.startsWith('https://')) {
throw new InvalidClientError("Invalid client_id");
}
// If it's a URL, fetch its metadata
try {
// Fetch client metadata from the URL using client ID helpers
client = await fetchClientMetadata(client_id);

// Store the fetched metadata, to prevent needing to refetch
// TODO: handle expiring metadata
// TODO: make this registration independent of DCR
if (provider.clientsStore.registerClient) {
await provider.clientsStore.registerClient(client);
}

} catch (fetchError) {
throw new InvalidClientError(`Failed to fetch client metadata: ${fetchError instanceof Error ? fetchError.message : fetchError}`);
}
}

if (redirect_uri !== undefined) {
Expand Down Expand Up @@ -168,4 +187,4 @@ function createErrorRedirect(redirectUri: string, error: OAuthError, state?: str
errorUrl.searchParams.set("state", state);
}
return errorUrl.href;
}
}
Loading