Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

103 linesCodeBlameRaw
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 */
10export type OAuthClient = {
11 /** Shown to the person approving, e.g. "Claude Code". */
12 name: string;
13 redirectUris: string[];
14};
15
16const PREFIX = "g1c_";
17const MAX_NAME_CHARS = 80;
18const MAX_REDIRECTS = 5;
19const MAX_URI_CHARS = 500;
20/** Schemes that run or expose content instead of opening an application. */
21const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "file:", "blob:", "vbscript:", "about:"]);
22const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
23
24function 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
31function 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
36function parse(uri: string): URL | null {
37 try {
38 return new URL(uri);
39 } catch {
40 return null;
41 }
42}
43
44function 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 */
52export 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. */
61export 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 (redirectUris.length === 0 || redirectUris.length > MAX_REDIRECTS || !redirectUris.every(isValidRedirectUri)) {
65 return null;
66 }
67 return PREFIX + toBase64Url(JSON.stringify({ n: name, r: redirectUris }));
68}
69
70export function decodeOAuthClient(clientId: string): OAuthClient | null {
71 if (!clientId.startsWith(PREFIX)) return null;
72 try {
73 const { n, r } = JSON.parse(fromBase64Url(clientId.slice(PREFIX.length)));
74 if (typeof n !== "string" || !Array.isArray(r)) return null;
75 const client = { name: n, redirectUris: r.map(String) };
76 // Decoding applies the same rules as encoding, so a hand-made id gains nothing.
77 return encodeOAuthClient(client) ? client : null;
78 } catch {
79 return null;
80 }
81}
82
83/**
84 * Whether `uri` is one of the client's redirect addresses. Addresses must
85 * match exactly, except that an application listening on this machine may
86 * use any port, since it cannot know which will be free.
87 */
88export function isRegisteredRedirect(client: OAuthClient, uri: string): boolean {
89 const asked = parse(uri);
90 if (!asked) return false;
91 return client.redirectUris.some((registered) => {
92 if (registered === uri) return true;
93 const known = parse(registered);
94 return (
95 known !== null &&
96 isLoopback(known) &&
97 isLoopback(asked) &&
98 known.hostname === asked.hostname &&
99 known.pathname === asked.pathname &&
100 known.search === asked.search
101 );
102 });
103}