/** * Client ID Metadata Document (CIMD) resolution * * Implements the AS side of CIMD as specified in the MCP authorization spec * and draft-ietf-oauth-client-id-metadata-document-00. When a client_id is a * valid HTTPS URL with a non-root path, the AS fetches it as a JSON metadata * document (instead of doing a DCR lookup) and validates the result. * * SSRF guards: * - HTTPS only (lowercase-canonical scheme), non-root path required, no * userinfo/fragment/query, no dot path segments (raw or percent-encoded). * - No IP-literal hosts in the URL (rejected before DNS). * - DNS gate: all resolved addresses are checked against the full IANA * special-purpose registries. IPv4 rejects 0/8, 10/8, 100.64/10, 127/8, * 169.254/16, 172.16/12, 192.0.0/24, 192.0.2/24, 192.88.99/24, 192.168/16, * 198.18/15, 198.51.100/24, 203.0.113/24, 224/4 (multicast), 240/4 * (reserved, incl. broadcast), and the AS112/AMT blocks. * IPv6 fails closed to "global unicast (2000::/3) only", with v4-mapped and * 6to4/ISATAP transition forms classified by their embedded IPv4 address and * the in-2000::/3 special-use prefixes (Teredo, ORCHID, documentation) also * rejected. * - Pinned-connect: the fetch connects to the exact address the gate validated * (custom `lookup`), while the hostname is kept for TLS SNI + certificate * verification — so DNS rebinding between the gate and the connection cannot * redirect the socket to an unvalidated address. * - `https.request` does not follow redirects, so a 3xx surfaces as a non-200 * and is rejected; only `200 OK` is accepted. * - 5 s deadline (configurable) covering DNS, connect, headers, AND body. * - Any rejection after headers (non-200, non-JSON, oversize) aborts the * request, tearing down the pinned socket — a hostile endpoint can't hold * connections open past the rejection. * - Concurrent DNS resolutions are globally bounded below libuv's default * 4-thread pool (getaddrinfo runs on that uncancellable pool and must not * starve fs/crypto users), and TOTAL concurrent resolutions (DNS + connect * + body) are separately bounded so unique client_ids can't fan out into * unbounded outbound HTTPS work; over either bound, resolution * fast-rejects. Concurrent resolutions of the same client_id are deduped * to one fetch. * - 64 KB document size cap (configurable). * - `Accept: application/json` only; non-JSON responses rejected. * - Timeout/size config values are coerced to finite positive numbers; * anything else (NaN, Infinity, non-numeric strings) falls back to the * defaults rather than failing open. * - Rejections never echo the resolved address or DNS outcome to the caller * (that would let unauthenticated clients probe the server's internal DNS * view); details are logged server-side only. * * Caching: * - Per-process Map keyed by URL, LRU-bounded to 1000 entries (the key is * attacker-chosen, so the cache must not grow unbounded). * - TTL: honor `Cache-Control: max-age` clamped to [60 s, 86400 s]; * default 3600 s when absent. `no-store`/`no-cache` are honored as the * 60 s floor rather than literally — the floor is deliberate DoS * protection (an attacker's document must not be able to force a fetch * per authorize request). * - Failures are NOT cached (the CIMD draft forbids caching error responses * and invalid documents); repeated fetch attempts are instead rate-limited * per client_id URL (fixed 10/min token bucket, #163) — legit clients hit * the cache after their first success, so only failures repeat. Issuance * itself is separately rate-limited at the client_credentials grant. * - Cached records are revalidated against the live redirect-host policy on * every hit, so tightening `allowedRedirectUriHosts` takes effect * immediately instead of after cache expiry (up to 24 h). * * Document shapes: * - Interactive (redirect-based) documents: `token_endpoint_auth_method: * none` (public clients + PKCE), redirect_uris required. * - Headless client_credentials documents (#161): grant_types exactly * ["client_credentials"], `token_endpoint_auth_method: private_key_jwt`, * an inline public Ed25519 JWK Set (`jwks_uri` rejected — no second SSRF * surface), NO redirect_uris/response_types — and only accepted when the * operator has pinned `clientIdMetadataDocuments.allowedHosts` (hosting a * reachable document must never suffice to mint tokens). */ import type { Logger, MCPClientIdMetadataDocumentsConfig, MCPClientRecord, MCPConfig } from '../../types.ts'; type ResolvedAddress = { address: string; family: number; }; /** * Upper bound on a client_id length, shared by every entry point that uses a * caller-supplied client_id as a lookup or rate-limiter map key. Same * defense-in-depth family as the repo's 2048-char request-path cap. */ export declare const MAX_CLIENT_ID_LENGTH = 2048; /** * Thrown when CIMD resolution fails due to a client-side issue (bad URL, * invalid document, unsupported auth method, allowedHosts policy). * Callers should surface this as the given `oauthError` with the `message` * as the `error_description`. Throttle rejections additionally carry a * `statusCode` (429) and `retryAfterSeconds` so callers can emit a proper * `Retry-After` instead of a misleading auth error. */ export declare class CimdClientError extends Error { readonly oauthError: string; readonly statusCode?: number; readonly retryAfterSeconds?: number; constructor(oauthError: string, message: string, options?: { cause?: unknown; statusCode?: number; retryAfterSeconds?: number; }); } export declare let _dnsLookup: (hostname: string, options: { all: true; }) => Promise>; export declare function _setDnsLookup(fn: ((hostname: string, options: { all: true; }) => Promise>) | null): void; /** * Minimal response shape the resolver consumes. The production `_fetch` * (`pinnedHttpsFetch`) returns this; tests stub it directly. */ export type CimdResponse = { status: number; headers: { get(name: string): string | null; }; body: { getReader(): ReadableStreamDefaultReader; } | null; }; export type CimdFetchInit = { headers: Record; signal: AbortSignal; /** * Pre-validated addresses from the SSRF gate. The connection is PINNED to * these (no re-resolution at connect time), which closes the DNS-rebinding * TOCTOU: the socket connects to exactly the address that passed the gate, * while the hostname is still used for TLS SNI and certificate validation. */ pinnedAddresses: ResolvedAddress[]; }; export type CimdFetch = (url: string, init: CimdFetchInit) => Promise; export declare let _fetch: CimdFetch; export declare function _setFetch(fn: CimdFetch | null): void; /** Clear the CIMD cache AND the per-URL fetch limiter (for testing) — tests * that loop many resolution attempts against one URL rely on the limiter * resetting alongside the cache. @internal */ export declare function _clearCimdCache(): void; /** * Return true when `clientId` has the shape of a CIMD client_id: * - https scheme, written in canonical lowercase `https://` form (the * document's client_id must string-match the URL exactly, so * non-canonical scheme spellings can never validate anyway) * - Non-root path (pathname !== '' and pathname !== '/') * - No dot path segments, raw or percent-encoded * - No userinfo (username / password) * - No fragment * - No query string (the draft says SHOULD NOT; enforced here — a dynamic * server could otherwise mint unlimited exact-match client_id aliases of * one document by echoing query variants) * - Host is NOT an IP literal (IPv4 dotted-quad or IPv6 [bracket] form) * * This is a structural check only — no network I/O, no policy enforcement. * Policy checks (allowedHosts, SSRF gate) happen inside `resolveCimdClient`. */ export declare function isCimdClientId(clientId: string): boolean; /** * Resolve a CIMD client_id by fetching and validating its metadata document. * * Returns a synthesized `MCPClientRecord` (with `_cimd: true`) on success. * Returns `null` when the URL's host is not in `allowedHosts` (treated as * "not found" — avoids leaking whether the host would be valid). * Throws `CimdClientError` for client-side validation failures (including * every DNS-gate rejection — see `DNS_GATE_REJECTION`). * Throws `Error` for server-side failures (network, timeout). * * Successful results are cached per URL per process (LRU-bounded); failures * are never cached (the CIMD draft forbids it). */ export declare function resolveCimdClient(clientId: string, cimdConfig: MCPClientIdMetadataDocumentsConfig | undefined, allowedRedirectUriHosts?: string[], logger?: Logger): Promise; /** * Resolve a client by client_id: * - URL-shaped client_ids (isCimdClientId) → CIMD resolution. * - Everything else → DCR lookup via MCPClientStore. * * Returns `null` when the client is not found (either path). * Throws `CimdClientError` for CIMD validation failures. * Throws `Error` for server-side failures (DB or network). */ export declare function resolveClient(clientId: string, mcpConfig: MCPConfig | undefined, logger?: Logger): Promise; export {};