g1t/apps/sudo/app/lib/access.test.ts
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.
| Billing accounts, terms and enterprises; g1t is no longer free | 1 | import assert from "node:assert/strict"; |
| 2 | import { beforeEach, test } from "node:test"; | |
| 3 | ||
| 4 | import { | |
| 5 | type AccessSettings, | |
| 6 | authorize, | |
| 7 | clearKeyCache, | |
| 8 | isSameOrigin, | |
| sudo: Access is on, and everyone at g1t.sh is staff | 9 | isStaff, |
| 10 | parseStaff, | |
| Billing accounts, terms and enterprises; g1t is no longer free | 11 | readSettings, |
| 12 | verifyAccessJwt, | |
| 13 | } from "./access.ts"; | |
| 14 | ||
| 15 | const TEAM = "g1t.cloudflareaccess.com"; | |
| 16 | const AUD = "a".repeat(64); | |
| 17 | const SETTINGS: AccessSettings = { teamDomain: TEAM, aud: AUD, staff: ["owner@g1t.sh"] }; | |
| 18 | const NOW = Date.UTC(2026, 9, 4, 12, 0, 0); | |
| 19 | const NOW_SECONDS = Math.floor(NOW / 1000); | |
| 20 | const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const; | |
| 21 | ||
| 22 | async function rsaKey() { | |
| 23 | return crypto.subtle.generateKey( | |
| 24 | { ...RSA, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) }, | |
| 25 | true, | |
| 26 | ["sign", "verify"], | |
| 27 | ); | |
| 28 | } | |
| 29 | ||
| 30 | const signing = await rsaKey(); | |
| 31 | const stranger = await rsaKey(); | |
| 32 | const publicJwk = { ...(await crypto.subtle.exportKey("jwk", signing.publicKey)), kid: "key-1", alg: "RS256", use: "sig" }; | |
| 33 | ||
| 34 | function b64url(bytes: Uint8Array | string): string { | |
| 35 | const raw = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes; | |
| 36 | let binary = ""; | |
| 37 | for (const byte of raw) binary += String.fromCharCode(byte); | |
| 38 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 39 | } | |
| 40 | ||
| 41 | async function sign( | |
| 42 | claims: Record<string, unknown>, | |
| 43 | { key = signing.privateKey, kid = "key-1", alg = "RS256" }: { key?: CryptoKey; kid?: string; alg?: string } = {}, | |
| 44 | ): Promise<string> { | |
| 45 | const body = `${b64url(JSON.stringify({ alg, kid, typ: "JWT" }))}.${b64url(JSON.stringify(claims))}`; | |
| 46 | const signature = new Uint8Array(await crypto.subtle.sign(RSA, key, new TextEncoder().encode(body))); | |
| 47 | return `${body}.${b64url(signature)}`; | |
| 48 | } | |
| 49 | ||
| 50 | function claims(overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 51 | return { | |
| 52 | iss: `https://${TEAM}`, | |
| 53 | aud: [AUD], | |
| 54 | email: "owner@g1t.sh", | |
| 55 | sub: "user-1", | |
| 56 | iat: NOW_SECONDS - 60, | |
| 57 | nbf: NOW_SECONDS - 60, | |
| 58 | exp: NOW_SECONDS + 3600, | |
| 59 | ...overrides, | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | let fetches: string[] = []; | |
| 64 | async function fetcher(url: string): Promise<Response> { | |
| 65 | fetches.push(url); | |
| 66 | return Response.json({ keys: [publicJwk], public_cert: { kid: "key-1", cert: "" } }); | |
| 67 | } | |
| 68 | ||
| 69 | function request(token?: string): Request { | |
| 70 | return new Request("https://sudo.g1t.sh/", { | |
| 71 | headers: token ? { "cf-access-jwt-assertion": token } : {}, | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | beforeEach(() => { | |
| 76 | clearKeyCache(); | |
| 77 | fetches = []; | |
| 78 | }); | |
| 79 | ||
| 80 | test("a valid token from staff is let in, by its email", async () => { | |
| 81 | const result = await authorize(request(await sign(claims())), SETTINGS, { fetcher, now: NOW }); | |
| 82 | assert.deepEqual(result, { ok: true, email: "owner@g1t.sh" }); | |
| 83 | assert.deepEqual(fetches, [`https://${TEAM}/cdn-cgi/access/certs`]); | |
| 84 | }); | |
| 85 | ||
| 86 | test("an audience given as a string is accepted too", async () => { | |
| 87 | const result = await verifyAccessJwt(await sign(claims({ aud: AUD })), SETTINGS, { fetcher, now: NOW }); | |
| 88 | assert.equal(result.ok, true); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a token for another Access application is refused", async () => { | |
| 92 | const result = await verifyAccessJwt(await sign(claims({ aud: ["b".repeat(64)] })), SETTINGS, { fetcher, now: NOW }); | |
| 93 | assert.deepEqual(result, { ok: false, reason: "wrong audience" }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a token from another team is refused", async () => { | |
| 97 | const result = await verifyAccessJwt( | |
| 98 | await sign(claims({ iss: "https://evil.cloudflareaccess.com" })), | |
| 99 | SETTINGS, | |
| 100 | { fetcher, now: NOW }, | |
| 101 | ); | |
| 102 | assert.deepEqual(result, { ok: false, reason: "wrong issuer" }); | |
| 103 | }); | |
| 104 | ||
| 105 | test("an expired token is refused", async () => { | |
| 106 | const result = await verifyAccessJwt(await sign(claims({ exp: NOW_SECONDS - 3600 })), SETTINGS, { fetcher, now: NOW }); | |
| 107 | assert.deepEqual(result, { ok: false, reason: "expired" }); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a token without an expiry is refused", async () => { | |
| 111 | const result = await verifyAccessJwt(await sign(claims({ exp: undefined })), SETTINGS, { fetcher, now: NOW }); | |
| 112 | assert.deepEqual(result, { ok: false, reason: "no expiry" }); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a token not valid yet is refused", async () => { | |
| 116 | const result = await verifyAccessJwt(await sign(claims({ nbf: NOW_SECONDS + 3600 })), SETTINGS, { fetcher, now: NOW }); | |
| 117 | assert.deepEqual(result, { ok: false, reason: "not yet valid" }); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a token signed by another key under a known key id is refused", async () => { | |
| 121 | const result = await verifyAccessJwt(await sign(claims(), { key: stranger.privateKey }), SETTINGS, { fetcher, now: NOW }); | |
| 122 | assert.deepEqual(result, { ok: false, reason: "bad signature" }); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a token whose claims were changed after signing is refused", async () => { | |
| 126 | const token = await sign(claims({ email: "intern@g1t.sh" })); | |
| 127 | const [header, , signature] = token.split("."); | |
| 128 | const forged = `${header}.${b64url(JSON.stringify(claims()))}.${signature}`; | |
| 129 | const result = await authorize(request(forged), SETTINGS, { fetcher, now: NOW }); | |
| 130 | assert.deepEqual(result, { ok: false, reason: "bad signature" }); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a token signed by a key the team does not publish is refused", async () => { | |
| 134 | const result = await verifyAccessJwt(await sign(claims(), { kid: "key-9" }), SETTINGS, { fetcher, now: NOW }); | |
| 135 | assert.deepEqual(result, { ok: false, reason: "unknown signing key" }); | |
| 136 | }); | |
| 137 | ||
| 138 | test("alg none and HS256 are refused before any key is fetched", async () => { | |
| 139 | for (const alg of ["none", "HS256"]) { | |
| 140 | const result = await verifyAccessJwt(await sign(claims(), { alg }), SETTINGS, { fetcher, now: NOW }); | |
| 141 | assert.deepEqual(result, { ok: false, reason: "unexpected algorithm" }); | |
| 142 | } | |
| 143 | assert.deepEqual(fetches, []); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a valid token whose email is not staff is refused", async () => { | |
| 147 | const result = await authorize(request(await sign(claims({ email: "someone@example.com" }))), SETTINGS, { | |
| 148 | fetcher, | |
| 149 | now: NOW, | |
| 150 | }); | |
| 151 | assert.deepEqual(result, { ok: false, reason: "not staff", email: "someone@example.com" }); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a service token, which has no email, is refused", async () => { | |
| 155 | const result = await authorize(request(await sign(claims({ email: undefined }))), SETTINGS, { fetcher, now: NOW }); | |
| 156 | assert.deepEqual(result, { ok: false, reason: "token has no email" }); | |
| 157 | }); | |
| 158 | ||
| 159 | test("staff emails match without regard to case", async () => { | |
| 160 | const result = await authorize(request(await sign(claims({ email: "Owner@G1T.sh" }))), SETTINGS, { fetcher, now: NOW }); | |
| 161 | assert.deepEqual(result, { ok: true, email: "owner@g1t.sh" }); | |
| 162 | }); | |
| 163 | ||
| 164 | test("a request without a token is refused", async () => { | |
| 165 | assert.deepEqual(await authorize(request(), SETTINGS, { fetcher, now: NOW }), { ok: false, reason: "no Access token" }); | |
| 166 | }); | |
| 167 | ||
| 168 | test("garbage is refused", async () => { | |
| 169 | for (const token of ["", "a.b", "a.b.c.d", "!!.??.**", "e30.e30.e30"]) { | |
| 170 | const result = await verifyAccessJwt(token, SETTINGS, { fetcher, now: NOW }); | |
| 171 | assert.equal(result.ok, false, token); | |
| 172 | } | |
| 173 | }); | |
| 174 | ||
| 175 | test("the team's keys are fetched once and kept for a few minutes", async () => { | |
| 176 | await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher, now: NOW }); | |
| 177 | await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher, now: NOW + 60_000 }); | |
| 178 | assert.equal(fetches.length, 1); | |
| 179 | await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher, now: NOW + 10 * 60_000 }); | |
| 180 | assert.equal(fetches.length, 2); | |
| 181 | }); | |
| 182 | ||
| 183 | test("when the keys cannot be fetched, nothing is let in", async () => { | |
| 184 | const down = async () => new Response("no", { status: 503 }); | |
| 185 | const result = await verifyAccessJwt(await sign(claims()), SETTINGS, { fetcher: down, now: NOW }); | |
| 186 | assert.deepEqual(result, { ok: false, reason: "unknown signing key" }); | |
| 187 | }); | |
| 188 | ||
| 189 | test("sudo is closed until every setting is given", () => { | |
| 190 | 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"] }); | |
| 192 | assert.equal(readSettings({ ...full, ACCESS_AUD: "" }), null); | |
| 193 | assert.equal(readSettings({ ...full, ACCESS_TEAM_DOMAIN: "" }), null); | |
| 194 | assert.equal(readSettings({ ...full, STAFF_EMAILS: " , " }), null); | |
| 195 | assert.equal(readSettings({ ...full, ACCESS_TEAM_DOMAIN: "evil.example.com" }), null); | |
| 196 | assert.equal(readSettings({}), null); | |
| 197 | }); | |
| 198 | ||
| 199 | test("changes are only taken from sudo's own pages", () => { | |
| 200 | const post = (headers: Record<string, string>) => new Request("https://sudo.g1t.sh/x", { method: "POST", headers }); | |
| 201 | assert.equal(isSameOrigin(post({ origin: "https://sudo.g1t.sh" })), true); | |
| 202 | assert.equal(isSameOrigin(post({ referer: "https://sudo.g1t.sh/accounts/x" })), true); | |
| 203 | assert.equal(isSameOrigin(post({ origin: "https://evil.example" })), false); | |
| 204 | assert.equal(isSameOrigin(post({ origin: "null" })), false); | |
| 205 | assert.equal(isSameOrigin(post({ referer: "https://sudo.g1t.sh.evil.example/" })), false); | |
| 206 | assert.equal(isSameOrigin(post({ origin: "https://sudo.g1t.sh", "sec-fetch-site": "cross-site" })), false); | |
| 207 | assert.equal(isSameOrigin(post({})), false); | |
| 208 | }); | |
| sudo: Access is on, and everyone at g1t.sh is staff | 209 | |
| 210 | test("everyone at a staff domain is staff, and nobody at a lookalike", () => { | |
| 211 | const staff = parseStaff("syntaqx@gmail.com, @g1t.sh"); | |
| 212 | assert.deepEqual(staff, ["syntaqx@gmail.com", "@g1t.sh"]); | |
| 213 | assert.equal(isStaff("syntaqx@gmail.com", staff), true); | |
| 214 | assert.equal(isStaff("ada@g1t.sh", staff), true); | |
| 215 | assert.equal(isStaff("ada@xg1t.sh", staff), false); | |
| 216 | assert.equal(isStaff("ada@g1t.sh.evil.com", staff), false); | |
| 217 | assert.equal(isStaff("someone@gmail.com", staff), false); | |
| 218 | assert.equal(isStaff("@g1t.sh", staff), false); | |
| 219 | assert.equal(isStaff("a@b@g1t.sh", staff), false); | |
| 220 | }); |