Skip to content

Commit

sudo: Access service tokens listed in STAFF_SERVICE_TOKENS are staff, recorded as <name>@service.g1t.sh

claude-sudo acts as claude@service.g1t.sh. scripts/ops/sudo.mjs reads pages and posts forms with it.

syntaqxcommitted Parentb395f99Browse files
6 files+163−80/6 viewed
+14−0
195195 JWT itself (RS256 against the team's published keys, audience, issuer,
196196 expiry), then requires its email to be in `STAFF_EMAILS`. That email is
197197 who every change is recorded as. See `app/lib/access.ts`.
198+ **Service tokens.** A program (Claude, working without a browser) signs
199+ in with an Access service token instead: it sends `CF-Access-Client-Id`
200+ and `CF-Access-Client-Secret`, the Access application has a policy with
201+ the **Service Auth** action that includes the token, and Access sends
202+ sudo a JWT with no email whose `common_name` is the token's client id.
203+ sudo lets it in only if that client id is in `STAFF_SERVICE_TOKENS`
204+ (`<client id>=<name>`, comma separated, in `wrangler.jsonc`), after the
205+ same signature, audience, issuer and expiry checks, and records it as
206+ `<name>@service.g1t.sh`: `claude@service.g1t.sh` for
207+ `claude-sudo`. Its secret lives in `.credentials/sudo-service-token.json`
208+ and is used by `scripts/ops/sudo.mjs`, which sends sudo's own `Origin`
209+ with each POST so the same-origin check applies to it as to a browser.
210+ To take its access away, remove its entry here, or delete or revoke the
211+ token in Zero Trust.
198212 3. **It fails closed.** Until `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` and
199213 `STAFF_EMAILS` are all set, every request gets a 403 saying sudo is not
200214 configured.
+39−3
77 clearKeyCache,
88 isSameOrigin,
99 isStaff,
10+ parseServiceTokens,
1011 parseStaff,
1112 readSettings,
1213 verifyAccessJwt,
1415
1516 const TEAM = "g1t.cloudflareaccess.com";
1617 const AUD = "a".repeat(64);
17−const SETTINGS: AccessSettings = { teamDomain: TEAM, aud: AUD, staff: ["owner@g1t.sh"] };
18+const CLIENT = "434297ede60761875e84742d0486cf27.access";
19+const SETTINGS: AccessSettings = { teamDomain: TEAM, aud: AUD, staff: ["owner@g1t.sh"], services: new Map([[CLIENT, "claude"]]) };
1820 const NOW = Date.UTC(2026, 9, 4, 12, 0, 0);
1921 const NOW_SECONDS = Math.floor(NOW / 1000);
2022 const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
151153 assert.deepEqual(result, { ok: false, reason: "not staff", email: "someone@example.com" });
152154 });
153155
154−test("a service token, which has no email, is refused", async () => {
156+test("a token with neither an email nor a client id is refused", async () => {
155157 const result = await authorize(request(await sign(claims({ email: undefined }))), SETTINGS, { fetcher, now: NOW });
156158 assert.deepEqual(result, { ok: false, reason: "token has no email" });
157159 });
158160
161+test("a listed service token is let in as its name at service.g1t.sh", async () => {
162+ const token = await sign(claims({ email: undefined, sub: "", common_name: CLIENT }));
163+ const result = await authorize(request(token), SETTINGS, { fetcher, now: NOW });
164+ assert.deepEqual(result, { ok: true, email: "claude@service.g1t.sh" });
165+});
166+
167+test("a service token not listed is refused", async () => {
168+ const token = await sign(claims({ email: undefined, common_name: "ffffffffffffffffffffffffffffffff.access" }));
169+ const result = await authorize(request(token), SETTINGS, { fetcher, now: NOW });
170+ assert.deepEqual(result, { ok: false, reason: "service token not staff" });
171+});
172+
173+test("a listed service token still needs a valid signature and audience", async () => {
174+ const forged = await sign(claims({ email: undefined, common_name: CLIENT }), { key: stranger.privateKey });
175+ assert.deepEqual(await authorize(request(forged), SETTINGS, { fetcher, now: NOW }), { ok: false, reason: "bad signature" });
176+ const elsewhere = await sign(claims({ email: undefined, common_name: CLIENT, aud: ["b".repeat(64)] }));
177+ assert.deepEqual(await authorize(request(elsewhere), SETTINGS, { fetcher, now: NOW }), { ok: false, reason: "wrong audience" });
178+});
179+
180+test("an email wins over a client id on the same token", async () => {
181+ const token = await sign(claims({ email: "someone@example.com", common_name: CLIENT }));
182+ const result = await authorize(request(token), SETTINGS, { fetcher, now: NOW });
183+ assert.deepEqual(result, { ok: false, reason: "not staff", email: "someone@example.com" });
184+});
185+
186+test("service tokens are read as client id to name, dropping malformed entries", () => {
187+ assert.deepEqual(
188+ parseServiceTokens(` ${CLIENT}=claude , short.access=x, abcdefabcdefabcdef.access=Bad Name, abcdefabcdefabcdef.access=`),
189+ new Map([[CLIENT, "claude"]]),
190+ );
191+ assert.deepEqual(parseServiceTokens(""), new Map());
192+});
193+
159194 test("staff emails match without regard to case", async () => {
160195 const result = await authorize(request(await sign(claims({ email: "Owner@G1T.sh" }))), SETTINGS, { fetcher, now: NOW });
161196 assert.deepEqual(result, { ok: true, email: "owner@g1t.sh" });
188223
189224 test("sudo is closed until every setting is given", () => {
190225 const full = { ACCESS_TEAM_DOMAIN: "https://g1t.cloudflareaccess.com/", ACCESS_AUD: AUD, STAFF_EMAILS: "A@g1t.sh, b@g1t.sh" };
191− assert.deepEqual(readSettings(full), { teamDomain: TEAM, aud: AUD, staff: ["a@g1t.sh", "b@g1t.sh"] });
226+ assert.deepEqual(readSettings(full), { teamDomain: TEAM, aud: AUD, staff: ["a@g1t.sh", "b@g1t.sh"], services: new Map() });
227+ assert.deepEqual(readSettings({ ...full, STAFF_SERVICE_TOKENS: `${CLIENT}=claude` })?.services, new Map([[CLIENT, "claude"]]));
192228 assert.equal(readSettings({ ...full, ACCESS_AUD: "" }), null);
193229 assert.equal(readSettings({ ...full, ACCESS_TEAM_DOMAIN: "" }), null);
194230 assert.equal(readSettings({ ...full, STAFF_EMAILS: " , " }), null);
+36−4
2828 aud: string;
2929 /** Lowercased staff emails, and `@domain` for everyone at a domain. */
3030 staff: string[];
31+ /**
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>;
3137 };
3238
3339 export type AccessEnv = {
3440 ACCESS_TEAM_DOMAIN?: string;
3541 ACCESS_AUD?: string;
3642 STAFF_EMAILS?: string;
43+ /** `<client id>=<name>`, comma separated: service tokens let in as staff. */
44+ STAFF_SERVICE_TOKENS?: string;
3745 };
3846
47+/** The domain a service token's staff identity is recorded under. */
48+export 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+ */
54+export 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+
3963 /**
4064 * Reads the settings, or null when any is missing or malformed: sudo then
4165 * refuses everything rather than guess.
4569 const aud = (env.ACCESS_AUD ?? "").trim();
4670 const staff = parseStaff(env.STAFF_EMAILS ?? "");
4771 if (!teamDomain || !/^[A-Za-z0-9]{16,128}$/.test(aud) || staff.length === 0) return null;
48− return { teamDomain, aud, staff };
72+ return { teamDomain, aud, staff, services: parseServiceTokens(env.STAFF_SERVICE_TOKENS ?? "") };
4973 }
5074
5175 /**
89113 iat?: number;
90114 sub?: string;
91115 email?: string;
116+ /** A service token's client id, on tokens with no email. */
117+ common_name?: string;
92118 [claim: string]: unknown;
93119 };
94120
232258
233259 /**
234260 * Whether a request comes from g1t staff, through Access: a valid token
235− * whose email is on the staff list. Service tokens carry no email and are
236− * refused.
261+ * 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.
237264 */
238265 export async function authorize(
239266 request: Request,
245272 const verified = await verifyAccessJwt(token, settings, options);
246273 if (!verified.ok) return verified;
247274 const email = typeof verified.claims.email === "string" ? verified.claims.email.trim().toLowerCase() : "";
248− if (!email) return { ok: false, reason: "token has no email" };
275+ 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+ }
249281 if (!isStaff(email, settings.staff)) return { ok: false, reason: "not staff", email };
250282 return { ok: true, email };
251283 }
+1−0
1414 ACCESS_TEAM_DOMAIN: string;
1515 ACCESS_AUD: string;
1616 STAFF_EMAILS: string;
17+ STAFF_SERVICE_TOKENS?: string;
1718 }
1819 }
1920 interface Env extends Cloudflare.Env {}
+5−1
3636 "ACCESS_AUD": "5479fcf84130fc7ae68c207ae69a81b2aa17d97714443c6f1dddd058c530b8cf",
3737 // Who may use sudo, comma separated. Access lets them in; this
3838 // decides again, in case the Access policy is ever widened.
39− "STAFF_EMAILS": "syntaqx@gmail.com, @g1t.sh"
39+ "STAFF_EMAILS": "syntaqx@gmail.com, @g1t.sh",
40+ // Access service tokens let in as staff: `<client id>=<name>`, comma
41+ // separated. Each acts, and is recorded, as `<name>@service.g1t.sh`.
42+ // The Access application needs a Service Auth policy including it.
43+ "STAFF_SERVICE_TOKENS": "434297ede60761875e84742d0486cf27.access=claude"
4044 },
4145 "observability": { "enabled": true },
4246 "upload_source_maps": true
+68−0
1+#!/usr/bin/env node
2+// Uses sudo (https://sudo.g1t.sh) without a browser, through the Access
3+// service token in .credentials/sudo-service-token.json. sudo records it
4+// as claude@service.g1t.sh (apps/sudo/README.md, "Service tokens").
5+//
6+// node scripts/ops/sudo.mjs get /costs
7+// node scripts/ops/sudo.mjs post /costs intent=run
8+// node scripts/ops/sudo.mjs get /users/g1t-reviewer --html
9+//
10+// Prints the status, where a redirect goes, and the page as text (or the
11+// raw HTML with --html). POSTs are form-encoded, the way sudo's pages send
12+// them, with sudo's own Origin. The secret is never printed.
13+import { readFileSync } from "node:fs";
14+
15+const SUDO = "https://sudo.g1t.sh";
16+const CREDENTIALS = new URL("../../.credentials/sudo-service-token.json", import.meta.url);
17+
18+/** The headers that get a request past Access. */
19+export function accessHeaders() {
20+ const { client_id, client_secret } = JSON.parse(readFileSync(CREDENTIALS, "utf8"));
21+ if (!client_id || !client_secret) throw new Error("sudo-service-token.json needs client_id and client_secret");
22+ return { "CF-Access-Client-Id": client_id, "CF-Access-Client-Secret": client_secret };
23+}
24+
25+/** A page's readable text: no scripts, styles or tags, one line per block. */
26+export function pageText(html) {
27+ return html
28+ .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "")
29+ .replace(/<(br|\/p|\/div|\/li|\/tr|\/h[1-6]|\/section|\/summary)\b[^>]*>/gi, "\n")
30+ .replace(/<\/t[dh]>/gi, "\t")
31+ .replace(/<[^>]+>/g, "")
32+ .replace(/&nbsp;/g, " ")
33+ .replace(/&amp;/g, "&")
34+ .replace(/&lt;/g, "<")
35+ .replace(/&gt;/g, ">")
36+ .replace(/&quot;/g, '"')
37+ .replace(/&#39;/g, "'")
38+ .split("\n")
39+ .map((line) => line.replace(/[ \t]+/g, " ").trim())
40+ .filter(Boolean)
41+ .join("\n");
42+}
43+
44+/** GETs or POSTs `path`; `fields` are the form's name=value pairs. */
45+export async function sudo(method, path, fields = []) {
46+ const headers = { ...accessHeaders() };
47+ let body;
48+ if (method === "POST") {
49+ headers.origin = SUDO;
50+ headers["content-type"] = "application/x-www-form-urlencoded";
51+ body = new URLSearchParams(fields.map((field) => field.split(/=(.*)/s).slice(0, 2))).toString();
52+ }
53+ const response = await fetch(new URL(path, SUDO), { method, headers, body, redirect: "manual" });
54+ return { status: response.status, location: response.headers.get("location"), html: await response.text() };
55+}
56+
57+if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/ops/sudo.mjs")) {
58+ const [verb = "get", path = "/", ...rest] = process.argv.slice(2);
59+ const html = rest.includes("--html");
60+ const fields = rest.filter((arg) => arg !== "--html");
61+ const result = await sudo(verb.toUpperCase() === "POST" ? "POST" : "GET", path, fields);
62+ console.log(`${result.status}${result.location ? ` -> ${result.location}` : ""}`);
63+ if (result.location?.includes("cloudflareaccess.com")) {
64+ console.log("Access did not accept the service token: the sudo application needs a Service Auth policy that includes it.");
65+ } else {
66+ console.log(html ? result.html : pageText(result.html));
67+ }
68+}