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.
6 files+163−80/6 viewed
| 195 | 195 | JWT itself (RS256 against the team's published keys, audience, issuer, | |
| 196 | 196 | expiry), then requires its email to be in `STAFF_EMAILS`. That email is | |
| 197 | 197 | 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. | |
| 198 | 212 | 3. **It fails closed.** Until `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` and | |
| 199 | 213 | `STAFF_EMAILS` are all set, every request gets a 403 saying sudo is not | |
| 200 | 214 | configured. |
| 7 | 7 | clearKeyCache, | |
| 8 | 8 | isSameOrigin, | |
| 9 | 9 | isStaff, | |
| 10 | + | parseServiceTokens, | |
| 10 | 11 | parseStaff, | |
| 11 | 12 | readSettings, | |
| 12 | 13 | verifyAccessJwt, | |
| ⋯ | |||
| 14 | 15 | ||
| 15 | 16 | const TEAM = "g1t.cloudflareaccess.com"; | |
| 16 | 17 | 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"]]) }; | |
| 18 | 20 | const NOW = Date.UTC(2026, 9, 4, 12, 0, 0); | |
| 19 | 21 | const NOW_SECONDS = Math.floor(NOW / 1000); | |
| 20 | 22 | const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const; | |
| ⋯ | |||
| 151 | 153 | assert.deepEqual(result, { ok: false, reason: "not staff", email: "someone@example.com" }); | |
| 152 | 154 | }); | |
| 153 | 155 | ||
| 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 () => { | |
| 155 | 157 | const result = await authorize(request(await sign(claims({ email: undefined }))), SETTINGS, { fetcher, now: NOW }); | |
| 156 | 158 | assert.deepEqual(result, { ok: false, reason: "token has no email" }); | |
| 157 | 159 | }); | |
| 158 | 160 | ||
| 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 | + | ||
| 159 | 194 | test("staff emails match without regard to case", async () => { | |
| 160 | 195 | const result = await authorize(request(await sign(claims({ email: "Owner@G1T.sh" }))), SETTINGS, { fetcher, now: NOW }); | |
| 161 | 196 | assert.deepEqual(result, { ok: true, email: "owner@g1t.sh" }); | |
| ⋯ | |||
| 188 | 223 | ||
| 189 | 224 | test("sudo is closed until every setting is given", () => { | |
| 190 | 225 | 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"]])); | |
| 192 | 228 | assert.equal(readSettings({ ...full, ACCESS_AUD: "" }), null); | |
| 193 | 229 | assert.equal(readSettings({ ...full, ACCESS_TEAM_DOMAIN: "" }), null); | |
| 194 | 230 | assert.equal(readSettings({ ...full, STAFF_EMAILS: " , " }), null); | |
| 28 | 28 | aud: string; | |
| 29 | 29 | /** Lowercased staff emails, and `@domain` for everyone at a domain. */ | |
| 30 | 30 | 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>; | |
| 31 | 37 | }; | |
| 32 | 38 | ||
| 33 | 39 | export type AccessEnv = { | |
| 34 | 40 | ACCESS_TEAM_DOMAIN?: string; | |
| 35 | 41 | ACCESS_AUD?: string; | |
| 36 | 42 | STAFF_EMAILS?: string; | |
| 43 | + | /** `<client id>=<name>`, comma separated: service tokens let in as staff. */ | |
| 44 | + | STAFF_SERVICE_TOKENS?: string; | |
| 37 | 45 | }; | |
| 38 | 46 | ||
| 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 | + | ||
| 39 | 63 | /** | |
| 40 | 64 | * Reads the settings, or null when any is missing or malformed: sudo then | |
| 41 | 65 | * refuses everything rather than guess. | |
| ⋯ | |||
| 45 | 69 | const aud = (env.ACCESS_AUD ?? "").trim(); | |
| 46 | 70 | const staff = parseStaff(env.STAFF_EMAILS ?? ""); | |
| 47 | 71 | 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 ?? "") }; | |
| 49 | 73 | } | |
| 50 | 74 | ||
| 51 | 75 | /** | |
| ⋯ | |||
| 89 | 113 | iat?: number; | |
| 90 | 114 | sub?: string; | |
| 91 | 115 | email?: string; | |
| 116 | + | /** A service token's client id, on tokens with no email. */ | |
| 117 | + | common_name?: string; | |
| 92 | 118 | [claim: string]: unknown; | |
| 93 | 119 | }; | |
| 94 | 120 | ||
| ⋯ | |||
| 232 | 258 | ||
| 233 | 259 | /** | |
| 234 | 260 | * 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. | |
| 237 | 264 | */ | |
| 238 | 265 | export async function authorize( | |
| 239 | 266 | request: Request, | |
| ⋯ | |||
| 245 | 272 | const verified = await verifyAccessJwt(token, settings, options); | |
| 246 | 273 | if (!verified.ok) return verified; | |
| 247 | 274 | 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 | + | } | |
| 249 | 281 | if (!isStaff(email, settings.staff)) return { ok: false, reason: "not staff", email }; | |
| 250 | 282 | return { ok: true, email }; | |
| 251 | 283 | } | |
| 14 | 14 | ACCESS_TEAM_DOMAIN: string; | |
| 15 | 15 | ACCESS_AUD: string; | |
| 16 | 16 | STAFF_EMAILS: string; | |
| 17 | + | STAFF_SERVICE_TOKENS?: string; | |
| 17 | 18 | } | |
| 18 | 19 | } | |
| 19 | 20 | interface Env extends Cloudflare.Env {} |
| 36 | 36 | "ACCESS_AUD": "5479fcf84130fc7ae68c207ae69a81b2aa17d97714443c6f1dddd058c530b8cf", | |
| 37 | 37 | // Who may use sudo, comma separated. Access lets them in; this | |
| 38 | 38 | // 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" | |
| 40 | 44 | }, | |
| 41 | 45 | "observability": { "enabled": true }, | |
| 42 | 46 | "upload_source_maps": true |
| 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(/ /g, " ") | |
| 33 | + | .replace(/&/g, "&") | |
| 34 | + | .replace(/</g, "<") | |
| 35 | + | .replace(/>/g, ">") | |
| 36 | + | .replace(/"/g, '"') | |
| 37 | + | .replace(/'/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 | + | } |