flagon-io/g1t

public

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/apps/sudo/app/lib/access.test.ts

220 lines9,522 bytesCodeBlame
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 parseStaff,
11 readSettings,
12 verifyAccessJwt,
13} from "./access.ts";
14
15const TEAM = "g1t.cloudflareaccess.com";
16const AUD = "a".repeat(64);
17const SETTINGS: AccessSettings = { teamDomain: TEAM, aud: AUD, staff: ["owner@g1t.sh"] };
18const NOW = Date.UTC(2026, 9, 4, 12, 0, 0);
19const NOW_SECONDS = Math.floor(NOW / 1000);
20const RSA = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
21
22async 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
30const signing = await rsaKey();
31const stranger = await rsaKey();
32const publicJwk = { ...(await crypto.subtle.exportKey("jwk", signing.publicKey)), kid: "key-1", alg: "RS256", use: "sig" };
33
34function 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
41async 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
50function 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
63let fetches: string[] = [];
64async function fetcher(url: string): Promise<Response> {
65 fetches.push(url);
66 return Response.json({ keys: [publicJwk], public_cert: { kid: "key-1", cert: "" } });
67}
68
69function request(token?: string): Request {
70 return new Request("https://sudo.g1t.sh/", {
71 headers: token ? { "cf-access-jwt-assertion": token } : {},
72 });
73}
74
75beforeEach(() => {
76 clearKeyCache();
77 fetches = [];
78});
79
80test("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
86test("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
91test("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
96test("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
105test("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
110test("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
115test("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
120test("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
125test("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
133test("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
138test("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
146test("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
154test("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
159test("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
164test("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
168test("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
175test("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
183test("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
189test("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
199test("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});
209
210test("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});