diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 4531f4c2a..2eaa59393 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -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()}`); @@ -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; } @@ -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'); @@ -419,4 +427,4 @@ async function main(): Promise { main().catch((error) => { console.error('Unhandled error:', error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/server/auth/clientIdHelpers.ts b/src/server/auth/clientIdHelpers.ts new file mode 100644 index 000000000..6d81e8f11 --- /dev/null +++ b/src/server/auth/clientIdHelpers.ts @@ -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( + response: Response, + options: { maxSizeInBytes?: number } = {} +): Promise { + 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 { + // 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 { +// 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; + } +} diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 126ce006b..5ebe987ee 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -12,6 +12,7 @@ import { TooManyRequestsError, OAuthError } from "../errors.js"; +import { fetchClientMetadata } from "../clientIdHelpers.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -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) { @@ -168,4 +187,4 @@ function createErrorRedirect(redirectUri: string, error: OAuthError, state?: str errorUrl.searchParams.set("state", state); } return errorUrl.href; -} \ No newline at end of file +}