Skip to content
171 linesCodeBlameRaw

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.

Merge main into Artifacts Phase 21import 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 tokenVerdict,
15 websiteUser,
16} from "./website-token.ts";
17
18const ada: User = {
19 id: "usr_ada",
20 username: "ada",
21 kind: "user",
22 verified: true,
23 workspaces: [{ slug: "acme", role: "owner" }],
24 token: { token_id: "tok_web", scopes: ["repo:read"], website: true },
25};
26
27/** identity's `user_for_access_token`, over a few tokens. */
28function lookup(tokens: Record<string, Viewer>) {
29 const asked: string[] = [];
30 const resolve = async (token: string) => {
31 asked.push(token);
32 return tokens[token] ?? null;
33 };
34 return Object.assign(resolve, { asked });
35}
36
37const tokens = lookup({
38 g1t_web: ada,
39 g1t_api_only: { ...ada, token: { token_id: "tok_api", scopes: null } },
40 g1t_workspace: { ...ada, id: "wsp_1", username: "acme", kind: "workspace", token: { token_id: "tok_ws", website: true } },
41 g1t_job: { ...ada, token: { token_id: "tok_job", website: true, job: { run_id: "run_1", job_id: "job_1" } } },
42 g1t_agent: { ...ada, kind: "agent", token: { token_id: "tok_agent", website: true } },
43});
44
45function request(path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}): Request {
46 return new Request(`https://g1t.sh${path}`, init);
47}
48
49const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
50
51test("a token with the website permission signs the request in as its owner", async () => {
52 for (const path of ["/", "/acme/rocket", "/acme/rocket/pull/1.data", "/settings/profile"]) {
53 const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
54 assert.equal(verdict.kind, "signed-in", path);
55 assert.equal(verdict.kind === "signed-in" && verdict.user.username, "ada");
56 }
57 // A form post too, which a token's request needs no CSRF token for.
58 const post = request("/acme/rocket/issues/new", { method: "POST", headers: { ...bearer("g1t_web"), origin: "https://g1t.sh" }, body: new URLSearchParams({ title: "x" }) });
59 assert.equal((await tokenVerdict(post, tokens)).kind, "signed-in");
60});
61
62test("a token without the website permission is no one: a page loads signed out, data and posts get a 401", async () => {
63 for (const token of ["g1t_api_only", "g1t_workspace", "g1t_job", "g1t_agent"]) {
64 assert.deepEqual(await tokenVerdict(request("/acme/rocket", { headers: bearer(token) }), tokens), { kind: "signed-out" }, token);
65 assert.deepEqual(await tokenVerdict(request("/acme/rocket.data", { headers: bearer(token) }), tokens), { kind: "refused", status: 401, body: TOKEN_REFUSED }, token);
66 const post = request("/acme/rocket/issues/new", { method: "POST", headers: bearer(token), body: new URLSearchParams({ title: "x" }) });
67 assert.equal((await tokenVerdict(post, tokens)).kind, "refused", token);
68 }
69 assert.match(TOKEN_REFUSED, /Use the website as you/);
70});
71
72test("a revoked, expired or made-up token is refused, and nothing else is tried", async () => {
73 // identity answers null for a token deleted, expired or never made.
74 assert.equal((await tokenVerdict(request("/_root.data", { headers: bearer("g1t_deleted") }), tokens)).kind, "refused");
75 assert.equal((await tokenVerdict(request("/", { headers: bearer("g1t_deleted") }), tokens)).kind, "signed-out");
76 // Not a g1t token at all: identity is not asked.
77 const before = tokens.asked.length;
78 assert.equal((await tokenVerdict(request("/x.data", { headers: bearer("not-a-token") }), tokens)).kind, "refused");
79 assert.equal(tokens.asked.length, before);
80});
81
82test("tokens are read from the Authorization header only, never a query string or a cookie", async () => {
83 assert.deepEqual(await tokenVerdict(request("/?access_token=g1t_web&token=g1t_web"), tokens), { kind: "none" });
84 assert.deepEqual(await tokenVerdict(request("/", { headers: { cookie: "g1t_session=g1t_web; token=g1t_web" } }), tokens), { kind: "none" });
85 assert.equal(bearerToken(request("/", { headers: { authorization: "Basic " + btoa("ada:g1t_web") } })), null);
86 assert.equal(bearerToken(request("/", { headers: { authorization: "bearer g1t_web " } })), "g1t_web");
87 assert.equal(bearerToken(request("/", { headers: { authorization: "Bearer a b" } })), null);
88});
89
90test("what needs a real sign-in is refused with a token, whatever the method", async () => {
91 for (const path of [
92 "/settings/tokens",
93 "/settings/tokens/new",
94 "/settings/tokens/tok_1.data",
95 "/settings/two-factor",
96 "/settings/emails",
97 "/settings/keys",
98 "/settings/account",
99 "/settings/applications",
100 "/settings/github",
101 "/device",
102 "/oauth/authorize",
103 "/auth/github/callback",
104 "/acme/-/tokens",
105 "/acme/-/tokens/new.data",
106 "/acme/-/personal-access-tokens",
107 // As routes match them: any case, encoded, doubled or trailing slashes.
108 "/Settings/Tokens",
109 "/settings/%74okens",
110 "//settings//two-factor/",
111 ]) {
112 assert.ok(alwaysNeedsSignIn(path), path);
113 const verdict = await tokenVerdict(request(path, { headers: bearer("g1t_web") }), tokens);
114 assert.deepEqual(verdict, { kind: "refused", status: 403, body: NEEDS_SIGN_IN }, path);
115 }
116 for (const path of ["/settings/profile", "/settings/notifications", "/settings/security-log", "/acme/-/settings", "/acme/-/billing", "/acme/rocket/settings"]) {
117 assert.ok(!alwaysNeedsSignIn(path), path);
118 }
119 assert.ok(isNeedsSignIn(NEEDS_SIGN_IN));
120 assert.ok(!isNeedsSignIn("Not found"));
121});
122
123test("deleting or giving away a workspace and payment methods are refused; other changes there are not", async () => {
124 const post = (path: string, fields: Record<string, string>) =>
125 request(path, { method: "POST", headers: bearer("g1t_web"), body: new URLSearchParams(fields) });
126 for (const [path, fields] of [
127 ["/acme/-/settings.data", { intent: "delete" }],
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.128 ["/acme/-/members", { action: "transfer", member: "bob" }],
Merge main into Artifacts Phase 2129 ["/acme/-/billing.data", { intent: "portal" }],
130 ["/acme/-/billing", { intent: "card-check" }],
131 ["/acme/-/billing", { intent: "subscribe" }],
132 ["/acme/-/billing", { intent: "buy-ai-credit", amount: "10" }],
133 ] as const) {
134 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "refused", `${path} ${JSON.stringify(fields)}`);
135 }
136 for (const [path, fields] of [
137 ["/acme/-/settings", { intent: "rename", slug: "acme2" }],
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.138 ["/acme/-/members", { action: "role", member: "bob", role: "member" }],
Merge main into Artifacts Phase 2139 ["/acme/-/billing", { intent: "budget" }],
140 ] as const) {
141 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "signed-in", `${path} ${JSON.stringify(fields)}`);
142 }
143 // Looking at those pages is fine.
144 assert.ok(!needsRealSignIn("/acme/-/billing", "GET", null));
145 assert.ok(!needsRealSignIn("/acme/-/settings", "POST", null));
146 // A multipart post is read too.
147 const multipart = new FormData();
148 multipart.set("intent", "delete");
149 const deleting = request("/acme/-/settings", { method: "POST", headers: bearer("g1t_web"), body: multipart });
150 assert.equal((await tokenVerdict(deleting, tokens)).kind, "refused");
151 // The action still reads the same body afterwards.
152 assert.equal((await deleting.formData()).get("intent"), "delete");
153});
154
155test("only a person's own token with the permission is a website user", () => {
156 assert.equal(websiteUser(ada)?.username, "ada");
157 assert.equal(websiteUser(null), null);
158 assert.equal(websiteUser({ ...ada, token: undefined }), null, "a session's user is not a token's");
159 assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: false } }), null);
160 assert.equal(websiteUser({ ...ada, token: { token_id: "t", website: true, deploy_key: "key_1" } }), null);
161 assert.equal(websiteUser({ ...ada, acting: { agent: "g1t" } as unknown as User["acting"] }), null);
162});
163
164test("cross-site form posts are refused for a session cookie and a token alike", () => {
165 const post = (headers: Record<string, string>) => request("/acme/rocket/issues/new", { method: "POST", headers });
166 assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://evil.example" })));
167 assert.ok(crossOrigin(post({ ...bearer("g1t_web"), origin: "https://evil.example" })));
168 assert.ok(crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "null" })));
169 assert.ok(!crossOrigin(post({ cookie: "g1t_session=" + "a".repeat(64), origin: "https://g1t.sh" })));
170 assert.ok(!crossOrigin(post(bearer("g1t_web"))), "automation that sends no Origin is not another site");
171});

This file's history is long; its oldest lines are credited to the oldest commit read.