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 2 | 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | |
| 3 | ||
| 4 | import type { User, Viewer } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import { crossOrigin } from "./same-origin.ts"; | |
| 7 | import { | |
| 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 | ||
| 18 | const 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. */ | |
| 28 | function 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 | ||
| 37 | const 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 | ||
| 45 | function request(path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}): Request { | |
| 46 | return new Request(`https://g1t.sh${path}`, init); | |
| 47 | } | |
| 48 | ||
| 49 | const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); | |
| 50 | ||
| 51 | test("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 | ||
| 62 | test("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 | ||
| 72 | test("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 | ||
| 82 | test("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 | ||
| 90 | test("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 | ||
| 123 | test("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 2 | 129 | ["/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 2 | 139 | ["/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 | ||
| 155 | test("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 | ||
| 164 | test("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.