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