g1t/packages/contracts/src/oauth.ts
| 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 | } |