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.

g1t.sh
302 linesCodeBlameRaw

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.

Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure1/**
2 * Who is asking: sudo sits behind Cloudflare Access, and checks Access's
3 * work itself on every request. Access signs a JWT for each request it
4 * lets through and sends it in `Cf-Access-Jwt-Assertion`; this verifies
5 * its RS256 signature against the team's published keys, its audience,
6 * issuer and lifetime, and then that its email belongs to g1t staff.
7 *
8 * Plain Web Crypto, no dependencies and no Workers imports, so the tests
9 * run it under Node as it runs on Workers.
10 */
11
12/** Where sudo is served; the only origin a change may be posted from. */
13export const ORIGIN = "https://sudo.g1t.sh";
14
15/** How far clocks may disagree, in seconds. */
16const LEEWAY_SECONDS = 30;
17/** How long the team's keys are trusted before they are fetched again. */
18const JWKS_TTL_MS = 5 * 60_000;
19/** A token signed by a key not seen yet refetches the keys, at most this often. */
20const JWKS_REFETCH_MS = 30_000;
21/** Access tokens are a few kilobytes; anything far larger is not one. */
22const MAX_TOKEN_LENGTH = 16_384;
23
24export type AccessSettings = {
25 /** The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. */
26 teamDomain: string;
27 /** The Access application's Audience (AUD) tag. */
28 aud: string;
29 /** Lowercased staff emails, and `@domain` for everyone at a domain. */
30 staff: string[];
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh31 /**
32 * Access service tokens let in as staff, by client id: the name each
33 * acts as. A service token's JWT has no email, only `common_name`, the
34 * token's client id; it is recorded as `<name>@service.g1t.sh`.
35 */
36 services: Map<string, string>;
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure37};
38
39export type AccessEnv = {
40 ACCESS_TEAM_DOMAIN?: string;
41 ACCESS_AUD?: string;
42 STAFF_EMAILS?: string;
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh43 /** `<client id>=<name>`, comma separated: service tokens let in as staff. */
44 STAFF_SERVICE_TOKENS?: string;
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure45};
46
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh47/** The domain a service token's staff identity is recorded under. */
48export const SERVICE_STAFF_DOMAIN = "service.g1t.sh";
49
50/**
51 * `abc.access=claude, def.access=ci` as client id to name. Entries without
52 * a name, or whose name is not a plain lowercase word, are dropped.
53 */
54export function parseServiceTokens(raw: string): Map<string, string> {
55 const out = new Map<string, string>();
56 for (const entry of raw.split(",")) {
57 const [id = "", name = ""] = entry.split("=").map((part) => part.trim());
58 if (/^[A-Za-z0-9]{16,64}\.access$/.test(id) && /^[a-z][a-z0-9-]{0,31}$/.test(name)) out.set(id, name);
59 }
60 return out;
61}
62
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure63/**
64 * Reads the settings, or null when any is missing or malformed: sudo then
65 * refuses everything rather than guess.
66 */
67export function readSettings(env: AccessEnv): AccessSettings | null {
68 const teamDomain = normalizeTeamDomain(env.ACCESS_TEAM_DOMAIN ?? "");
69 const aud = (env.ACCESS_AUD ?? "").trim();
70 const staff = parseStaff(env.STAFF_EMAILS ?? "");
71 if (!teamDomain || !/^[A-Za-z0-9]{16,128}$/.test(aud) || staff.length === 0) return null;
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh72 return { teamDomain, aud, staff, services: parseServiceTokens(env.STAFF_SERVICE_TOKENS ?? "") };
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure73}
74
75/**
76 * `g1t.cloudflareaccess.com`, given with or without its scheme. Only a
77 * Cloudflare Access team domain is accepted, since that is where the
78 * signing keys are fetched from.
79 */
80export function normalizeTeamDomain(raw: string): string | null {
81 const host = raw
82 .trim()
83 .toLowerCase()
84 .replace(/^https:\/\//, "")
85 .replace(/\/+$/, "");
86 return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cloudflareaccess\.com$/.test(host) ? host : null;
87}
88
89/**
90 * The staff list: emails, and `@g1t.sh` for everyone with an address at
91 * that domain. Access proves the address belongs to whoever signed in.
92 */
93export function parseStaff(raw: string): string[] {
94 return raw
95 .split(/[,\s]+/)
96 .map((email) => email.trim().toLowerCase())
97 .filter((email) => /^[^@\s]*@[a-z0-9.-]+\.[a-z]{2,}$/.test(email));
98}
99
100/** Whether a signed-in email is on the staff list: exactly, or by its whole domain. */
101export function isStaff(email: string, staff: string[]): boolean {
102 const at = email.lastIndexOf("@");
103 if (at <= 0 || email.indexOf("@") !== at) return false;
104 const domain = email.slice(at);
105 return staff.some((entry) => (entry.startsWith("@") ? entry === domain : entry === email));
106}
107
108export type AccessClaims = {
109 iss: string;
110 aud: string | string[];
111 exp: number;
112 nbf?: number;
113 iat?: number;
114 sub?: string;
115 email?: string;
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh116 /** A service token's client id, on tokens with no email. */
117 common_name?: string;
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure118 [claim: string]: unknown;
119};
120
121export type Verified = { ok: true; claims: AccessClaims } | { ok: false; reason: string };
122
123export type VerifyOptions = {
124 /** Fetches the team's keys; the global `fetch` unless a test gives another. */
125 fetcher?: (url: string) => Promise<Response>;
126 /** Milliseconds since the epoch. */
127 now?: number;
128};
129
130type KeySet = { keys: Map<string, CryptoKey>; fetchedAt: number };
131
132/** The team's signing keys, by team domain, kept briefly in memory. */
133const keySets = new Map<string, KeySet>();
134
135/** Forgets the cached keys. For tests. */
136export function clearKeyCache(): void {
137 keySets.clear();
138}
139
140const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
141
142async function fetchKeySet(
143 teamDomain: string,
144 fetcher: (url: string) => Promise<Response>,
145 now: number,
146): Promise<KeySet> {
147 const response = await fetcher(`https://${teamDomain}/cdn-cgi/access/certs`);
148 if (!response.ok) throw new Error(`Access keys answered ${response.status}`);
149 const body = (await response.json()) as { keys?: unknown };
150 const keys = new Map<string, CryptoKey>();
151 for (const jwk of Array.isArray(body.keys) ? body.keys : []) {
152 if (!jwk || typeof jwk !== "object") continue;
153 const { kid, kty, n, e, alg, use } = jwk as Record<string, unknown>;
154 if (typeof kid !== "string" || kty !== "RSA" || typeof n !== "string" || typeof e !== "string") continue;
155 if (alg !== undefined && alg !== "RS256") continue;
156 if (use !== undefined && use !== "sig") continue;
157 try {
158 keys.set(kid, await crypto.subtle.importKey("jwk", { kty, n, e }, RSA, false, ["verify"]));
159 } catch {
160 // A key that does not import is skipped; tokens signed by it fail.
161 }
162 }
163 return { keys, fetchedAt: now };
164}
165
166/** The key a token names, fetching the team's keys when they are stale or it is new. */
167async function keyFor(
168 teamDomain: string,
169 kid: string,
170 fetcher: (url: string) => Promise<Response>,
171 now: number,
172): Promise<CryptoKey | null> {
173 let set = keySets.get(teamDomain);
174 const stale = !set || now - set.fetchedAt >= JWKS_TTL_MS;
175 const unknown = set && !set.keys.has(kid) && now - set.fetchedAt >= JWKS_REFETCH_MS;
176 if (stale || unknown) {
177 try {
178 set = await fetchKeySet(teamDomain, fetcher, now);
179 keySets.set(teamDomain, set);
180 } catch {
181 // Keys that could not be refreshed are not trusted past their time.
182 if (stale) {
183 keySets.delete(teamDomain);
184 return null;
185 }
186 }
187 }
188 return set?.keys.get(kid) ?? null;
189}
190
191function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> {
192 const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
193 const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
194 const bytes = new Uint8Array(binary.length);
195 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
196 return bytes;
197}
198
199function decodeJson(segment: string): Record<string, unknown> | null {
200 try {
201 const value: unknown = JSON.parse(new TextDecoder().decode(base64UrlBytes(segment)));
202 return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
203 } catch {
204 return null;
205 }
206}
207
208/** Verifies an Access JWT: signature, algorithm, issuer, audience and lifetime. */
209export async function verifyAccessJwt(
210 token: string,
211 settings: Pick<AccessSettings, "teamDomain" | "aud">,
212 options: VerifyOptions = {},
213): Promise<Verified> {
214 const now = options.now ?? Date.now();
215 const fetcher = options.fetcher ?? ((url: string) => fetch(url));
216
217 if (token.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "token too long" };
218 const parts = token.split(".");
219 if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part))) {
220 return { ok: false, reason: "malformed token" };
221 }
222 const [encodedHeader, encodedPayload, encodedSignature] = parts;
223 const header = decodeJson(encodedHeader);
224 const payload = decodeJson(encodedPayload);
225 if (!header || !payload) return { ok: false, reason: "malformed token" };
226 // Only RS256: never `none`, and never a symmetric algorithm keyed with a public key.
227 if (header.alg !== "RS256") return { ok: false, reason: "unexpected algorithm" };
228 if (typeof header.kid !== "string" || !header.kid) return { ok: false, reason: "no key id" };
229
230 const key = await keyFor(settings.teamDomain, header.kid, fetcher, now);
231 if (!key) return { ok: false, reason: "unknown signing key" };
232
233 let signature: Uint8Array<ArrayBuffer>;
234 try {
235 signature = base64UrlBytes(encodedSignature);
236 } catch {
237 return { ok: false, reason: "malformed signature" };
238 }
239 const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
240 const valid = await crypto.subtle.verify(RSA, key, signature, signed);
241 if (!valid) return { ok: false, reason: "bad signature" };
242
243 // Only now that the signature holds do the claims mean anything.
244 if (payload.iss !== `https://${settings.teamDomain}`) return { ok: false, reason: "wrong issuer" };
245 const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
246 if (!audiences.includes(settings.aud)) return { ok: false, reason: "wrong audience" };
247 const seconds = Math.floor(now / 1000);
248 if (typeof payload.exp !== "number") return { ok: false, reason: "no expiry" };
249 if (seconds >= payload.exp + LEEWAY_SECONDS) return { ok: false, reason: "expired" };
250 if (payload.nbf !== undefined) {
251 if (typeof payload.nbf !== "number") return { ok: false, reason: "malformed nbf" };
252 if (seconds + LEEWAY_SECONDS < payload.nbf) return { ok: false, reason: "not yet valid" };
253 }
254 return { ok: true, claims: payload as AccessClaims };
255}
256
257export type Authorized = { ok: true; email: string } | { ok: false; reason: string; email?: string };
258
259/**
260 * Whether a request comes from g1t staff, through Access: a valid token
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh261 * whose email is on the staff list, or a service token's (no email, its
262 * client id as `common_name`) listed in STAFF_SERVICE_TOKENS, which acts as
263 * `<name>@service.g1t.sh`. Any other service token is refused.
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure264 */
265export async function authorize(
266 request: Request,
267 settings: AccessSettings,
268 options: VerifyOptions = {},
269): Promise<Authorized> {
270 const token = request.headers.get("cf-access-jwt-assertion");
271 if (!token) return { ok: false, reason: "no Access token" };
272 const verified = await verifyAccessJwt(token, settings, options);
273 if (!verified.ok) return verified;
274 const email = typeof verified.claims.email === "string" ? verified.claims.email.trim().toLowerCase() : "";
sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh275 if (!email) {
276 const client = typeof verified.claims.common_name === "string" ? verified.claims.common_name.trim() : "";
277 const name = client ? settings.services.get(client) : undefined;
278 if (!name) return { ok: false, reason: client ? "service token not staff" : "token has no email" };
279 return { ok: true, email: `${name}@${SERVICE_STAFF_DOMAIN}` };
280 }
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure281 if (!isStaff(email, settings.staff)) return { ok: false, reason: "not staff", email };
282 return { ok: true, email };
283}
284
285/**
286 * Whether a change was posted from sudo's own pages: the browser's
287 * `Origin`, or failing that its `Referer`, must be sudo's. A request that
288 * says neither is refused.
289 */
290export function isSameOrigin(request: Request): boolean {
291 const site = request.headers.get("sec-fetch-site");
292 if (site !== null && site !== "same-origin") return false;
293 const origin = request.headers.get("origin");
294 if (origin !== null) return origin === ORIGIN;
295 const referer = request.headers.get("referer");
296 if (!referer) return false;
297 try {
298 return new URL(referer).origin === ORIGIN;
299 } catch {
300 return false;
301 }
302}

This file's history is long; its oldest lines are credited to the oldest commit read.