Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

295 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { User, Viewer } from "@g1t/contracts";
5
6import { crossOrigin } from "./same-origin.ts";
7import {
8 NEEDS_SIGN_IN,
9 TOKEN_REFUSED,
10 alwaysNeedsSignIn,
11 bearerToken,
12 isNeedsSignIn,
13 needsRealSignIn,
14 scopedRoute,
15 scopedUser,
16 socketUser,
17 tokenVerdict,
18 websiteUser,
19} from "./website-token.ts";
20
21const ada: User = {
22 id: "usr_ada",
23 username: "ada",
24 kind: "user",
25 verified: true,
26 workspaces: [{ slug: "acme", role: "owner" }],
27 token: { token_id: "tok_web", scopes: ["repo:read"], website: true },
28};
29
30/** identity's `user_for_access_token`, over a few tokens. */
31function lookup(tokens: Record<string, Viewer>) {
32 const asked: string[] = [];
33 const resolve = async (token: string) => {
34 asked.push(token);
35 return tokens[token] ?? null;
36 };
37 return Object.assign(resolve, { asked });
38}
39
40const tokens = lookup({
41 g1t_web: ada,
42 g1t_api_only: { ...ada, token: { token_id: "tok_api", scopes: null } },
43 g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", website: true } },
44 g1t_job: { ...ada, token: { token_id: "tok_job", website: true, job: { run_id: "run_1", job_id: "job_1" } } },
45 g1t_agent: { ...ada, kind: "agent", token: { token_id: "tok_agent", website: true } },
46});
47
48function request(path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}): Request {
49 return new Request(`https://g1t.sh${path}`, init);
50}
51
52const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
53
54test("a token with the website permission signs the request in as its owner", async () => {
55 for (const path of ["/", "/acme/rocket", "/acme/rocket/pull/1.data", "/settings/profile"]) {
56 const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
57 assert.equal(verdict.kind, "signed-in", path);
58 assert.equal(verdict.kind === "signed-in" && verdict.user.username, "ada");
59 }
60 // A form post too, which a token's request needs no CSRF token for.
61 const post = request("/acme/rocket/issues/new", {
62 method: "POST",
63 headers: { ...bearer("g1t_web"), origin: "https://g1t.sh" },
64 body: new URLSearchParams({ title: "x" }),
65 });
66 assert.equal((await tokenVerdict(post, tokens)).kind, "signed-in");
67});
68
69test("a token without the website permission is no one: a page loads signed out, data and posts get a 401", async () => {
70 for (const token of ["g1t_api_only", "g1t_workspace", "g1t_job", "g1t_agent"]) {
71 assert.deepEqual(await tokenVerdict(request("/acme/rocket", { headers: bearer(token) }), tokens), { kind: "signed-out" }, token);
72 assert.deepEqual(
73 await tokenVerdict(request("/acme/rocket.data", { headers: bearer(token) }), tokens),
74 { kind: "refused", status: 401, body: TOKEN_REFUSED },
75 token,
76 );
77 const post = request("/acme/rocket/issues/new", { method: "POST", headers: bearer(token), body: new URLSearchParams({ title: "x" }) });
78 assert.equal((await tokenVerdict(post, tokens)).kind, "refused", token);
79 }
80 assert.match(TOKEN_REFUSED, /Use the website as you/);
81});
82
83test("a revoked, expired or made-up token is refused, and nothing else is tried", async () => {
84 // identity answers null for a token deleted, expired or never made.
85 assert.equal((await tokenVerdict(request("/_root.data", { headers: bearer("g1t_deleted") }), tokens)).kind, "refused");
86 assert.equal((await tokenVerdict(request("/", { headers: bearer("g1t_deleted") }), tokens)).kind, "signed-out");
87 // Not a g1t token at all: identity is not asked.
88 const before = tokens.asked.length;
89 assert.equal((await tokenVerdict(request("/x.data", { headers: bearer("not-a-token") }), tokens)).kind, "refused");
90 assert.equal(tokens.asked.length, before);
91});
92
93test("tokens are read from the Authorization header only, never a query string or a cookie", async () => {
94 assert.deepEqual(await tokenVerdict(request("/?access_token=g1t_web&token=g1t_web"), tokens), { kind: "none" });
95 assert.deepEqual(await tokenVerdict(request("/", { headers: { cookie: "g1t_session=g1t_web; token=g1t_web" } }), tokens), { kind: "none" });
96 assert.equal(bearerToken(request("/", { headers: { authorization: "Basic " + btoa("ada:g1t_web") } })), null);
97 assert.equal(bearerToken(request("/", { headers: { authorization: "bearer g1t_web " } })), "g1t_web");
98 assert.equal(bearerToken(request("/", { headers: { authorization: "Bearer a b" } })), null);
99});
100
101test("what needs a real sign-in is refused with a token, whatever the method", async () => {
102 for (const path of [
103 "/settings/tokens",
104 "/settings/tokens/new",
105 "/settings/tokens/tok_1.data",
106 "/settings/two-factor",
107 "/settings/emails",
108 "/settings/keys",
109 "/settings/account",
110 "/settings/applications",
111 "/settings/github",
112 "/device",
113 "/oauth/authorize",
114 "/auth/github/callback",
115 "/acme/-/tokens",
116 "/acme/-/tokens/new.data",
117 "/acme/-/personal-access-tokens",
118 // As routes match them: any case, encoded, doubled or trailing slashes.
119 "/Settings/Tokens",
120 "/settings/%74okens",
121 "//settings//two-factor/",
122 ]) {
123 assert.ok(alwaysNeedsSignIn(path), path);
124 const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
125 assert.deepEqual(verdict, { kind: "refused", status: 403, body: NEEDS_SIGN_IN }, path);
126 }
127 for (const path of [
128 "/settings/profile",
129 "/settings/notifications",
130 "/settings/security-log",
131 "/acme/-/settings",
132 "/acme/-/billing",
133 "/acme/rocket/settings",
134 ]) {
135 assert.ok(!alwaysNeedsSignIn(path), path);
136 }
137 assert.ok(isNeedsSignIn(NEEDS_SIGN_IN));
138 assert.ok(!isNeedsSignIn("Not found"));
139});
140
141test("deleting or giving away a workspace and payment methods are refused; other changes there are not", async () => {
142 const post = (path: string, fields: Record<string, string>) =>
143 request(path, { method: "POST", headers: bearer("g1t_web"), body: new URLSearchParams(fields) });
144 for (const [path, fields] of [
145 ["/acme/-/settings.data", { intent: "delete" }],
146 ["/acme/-/members", { action: "transfer", member: "bob" }],
147 ["/acme/-/billing.data", { intent: "portal" }],
148 ["/acme/-/billing", { intent: "card-check" }],
149 ["/acme/-/billing", { intent: "subscribe" }],
150 ["/acme/-/billing", { intent: "buy-ai-credit", amount: "10" }],
151 ] as const) {
152 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "refused", `${path} ${JSON.stringify(fields)}`);
153 }
154 for (const [path, fields] of [
155 ["/acme/-/settings", { intent: "rename", slug: "acme2" }],
156 ["/acme/-/members", { action: "role", member: "bob", role: "member" }],
157 ["/acme/-/billing", { intent: "budget" }],
158 ] as const) {
159 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "signed-in", `${path} ${JSON.stringify(fields)}`);
160 }
161 // Looking at those pages is fine.
162 assert.ok(!needsRealSignIn("/acme/-/billing", "GET", null));
163 assert.ok(!needsRealSignIn("/acme/-/settings", "POST", null));
164 // A multipart post is read too.
165 const multipart = new FormData();
166 multipart.set("intent", "delete");
167 const deleting = request("/acme/-/settings", { method: "POST", headers: bearer("g1t_web"), body: multipart });
168 assert.equal((await tokenVerdict(deleting, tokens)).kind, "refused");
169 // The action still reads the same body afterwards.
170 assert.equal((await deleting.formData()).get("intent"), "delete");
171});
172
173test("only a person's own token with the permission is a website user", () => {
174 assert.equal(websiteUser(ada)?.username, "ada");
175 assert.equal(websiteUser(null), null);
176 assert.equal(websiteUser({ ...ada, token: undefined }), null, "a session's user is not a token's");
177 assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: false } }), null);
178 assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: true, deploy_key: "key_1" } }), null);
179 assert.equal(websiteUser({ ...ada, acting: { agent: "g1t" } as unknown as User["acting"] }), null);
180});
181
182test("cross-site form posts are refused for a session cookie and a token alike", () => {
183 const post = (headers: Record<string, string>) => request("/acme/rocket/issues/new", { method: "POST", headers });
184 assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://evil.example" })));
185 assert.ok(crossOrigin(post({ ...bearer("g1t_web"), origin: "https://evil.example" })));
186 assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "null" })));
187 assert.ok(!crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://g1t.sh" })));
188 assert.ok(!crossOrigin(post(bearer("g1t_web"))), "automation that sends no Origin is not another site");
189});
190
191/** A person's token without the website permission, as an OAuth sign-in (the phone app) or a personal token has it. */
192const scoped = (scopes: string[] | null, more: Partial<NonNullable<User["token"]>> = {}): User => ({
193 ...ada,
194 token: { token_id: "tok_app", scopes, ...more },
195});
196
197const scopedTokens = lookup({
198 g1t_chat_read: scoped(["chat:read", "notifications:read"]),
199 g1t_chat_write: scoped(["chat:write", "notifications:write"]),
200 g1t_no_chat: scoped(["repo:read", "work:write", "notifications:read"]),
201 g1t_full: scoped(null),
202 g1t_legacy: scoped(null, { legacy: true }),
203 g1t_web: ada,
204 g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", scopes: ["chat:write"] } },
205 g1t_job: scoped(["chat:write"], { job: { run_id: "run_1", job_id: "job_1" } }),
206 g1t_deploy: scoped(["chat:write"], { deploy_key: "key_1" }),
207 g1t_agent: { ...scoped(["chat:write"]), kind: "agent" },
208});
209
210const verdictOf = async (token: string, path: string, method = "GET") =>
211 (await tokenVerdict(request(path, { method, headers: bearer(token), body: method === "GET" ? undefined : JSON.stringify({ intent: "post" }) }), scopedTokens))
212 .kind;
213
214test("a token with chat:read reads Chat's JSON without the website permission, and sends nothing", async () => {
215 for (const path of ["/acme/-/chat/api", "/acme/-/chat/api?channel=ch_1&thread=m_1", "/Acme//-/chat/api/", "/acme/-/chat/person/bob"]) {
216 assert.equal(await verdictOf("g1t_chat_read", path), "signed-in", path);
217 }
218 assert.equal(await verdictOf("g1t_chat_read", "/acme/-/chat/api", "POST"), "refused");
219 assert.equal(await verdictOf("g1t_chat_read", "/acme/-/chat/person/bob", "POST"), "refused");
220});
221
222test("a token with chat:write sends to Chat's JSON, and reads it too", async () => {
223 assert.equal(await verdictOf("g1t_chat_write", "/acme/-/chat/api", "POST"), "signed-in");
224 assert.equal(await verdictOf("g1t_chat_write", "/acme/-/chat/api"), "signed-in");
225 assert.equal(await verdictOf("g1t_chat_write", "/-/live/ticket?path=%2Facme%2F-%2Fchat%2Flive"), "signed-in");
226});
227
228test("a token without a chat scope, or not a person's own, reaches none of Chat", async () => {
229 for (const token of ["g1t_no_chat", "g1t_workspace", "g1t_job", "g1t_deploy", "g1t_agent"]) {
230 assert.equal(await verdictOf(token, "/acme/-/chat/api"), "signed-out", token);
231 assert.equal(await verdictOf(token, "/acme/-/chat/api", "POST"), "refused", token);
232 assert.equal(await verdictOf(token, "/-/live/ticket?path=%2F-%2Flive"), "signed-out", token);
233 }
234});
235
236test("a chat-scoped token opens nothing else on the site: pages are signed out, data and posts are a 401", async () => {
237 for (const token of ["g1t_chat_write", "g1t_full"]) {
238 for (const path of ["/", "/acme", "/acme/-/chat", "/acme/-/chat/general", "/acme/rocket", "/settings/profile", "/acme/-/chat/live", "/-/live"]) {
239 assert.deepEqual(await tokenVerdict(request(path, { headers: bearer(token) }), scopedTokens), { kind: "signed-out" }, `${token} ${path}`);
240 }
241 // A client navigation's data runs the loaders around the route as well, so it is not Chat's JSON.
242 for (const path of ["/acme/-/chat/api.data", "/acme/-/chat/person/bob.data", "/acme/-/chat.data", "/-/notify.data", "/-/live/ticket.data", "/_root.data"]) {
243 assert.deepEqual(
244 await tokenVerdict(request(path, { headers: bearer(token) }), scopedTokens),
245 { kind: "refused", status: 401, body: TOKEN_REFUSED },
246 `${token} ${path}`,
247 );
248 }
249 for (const path of ["/acme/rocket/issues/new", "/acme/-/chat/general", "/-/live/ticket", "/acme/-/chat/person/bob", "/-/-/chat/api"]) {
250 assert.equal(await verdictOf(token, path, "POST"), "refused", `${token} ${path}`);
251 }
252 assert.equal(await verdictOf(token, "/acme/-/chat/api", "DELETE"), "refused");
253 }
254 assert.equal(scopedRoute("/-/-/chat/api", "GET"), null, "`-` is no workspace");
255 assert.equal(scopedRoute("/acme/web/-/chat/api", "GET"), null);
256 assert.equal(scopedRoute("/acme/-/chat/person/bob/x", "GET"), null);
257});
258
259test("push registration and notification settings take the notifications scopes", async () => {
260 assert.equal(await verdictOf("g1t_chat_read", "/-/notify"), "signed-in");
261 assert.equal(await verdictOf("g1t_chat_read", "/-/notify", "POST"), "refused");
262 assert.equal(await verdictOf("g1t_chat_write", "/-/notify", "POST"), "signed-in");
263 assert.equal(await verdictOf("g1t_chat_write", "/-/notify"), "signed-in", "write includes read");
264 assert.equal(await verdictOf("g1t_no_chat", "/-/notify"), "signed-in", "notifications:read alone is enough");
265 assert.equal(await verdictOf("g1t_no_chat", "/-/notify", "POST"), "refused");
266 assert.equal(scopedRoute("/-/notify", "POST"), "notifications:write");
267});
268
269test("full access, legacy or not, reaches Chat only with the website permission; the website permission keeps opening everything", async () => {
270 for (const token of ["g1t_full", "g1t_legacy"]) {
271 assert.equal(await verdictOf(token, "/acme/-/chat/api", "GET"), "signed-out", token);
272 assert.equal(await verdictOf(token, "/acme/-/chat/api", "POST"), "refused", token);
273 assert.equal(await verdictOf(token, "/-/notify", "POST"), "refused", token);
274 assert.equal(socketUser(scoped(null), "/-/live"), null, token);
275 }
276 // A website token with no chat scope is the website's as before, Chat included.
277 assert.equal(await verdictOf("g1t_web", "/acme/-/chat/api", "POST"), "signed-in");
278 assert.equal(await verdictOf("g1t_web", "/acme/rocket"), "signed-in");
279 assert.equal(scopedUser(scoped(["chat:read"]), "/acme/-/chat/api", "GET")?.username, "ada");
280 assert.equal(scopedUser({ ...ada, token: undefined }, "/acme/-/chat/api", "GET"), null, "a session is not a token");
281 assert.equal(scopedUser(scoped(["chat:bogus", "chat"]), "/acme/-/chat/api", "GET"), null);
282});
283
284test("a chat-scoped token opens Chat's socket and the feed, never an artifact's", () => {
285 const reader = scoped(["chat:read"]);
286 assert.equal(socketUser(reader, "/acme/-/chat/live")?.username, "ada");
287 assert.equal(socketUser(reader, "/-/live")?.username, "ada");
288 assert.equal(socketUser(reader, "/acme/-/artifacts/live"), null);
289 assert.equal(socketUser(scoped(null), "/acme/-/chat/live"), null, "full access, without the website permission");
290 assert.equal(socketUser(scoped(["notifications:write"]), "/-/live"), null);
291 assert.equal(socketUser(scoped(["chat:read"], { job: { run_id: "run_1", job_id: "job_1" } }), "/-/live"), null);
292 assert.equal(socketUser({ ...ada, token: undefined }, "/-/live"), null);
293 // The website permission opens every socket, as before.
294 assert.equal(socketUser(ada, "/acme/-/artifacts/live")?.username, "ada");
295});