pr_01m47d15m3e54sn21z27rpy5n9/packages/contracts/src/oauth.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| OAuth 2.1 sign-in for MCP clients and other applications | 1 | /** |
| 2 | * OAuth clients. A client is not stored anywhere: its id is its name and | |
| 3 | * redirect addresses, encoded. Registering one therefore writes nothing, | |
| 4 | * and anyone holding a client id can read what it claims to be. | |
| 5 | * | |
| 6 | * That is safe because a public client has no secret to protect: what | |
| 7 | * stops a stolen authorization code being used is PKCE, and what a person | |
| 8 | * approves is the redirect address shown to them. | |
| 9 | */ | |
| 10 | export type OAuthClient = { | |
| 11 | /** Shown to the person approving, e.g. "Claude Code". */ | |
| 12 | name: string; | |
| 13 | redirectUris: string[]; | |
| 14 | }; | |
| 15 | ||
| 16 | const PREFIX = "g1c_"; | |
| 17 | const MAX_NAME_CHARS = 80; | |
| 18 | const MAX_REDIRECTS = 5; | |
| 19 | const MAX_URI_CHARS = 500; | |
| 20 | /** Schemes that run or expose content instead of opening an application. */ | |
| 21 | const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "file:", "blob:", "vbscript:", "about:"]); | |
| 22 | const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); | |
| 23 | ||
| 24 | function toBase64Url(text: string): string { | |
| 25 | const bytes = new TextEncoder().encode(text); | |
| 26 | let binary = ""; | |
| 27 | for (const byte of bytes) binary += String.fromCharCode(byte); | |
| 28 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 29 | } | |
| 30 | ||
| 31 | function fromBase64Url(encoded: string): string { | |
| 32 | const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/")); | |
| 33 | return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))); | |
| 34 | } | |
| 35 | ||
| 36 | function parse(uri: string): URL | null { | |
| 37 | try { | |
| 38 | return new URL(uri); | |
| 39 | } catch { | |
| 40 | return null; | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | function isLoopback(url: URL): boolean { | |
| 45 | return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname); | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Whether an application may ask to be redirected here: an https address, | |
| 50 | * http on this machine only, or an application's own scheme. | |
| 51 | */ | |
| 52 | export function isValidRedirectUri(uri: string): boolean { | |
| 53 | const url = parse(uri); | |
| 54 | if (!url || uri.length > MAX_URI_CHARS || url.hash) return false; | |
| 55 | if (url.protocol === "https:") return true; | |
| 56 | if (url.protocol === "http:") return isLoopback(url); | |
| 57 | return !FORBIDDEN_SCHEMES.has(url.protocol); | |
| 58 | } | |
| 59 | ||
| 60 | /** The client id for a client, or null if what it asks for is not allowed. */ | |
| 61 | export function encodeOAuthClient(client: OAuthClient): string | null { | |
| 62 | const name = client.name.trim().slice(0, MAX_NAME_CHARS) || "An application"; | |
| 63 | const { redirectUris } = client; | |
| 64 | if ( | |
| 65 | redirectUris.length === 0 || | |
| 66 | redirectUris.length > MAX_REDIRECTS || | |
| 67 | !redirectUris.every(isValidRedirectUri) | |
| 68 | ) { | |
| 69 | return null; | |
| 70 | } | |
| 71 | return PREFIX + toBase64Url(JSON.stringify({ n: name, r: redirectUris })); | |
| 72 | } | |
| 73 | ||
| 74 | export function decodeOAuthClient(clientId: string): OAuthClient | null { | |
| 75 | if (!clientId.startsWith(PREFIX)) return null; | |
| 76 | try { | |
| 77 | const { n, r } = JSON.parse(fromBase64Url(clientId.slice(PREFIX.length))); | |
| 78 | if (typeof n !== "string" || !Array.isArray(r)) return null; | |
| 79 | const client = { name: n, redirectUris: r.map(String) }; | |
| 80 | // Decoding applies the same rules as encoding, so a hand-made id gains nothing. | |
| 81 | return encodeOAuthClient(client) ? client : null; | |
| 82 | } catch { | |
| 83 | return null; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Whether `uri` is one of the client's redirect addresses. Addresses must | |
| 89 | * match exactly, except that an application listening on this machine may | |
| 90 | * use any port, since it cannot know which will be free. | |
| 91 | */ | |
| 92 | export function isRegisteredRedirect(client: OAuthClient, uri: string): boolean { | |
| 93 | const asked = parse(uri); | |
| 94 | if (!asked) return false; | |
| 95 | return client.redirectUris.some((registered) => { | |
| 96 | if (registered === uri) return true; | |
| 97 | const known = parse(registered); | |
| 98 | return ( | |
| 99 | known !== null && | |
| 100 | isLoopback(known) && | |
| 101 | isLoopback(asked) && | |
| 102 | known.hostname === asked.hostname && | |
| 103 | known.pathname === asked.pathname && | |
| 104 | known.search === asked.search | |
| 105 | ); | |
| 106 | }); | |
| 107 | } |