Skip to content
171 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 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" }],
128 ["/acme/-/members", { action: "transfer", member: "bob" }],
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" }],
138 ["/acme/-/members", { action: "role", member: "bob", role: "member" }],
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
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});