flagon-io/g1t

public

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

g1t/packages/contracts/src/oauth.ts

107 lines3,780 bytesCodeBlame
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 (
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
74export 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 */
92export 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}