import { env } from "cloudflare:workers"; import { z } from "zod"; import { all, chunks, first, placeholders, run } from "../db"; import { randomId } from "../crypto"; import { enforceRateLimit, requestIp } from "../limits"; import { assertResolvesPublic } from "../net/resolve-guard"; const MAX_REDIRECT_URIS = 10; const CIMD_FETCH_TIMEOUT_MS = 5000; const CIMD_MAX_BYTES = 16 * 1024; const CIMD_REFRESH_MS = 24 * 60 * 60 * 1000; /** Unused RFC 7591 registrations are dropped after this; used ones after this long without a token issued (AUTH-A9). */ const DCR_UNUSED_TTL_MS = 24 * 60 * 60 * 1000; const DCR_IDLE_TTL_MS = 90 * 24 * 60 * 60 * 1000; const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]); const BLOCKED_SCHEMES = new Set(["javascript:", "data:", "file:", "vbscript:", "blob:", "about:", "ws:", "wss:"]); export type OAuthClient = { id: string; name: string; redirectUris: string[] }; type ClientRow = { id: string; name: string; redirect_uris: string; kind: "dcr" | "cimd"; created_at: number }; export class OAuthError extends Error { constructor( readonly error: string, message: string, readonly status = 400, ) { super(message); } } /** https anywhere, plain http only on loopback (RFC 8252), or a private-use scheme for native apps. */ function validRedirectUri(raw: string): boolean { let url: URL; try { url = new URL(raw); } catch { return false; } if (url.hash || BLOCKED_SCHEMES.has(url.protocol)) return false; if (url.protocol === "https:") return true; if (url.protocol === "http:") return LOOPBACK.has(url.hostname); return /^[a-z][a-z0-9+.-]*:$/.test(url.protocol); } /** Exact match, except a registered http loopback redirect may be requested on any port (RFC 8252 ยง7.3). */ export function redirectMatches(registered: string[], requested: string): boolean { if (registered.includes(requested)) return true; let target: URL; try { target = new URL(requested); } catch { return false; } if (target.protocol !== "http:" || !LOOPBACK.has(target.hostname) || target.username || target.password) return false; return registered.some((candidate) => { const url = URL.canParse(candidate) ? new URL(candidate) : null; return ( url !== null && url.protocol === "http:" && url.hostname === target.hostname && url.pathname === target.pathname && url.search === target.search ); }); } const registrationInput = z.object({ redirect_uris: z.array(z.string().max(2000)).min(1).max(MAX_REDIRECT_URIS), client_name: z.string().trim().max(100).optional(), token_endpoint_auth_method: z.string().optional(), grant_types: z.array(z.string()).optional(), application_type: z.enum(["web", "native"]).optional(), }); /** RFC 7591 dynamic registration. Only public clients (PKCE, no secret) are issued. */ export async function registerClient(request: Request): Promise { await enforceRateLimit("AUTH_LIMITER", `oauth-register:${requestIp(request)}`); let body: unknown; try { body = await request.json(); } catch { throw new OAuthError("invalid_client_metadata", "Body must be JSON"); } const parsed = registrationInput.safeParse(body); if (!parsed.success) throw new OAuthError("invalid_client_metadata", parsed.error.issues[0]?.message ?? "Invalid"); const uris = parsed.data.redirect_uris; if (!uris.every(validRedirectUri)) throw new OAuthError("invalid_redirect_uri", "Unsupported redirect_uri"); const id = `mcp_${randomId()}`; const name = parsed.data.client_name || "MCP client"; const now = Date.now(); await run( "INSERT INTO oauth_clients (id, name, redirect_uris, kind, created_at) VALUES (?, ?, ?, 'dcr', ?)", id, name, JSON.stringify(uris), now, ); return Response.json( { client_id: id, client_id_issued_at: Math.floor(now / 1000), client_name: name, redirect_uris: uris, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", application_type: parsed.data.application_type ?? "native", }, { status: 201, headers: { "cache-control": "no-store" } }, ); } /** * Cron: registrations nobody ever authorized, and clients idle for a long time, stop accumulating. No account is * visited: issuing or rotating a refresh token sets `last_used_at`, and refresh tokens expire well within the idle * window, so a client with a live grant is never stale. */ export async function sweepStaleOAuthClients(): Promise { const now = Date.now(); const stale = await all<{ id: string }>( `SELECT id FROM oauth_clients WHERE kind = 'dcr' AND ((last_used_at IS NULL AND created_at < ?) OR (last_used_at IS NOT NULL AND last_used_at < ?))`, now - DCR_UNUSED_TTL_MS, now - DCR_IDLE_TTL_MS, ); const doomed = stale.map((row) => row.id); for (const ids of chunks(doomed)) { await run(`DELETE FROM oauth_clients WHERE id IN (${placeholders(ids.length)})`, ...ids); } } const metadataDocument = z.object({ client_id: z.string(), client_name: z.string().max(100).optional(), redirect_uris: z.array(z.string().max(2000)).min(1).max(MAX_REDIRECT_URIS), }); /** * Client ID Metadata Documents: the client_id is an https URL serving the client's metadata. The fetch is * server-side and reachable by anyone, so (AUTH-A9) the URL must be a plain https origin on the default * port with a real path, must not be this forge, must resolve to public addresses, and must answer JSON. */ async function fetchMetadataDocument(clientId: string): Promise { const url = new URL(clientId); if ( url.protocol !== "https:" || url.hash || url.username || url.password || url.port !== "" || !/^\/[^/]/.test(url.pathname) ) { throw new OAuthError("invalid_client", "client_id metadata URL must be https on the default port with a path"); } if (url.hostname === new URL(env.PUBLIC_URL).hostname) { throw new OAuthError("invalid_client", "client_id metadata URL cannot point at this forge"); } try { await assertResolvesPublic(url); } catch { throw new OAuthError("invalid_client", "client_id metadata URL does not resolve to a public address"); } const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(CIMD_FETCH_TIMEOUT_MS), }).catch(() => null); if (!response?.ok) throw new OAuthError("invalid_client", "Could not fetch the client metadata document"); const contentType = (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase(); if (contentType !== "application/json") { await response.body?.cancel(); throw new OAuthError("invalid_client", "Client metadata document must be served as application/json"); } const text = await response.text(); if (text.length > CIMD_MAX_BYTES) throw new OAuthError("invalid_client", "Client metadata document is too large"); let raw: unknown; try { raw = JSON.parse(text); } catch { throw new OAuthError("invalid_client", "Client metadata document is not JSON"); } const parsed = metadataDocument.safeParse(raw); if (!parsed.success || parsed.data.client_id !== clientId || !parsed.data.redirect_uris.every(validRedirectUri)) { throw new OAuthError("invalid_client", "Client metadata document is invalid"); } return { id: clientId, name: parsed.data.client_name || url.hostname, redirectUris: parsed.data.redirect_uris }; } /** `ip` rate-limits metadata-document fetches, which an unauthenticated caller can trigger. */ export async function resolveClient(clientId: string, ip: string): Promise { const row = await first("SELECT * FROM oauth_clients WHERE id = ?", clientId); const fresh = row && (row.kind === "dcr" || Date.now() - row.created_at < CIMD_REFRESH_MS); if (row && fresh) return { id: row.id, name: row.name, redirectUris: JSON.parse(row.redirect_uris) }; if (!clientId.startsWith("https://")) throw new OAuthError("invalid_client", "Unknown client_id", 401); await enforceRateLimit("AUTH_LIMITER", `oauth-cimd:${ip}`); const client = await fetchMetadataDocument(clientId); await run( `INSERT INTO oauth_clients (id, name, redirect_uris, kind, created_at) VALUES (?, ?, ?, 'cimd', ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, redirect_uris = excluded.redirect_uris, created_at = excluded.created_at`, client.id, client.name, JSON.stringify(client.redirectUris), Date.now(), ); return client; }