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