Skip to content

Commit

Automation driving a browser can use g1t.sh as a person by sending their access token as Authorization: Bearer on every request, once the token has Use the website as you ticked on its form: no cookie or session is made, the token is checked on each request and refused at once when deleted, expired or revoked, never read from a query string or cookie, counted at the API's limit per token, and never served or kept as a public page, while tokens, two-factor authentication, emails, keys, the account, applications, device and application sign-ins, workspace deletion and transfer, and payment pages answer This needs you to sign in, a token without the permission is signed out with a 401 for data requests and form posts, and form posts from other sites are refused for cookies and tokens alike.

syntaqxcommitted Parent526cea5Browse files
18 files+513−170/18 viewed
+35−1
2828 policyNote,
2929 tokenPermissions,
3030 } from "../lib/access-tokens";
31−import { Checkbox } from "./ui/checkbox";
31+import { Checkbox, CheckboxOption } from "./ui/checkbox";
3232 import { Hint } from "./ui/hint";
3333 import { RadioGroup, RadioOption } from "./ui/radio-group";
3434 import { SelectField } from "./ui/select";
214214 const [levels, setLevels] = useState<Permissions>(() =>
215215 editing ? tokenPermissions(editing) : permissionsOf(presetScopes(preset) ?? null),
216216 );
217+ const [website, setWebsite] = useState<boolean>(editing?.website ?? false);
217218
218219 // The rules of the workspaces it would reach decide how long it may last.
219220 const reached = workspaceOwned ? [] : one ? [chosen] : reach === ALL_WORKSPACES ? workspaces : [];
387388 </p>
388389 )}
389390 </section>
391+
392+ {!workspaceOwned && (
393+ <section className="space-y-3">
394+ <div>
395+ <h3 className="text-sm font-medium text-fg">Website</h3>
396+ <p className="mt-0.5 text-xs text-faint">For automation that drives a browser, such as end-to-end tests.</p>
397+ </div>
398+ <CheckboxOption
399+ id="token-website"
400+ name="website"
401+ value="on"
402+ checked={website}
403+ onCheckedChange={(on) => setWebsite(on === true)}
404+ className="rounded-md border border-line px-3 py-2.5"
405+ labelClassName="font-medium"
406+ label="Use the website as you"
407+ description={
408+ <>
409+ Sent as <code className="font-mono">Authorization: Bearer</code> on each request, it signs g1t.sh in as you
410+ without a password or a two-factor code. Tokens, two-factor authentication, your password, email addresses,
411+ keys, deleting your account or a workspace, giving a workspace away and payment methods still need you to sign
412+ in.
413+ </>
414+ }
415+ />
416+ {website && (
417+ <Note tone="warn">
418+ The website does not hold this token to its permissions above: treat it as able to do anything you can in
419+ the workspaces it reaches. Keep it as safe as your password, and give it an expiration.
420+ </Note>
421+ )}
422+ </section>
423+ )}
390424 </div>
391425 );
392426 }
+1−0
7777 <>
7878 {badge && <Badge tone={badge.tone}>{badge.label}</Badge>}
7979 {token.workspaceOwned && <Badge tone={token.admin ? "danger" : "neutral"}>{token.admin ? "Admin" : "Write"}</Badge>}
80+ {token.website && <Badge tone="warn">Uses the website</Badge>}
8081 </>
8182 );
8283 }
+16−0
165165 assert.deepEqual(changesTo(token, { ...same, repositories: ["acme/web", "acme/api"] }), { repositorySelection: "selected", repositories: ["acme/web", "acme/api"] });
166166 assert.deepEqual(changesTo(token, { ...same, repositorySelection: "all", repositories: [] }), { repositorySelection: "all" });
167167 assert.deepEqual(changesTo(token, { ...same, name: "release", description: "ships" }), { name: "release", description: "ships" });
168+ assert.deepEqual(changesTo(token, { ...same, website: true }), { website: true });
169+ assert.deepEqual(changesTo({ ...token, website: true }, { ...same, website: false }), { website: false });
170+ assert.deepEqual(changesTo({ ...token, website: true }, { ...same, website: true }), {});
171+});
172+
173+test("using the website is off unless checked, and never on a workspace's token", () => {
174+ const base = { name: "e2e", workspace: "*", expires: "7", "perm.repo": "read" };
175+ const off = tokenFromForm(form(base));
176+ assert.ok(off.ok);
177+ assert.equal(off.value.website, false);
178+ const on = tokenFromForm(form({ ...base, website: "on" }));
179+ assert.ok(on.ok);
180+ assert.equal(on.value.website, true);
181+ const workspace = tokenFromForm(form({ ...base, website: "on" }), { workspaceOwned: true, owner: "acme" });
182+ assert.ok(workspace.ok);
183+ assert.equal(workspace.value.website, false);
168184 });
+5−1
99 * The form posts `name`, `description`, `expires` (days, or `never`),
1010 * `workspace` (`*` for every workspace you belong to, `-` for none, or a
1111 * slug), `repository_selection`, one `repo` per chosen repository, and
12− * `perm.<resource>` for each resource's level. Every field is a form field,
12+ * `perm.<resource>` for each resource's level, and `website` when a personal
13+ * token may use the website as you. Every field is a form field,
1314 * so the form posts the same with or without JavaScript.
1415 */
1516
180181 repositorySelection,
181182 repositories: repositorySelection === "selected" ? repositories : [],
182183 permissions,
184+ // A person's token only: a workspace's acts as no one who signs in.
185+ website: !workspaceOwned && ["on", "true", "1"].includes(String(form.get("website") ?? "")),
183186 },
184187 };
185188 }
275278 if (scopesOfPermissions(input.permissions).join(" ") !== scopesOfPermissions(tokenPermissions(token)).join(" ")) {
276279 change.permissions = input.permissions;
277280 }
281+ if (!token.workspaceOwned && Boolean(input.website) !== Boolean(token.website)) change.website = Boolean(input.website);
278282 if (input.repositorySelection !== (token.repositorySelection ?? "all")) change.repositorySelection = input.repositorySelection;
279283 if (input.repositorySelection === "selected" && (change.repositorySelection || !sameNames(input.repositories, token.repositories))) {
280284 change.repositorySelection = "selected";
+14−0
111111 assert.equal(refused?.status, 429);
112112 });
113113
114+test("requests with an access token count by a hash of the token, at the API's limit", async () => {
115+ const env = { WEB_ADDRESS_LIMIT: binding(100), WEB_TOKEN_LIMIT: binding(1), WEB_SESSION_LIMIT: binding(100), WEB_ANONYMOUS_LIMIT: binding(0) };
116+ const withToken = () => request("/acme/rocket", { authorization: "Bearer g1t_secret", cookie: "g1t_session=abc123" });
117+ assert.equal(await pageLimited(env, withToken(), "/acme/rocket"), null);
118+ assert.equal(env.WEB_SESSION_LIMIT.keys.length, 0, "the token counts, not a cookie beside it");
119+ assert.equal(env.WEB_ANONYMOUS_LIMIT.keys.length, 0);
120+ assert.match(env.WEB_TOKEN_LIMIT.keys[0]!, /^token:[0-9a-f]{16}$/);
121+ assert.ok(!env.WEB_TOKEN_LIMIT.keys[0]!.includes("g1t_secret"));
122+ const refused = await pageLimited(env, withToken(), "/acme/rocket");
123+ assert.equal(refused?.status, 429);
124+ assert.match((await refused?.text()) ?? "", /this access token/);
125+ assert.equal(RATE_LIMITS.WEB_TOKEN_LIMIT.limit, RATE_LIMITS.API_TOKEN_LIMIT.limit);
126+});
127+
114128 test("files the Worker serves itself are never limited", async () => {
115129 const env = { WEB_ADDRESS_LIMIT: binding(0), WEB_ANONYMOUS_LIMIT: binding(0) };
116130 for (const path of ["/assets/app-1a2b.js", "/fonts/hanken.woff2", "/favicon.ico", "/robots.txt", "/llms.txt", "/sitemap.xml"]) {
+21−3
1010 * costly to answer (archives, run pages, logs, search). Signed in, by a
1111 * hash of the session cookie, higher: the session is not checked here,
1212 * which would cost a call to identity, and the ceiling per address keeps
13− * made-up cookies from getting round the signed-out limit.
13+ * made-up cookies from getting round the signed-out limit. With an
14+ * access token (`Authorization: Bearer`, app/lib/website-token.ts), by a
15+ * hash of the token, at the API's limit for a token; the address ceiling
16+ * applies as for cookies.
1417 *
1518 * Static assets never reach the Worker (the assets binding answers them),
1619 * and the few files it serves itself are left out here too. Every limit
2932 WEB_HEAVY_LIMIT?: RateLimitBinding;
3033 WEB_SESSION_LIMIT?: RateLimitBinding;
3134 WEB_ADDRESS_LIMIT?: RateLimitBinding;
35+ WEB_TOKEN_LIMIT?: RateLimitBinding;
3236 GIT_ANONYMOUS_LIMIT?: RateLimitBinding;
3337 GIT_SIGNED_LIMIT?: RateLimitBinding;
3438 };
5862 "Too many git requests from your network. Wait a minute and try again, or use credentials for a higher limit: https://docs.g1t.sh/reference/rate-limits/\n";
5963 const GIT_MESSAGE_SIGNED = "Too many git requests with these credentials. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/\n";
6064 const PAGE_MESSAGE = "Too many requests from your network. Wait a minute and try again.\n";
65+const TOKEN_PAGE_MESSAGE = "Too many requests with this access token. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/\n";
6166
6267 /**
6368 * The 429 for a git request past its limit, or null to go on. Git shows a
7378 return verdict === "limited" ? tooManyRequests(GIT_MESSAGE_ANONYMOUS) : null;
7479 }
7580
81+/**
82+ * The token in a page request's `Authorization: Bearer` header, or null
83+ * (app/lib/website-token.ts). Not checked here either.
84+ */
85+export function websiteToken(authorization: string | null): string | null {
86+ return /^\s*bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1] ?? null;
87+}
88+
7689 /** The 429 for a page or data request past its limit, or null to go on. */
7790 export async function pageLimited(env: FrontDoorLimits, request: Request, pathname: string): Promise<Response | null> {
7891 if (unlimited(pathname)) return null;
7992 const address = `ip:${clientAddress(request)}`;
93+ const token = websiteToken(request.headers.get("authorization"));
8094 const session = sessionCookie(request.headers.get("cookie"));
8195 const checks: Promise<string>[] = [checkLimit(env.WEB_ADDRESS_LIMIT, address)];
82− if (session) {
96+ if (token) {
97+ // A token on the website: per token, as the API counts it.
98+ checks.push(secretKey("token", token).then((key) => checkLimit(env.WEB_TOKEN_LIMIT, key)));
99+ } else if (session) {
83100 checks.push(secretKey("session", session).then((key) => checkLimit(env.WEB_SESSION_LIMIT, key)));
84101 } else {
85102 checks.push(checkLimit(env.WEB_ANONYMOUS_LIMIT, address));
87104 }
88105 const verdicts = await Promise.all(checks);
89106 if (!verdicts.includes("limited")) return null;
90− return tooManyRequests(session ? PAGE_MESSAGE : `${PAGE_MESSAGE.trimEnd()} Signed-in accounts have a higher limit.\n`);
107+ if (token && verdicts[1] === "limited") return tooManyRequests(TOKEN_PAGE_MESSAGE);
108+ return tooManyRequests(token || session ? PAGE_MESSAGE : `${PAGE_MESSAGE.trimEnd()} Signed-in accounts have a higher limit.\n`);
91109 }
92110
93111 /**
+1−1
116116 "readable ready_issues references registration repo_access resolve resolve_branch resolve_path resolve_slug " +
117117 "routes run run_context run_cost runner_groups runner_settings runners runs scorecards search search_memories " +
118118 "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " +
119− "user_for_session usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
119+ "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121121 "stargazers starred commit_checks shortcuts"
122122 ).split(" "),
+12−0
1+/**
2+ * Whether a request came from another site: its `Origin` names an origin
3+ * other than the site's own. Form posts from g1t's pages carry the site's
4+ * origin; a request without the header (not from a browser's form) is not
5+ * cross-site. lib/session.server.ts's `assertSameOrigin` refuses these on
6+ * every action, for a session cookie and an access token alike
7+ * (lib/website-token.ts).
8+ */
9+export function crossOrigin(request: Request): boolean {
10+ const origin = request.headers.get("origin");
11+ return Boolean(origin && origin !== new URL(request.url).origin);
12+}
+29−9
1515 import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
1616 import { codeGate } from "./workspace-nav";
1717 import { identity } from "./services.server";
18+import { TOKEN_CHALLENGE, bearerToken, tokenVerdict } from "./website-token";
19+import { crossOrigin } from "./same-origin";
1820
1921 const SESSION_COOKIE = "g1t_session";
2022 const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
2224 const viewerContext = createContext<Viewer>(null);
2325
2426 function sessionToken(request: Request): string | null {
27+ // A request with a token is the token's alone (lib/website-token.ts).
28+ if (bearerToken(request) !== null) return null;
2529 const cookies = request.headers.get("cookie") ?? "";
2630 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
2731 return match ? match[1] : null;
4044 * Everything on g1t lives in a workspace, so a confirmed account with none
4145 * is sent to create one, from wherever it was going, and returned there
4246 * afterwards.
47+ *
48+ * Automation can send an access token as `Authorization: Bearer` in place
49+ * of the cookie, when its owner let it use the website; the rules are in
50+ * lib/website-token.ts.
4351 */
44−export const viewerMiddleware: MiddlewareFunction<Response> = async ({
45− request,
46− context,
47−}) => {
48− const token = sessionToken(request);
49− if (!token) return;
50− const viewer = await identity.userForSession(token);
52+export const viewerMiddleware: MiddlewareFunction<Response> = async ({ request, context }, next) => {
53+ const verdict = await tokenVerdict(request, (token) => identity.userForAccessToken(token));
54+ if (verdict.kind === "refused") {
55+ const headers: HeadersInit = verdict.status === 401 ? { "www-authenticate": TOKEN_CHALLENGE } : {};
56+ throw data(verdict.body, { status: verdict.status, headers });
57+ }
58+ if (verdict.kind === "signed-out") {
59+ // The page as anyone signed out sees it, saying the token was not taken.
60+ const response = await next();
61+ response.headers.set("www-authenticate", TOKEN_CHALLENGE);
62+ return response;
63+ }
64+ let viewer: Viewer;
65+ if (verdict.kind === "signed-in") {
66+ viewer = verdict.user;
67+ } else {
68+ const token = sessionToken(request);
69+ if (!token) return;
70+ viewer = await identity.userForSession(token);
71+ }
5172 context.set(viewerContext, viewer);
5273
5374 const { pathname, search } = new URL(request.url);
142163
143164 /** Rejects cross-site form posts; call at the top of every action. */
144165 export function assertSameOrigin(request: Request): void {
145− const origin = request.headers.get("origin");
146− if (origin && origin !== new URL(request.url).origin) {
166+ if (crossOrigin(request)) {
147167 throw new Response("Cross-origin request rejected", { status: 403 });
148168 }
149169 }
+171−0
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" }],
128+ ["/acme/-/people", { 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/-/people", { 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+
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+});
+183−0
1+/**
2+ * Using the website with an access token: automation driving a browser
3+ * (Playwright and the like) sends `Authorization: Bearer g1t_…` on every
4+ * request and is signed in as the token's owner for that request alone.
5+ * lib/session.server.ts resolves it; these are the rules it follows.
6+ *
7+ * - Only the `Authorization` header is read, never a query string or a
8+ * cookie, and only a person's own token whose owner turned on "Use the
9+ * website as you" is accepted (`token.website`, identity's tokens.rs).
10+ * No cookie is set and no session is made: each request carries the
11+ * token, and expiry, deletion and a workspace revoking it apply at once.
12+ * - The header wins over a session cookie on the same request, so a
13+ * request is either a token's or a session's, never both.
14+ * - Browsers never send the header by themselves, so another site cannot
15+ * make one: the same-origin check on form posts (`assertSameOrigin`) is
16+ * the same for both and nothing about cookies is relaxed.
17+ * - What the token's owner does on the website is theirs, as for a
18+ * session, except what needs a real sign-in: tokens, two-factor
19+ * authentication, passwords, email addresses, SSH and signing keys,
20+ * applications, deleting the account or a workspace, giving a workspace
21+ * away, and payment methods ({@link needsRealSignIn}).
22+ * - A token that is not accepted is no one: a page loads signed out, and a
23+ * data request or form post is refused with a 401 that says why.
24+ */
25+
26+import type { User, Viewer } from "@g1t/contracts";
27+
28+/**
29+ * The page a request is for, as routes match it: decoded, any case, no
30+ * doubled or trailing slashes, and a client navigation's `.data` as its
31+ * page (as lib/confirm-gate.ts's `pageOf`).
32+ */
33+function pageOf(pathname: string): string {
34+ let path = pathname;
35+ try {
36+ path = decodeURIComponent(path);
37+ } catch {
38+ // Left as it came: routes cannot match a malformed escape either.
39+ }
40+ path = path.toLowerCase().replace(/\/{2,}/g, "/");
41+ if (path.endsWith(".data")) {
42+ path = path.slice(0, -".data".length);
43+ if (path === "/_root") path = "/";
44+ }
45+ if (path.length > 1) path = path.replace(/\/+$/, "");
46+ return path || "/";
47+}
48+
49+/** Where the docs explain it. */
50+export const WEBSITE_TOKEN_DOCS = "https://docs.g1t.sh/guides/authentication/#use-a-token-on-the-website";
51+
52+/**
53+ * The token in `Authorization: Bearer <token>`, or null when the request
54+ * has no such header. Any other scheme is not a token.
55+ */
56+export function bearerToken(request: Request): string | null {
57+ const header = request.headers.get("authorization");
58+ if (!header) return null;
59+ const match = /^\s*bearer\s+(\S+)\s*$/i.exec(header);
60+ return match ? match[1] : null;
61+}
62+
63+/**
64+ * The person a token resolved to, when it may use the website: a person
65+ * (not a workspace, an agent or a job) whose token has the website
66+ * permission. Null otherwise.
67+ */
68+export function websiteUser(viewer: Viewer): User | null {
69+ if (!viewer || (viewer.kind ?? "user") !== "user" || viewer.acting) return null;
70+ const token = viewer.token;
71+ if (!token?.website || token.job || token.deploy_key) return null;
72+ return viewer;
73+}
74+
75+/** Why a token was not accepted, for the 401. */
76+export const TOKEN_REFUSED =
77+ "This access token cannot be used on the website: it is not valid, has expired, or does not have “Use the website as you” turned on. " +
78+ `See ${WEBSITE_TOKEN_DOCS}`;
79+
80+/** The `WWW-Authenticate` header for a refused token. */
81+export const TOKEN_CHALLENGE = 'Bearer realm="g1t", error="invalid_token"';
82+
83+/** Pages a token never opens, whatever the method. */
84+const ALWAYS = [
85+ // Your tokens, two-factor authentication, emails, keys, the account
86+ // itself (its password and deleting it), applications you let in, and
87+ // how you sign in.
88+ /^\/settings\/(?:tokens|two-factor|emails|keys|account|applications|github)(?:\/|$)/,
89+ // Letting a device or an application in makes a token.
90+ /^\/(?:device|oauth\/authorize|auth\/github)(?:\/|$)/,
91+ // A workspace's own tokens, and its rules for and approvals of members' tokens.
92+ /^\/[^/]+\/-\/(?:tokens|personal-access-tokens)(?:\/|$)/,
93+];
94+
95+/** Form posts a token never makes: a page, the field that names the change, and the changes. */
96+const CHANGES: { page: RegExp; field: string; values: string[] }[] = [
97+ // Deleting a workspace.
98+ { page: /^\/[^/]+\/-\/settings$/, field: "intent", values: ["delete"] },
99+ // Giving a workspace to another owner.
100+ { page: /^\/[^/]+\/-\/people$/, field: "action", values: ["transfer"] },
101+ // Payment methods: the card on file, and the payment pages that take one.
102+ { page: /^\/[^/]+\/-\/billing$/, field: "intent", values: ["portal", "card-check", "subscribe", "buy-ai-credit"] },
103+];
104+
105+/** Whether a page opens nothing for a token whatever is posted to it. */
106+export function alwaysNeedsSignIn(pathname: string): boolean {
107+ const page = pageOf(pathname);
108+ return ALWAYS.some((pattern) => pattern.test(page));
109+}
110+
111+/**
112+ * Whether a request needs a real sign-in rather than a token. `form` is
113+ * the posted form, read only for the few pages where one change of many
114+ * does (null for none, or when it could not be read).
115+ */
116+export function needsRealSignIn(pathname: string, method: string, form: { get(name: string): unknown } | null): boolean {
117+ if (alwaysNeedsSignIn(pathname)) return true;
118+ if (method === "GET" || method === "HEAD" || !form) return false;
119+ const page = pageOf(pathname);
120+ return CHANGES.some((change) => change.page.test(page) && change.values.includes(String(form.get(change.field) ?? "")));
121+}
122+
123+/** Whether a posted form must be read to decide: a form post to one of {@link CHANGES}' pages. */
124+export function readsForm(pathname: string, method: string): boolean {
125+ if (method === "GET" || method === "HEAD") return false;
126+ const page = pageOf(pathname);
127+ return CHANGES.some((change) => change.page.test(page));
128+}
129+
130+/** What a token is told on a page that needs a real sign-in. */
131+export type NeedsSignIn = { needs_sign_in: true; message: string };
132+
133+export const NEEDS_SIGN_IN: NeedsSignIn = {
134+ needs_sign_in: true,
135+ message:
136+ "You are using g1t with an access token. Tokens, two-factor authentication, your password, email addresses and keys, " +
137+ "deleting an account or a workspace, giving a workspace away, and payment methods need you to sign in on g1t.sh yourself.",
138+};
139+
140+/** Whether an error's data is {@link NEEDS_SIGN_IN}, for the error page. */
141+export function isNeedsSignIn(value: unknown): value is NeedsSignIn {
142+ return typeof value === "object" && value !== null && (value as { needs_sign_in?: unknown }).needs_sign_in === true;
143+}
144+
145+/** Whether a request wants data (a loader's `.data` or a form post) rather than a page. */
146+export function wantsData(pathname: string, method: string): boolean {
147+ return pathname.endsWith(".data") || (method !== "GET" && method !== "HEAD");
148+}
149+
150+/** What to do with a request, as far as a token on it goes. */
151+export type TokenVerdict =
152+ /** No token: the session cookie, if any, decides. */
153+ | { kind: "none" }
154+ /** Signed in as the token's owner, for this request. */
155+ | { kind: "signed-in"; user: User }
156+ /** A page with a token not accepted: shown signed out, with a challenge header. */
157+ | { kind: "signed-out" }
158+ /** Refused: a 401 for a token not accepted, a 403 for what needs a real sign-in. */
159+ | { kind: "refused"; status: 401; body: string }
160+ | { kind: "refused"; status: 403; body: NeedsSignIn };
161+
162+/**
163+ * Decides a request's token. `lookup` resolves a token to whoever it
164+ * names (identity's `user_for_access_token`), checked on every request.
165+ */
166+export async function tokenVerdict(request: Request, lookup: (token: string) => Promise<Viewer>): Promise<TokenVerdict> {
167+ const token = bearerToken(request);
168+ if (token === null) return { kind: "none" };
169+ const { pathname } = new URL(request.url);
170+ const method = request.method.toUpperCase();
171+ const user = token.startsWith("g1t_") ? websiteUser(await lookup(token)) : null;
172+ if (!user) return wantsData(pathname, method) ? { kind: "refused", status: 401, body: TOKEN_REFUSED } : { kind: "signed-out" };
173+ let form: { get(name: string): unknown } | null = null;
174+ if (readsForm(pathname, method)) {
175+ try {
176+ form = await request.clone().formData();
177+ } catch {
178+ form = null;
179+ }
180+ }
181+ if (needsRealSignIn(pathname, method, form)) return { kind: "refused", status: 403, body: NEEDS_SIGN_IN };
182+ return { kind: "signed-in", user };
183+}
+6−0
7171 import { useSignUpCopy } from "./lib/registration";
7272 import { RELOADED_KEY, reloadFixes } from "./lib/stale-build";
7373 import { useNonce } from "./lib/nonce";
74+import { isNeedsSignIn } from "./lib/website-token";
7475 import { LiveNotifications } from "./components/notifications/live-notifications";
7576
7677
703704 if (typeof error.data === "string" && error.data) {
704705 details = error.data;
705706 }
707+ // A token asked for what needs a real sign-in (lib/website-token.ts).
708+ if (isNeedsSignIn(error.data)) {
709+ title = "This needs you to sign in";
710+ details = error.data.message;
711+ }
706712 } else if (import.meta.env.DEV && error instanceof Error) {
707713 details = error.message;
708714 stack = error.stack;
+2−0
124124 function anonymousPage(request: Request, pathname: string): boolean {
125125 if (request.method !== "GET") return false;
126126 if (/(?:^|;\s*)g1t_session=/.test(request.headers.get("cookie") ?? "")) return false;
127+ // A token signs the request in (app/lib/website-token.ts): never kept, never served a kept page.
128+ if (request.headers.has("authorization")) return false;
127129 return PUBLIC_TOP.test(pathname) || PUBLIC_PROJECT.test(pathname);
128130 }
129131
+1−0
6464 WEB_HEAVY_LIMIT?: RateLimitBinding;
6565 WEB_SESSION_LIMIT?: RateLimitBinding;
6666 WEB_ADDRESS_LIMIT?: RateLimitBinding;
67+ WEB_TOKEN_LIMIT?: RateLimitBinding;
6768 GIT_ANONYMOUS_LIMIT?: RateLimitBinding;
6869 GIT_SIGNED_LIMIT?: RateLimitBinding;
6970 /**
+2−1
6565 { "name": "WEB_SESSION_LIMIT", "namespace_id": "4203", "simple": { "limit": 1200, "period": 60 } },
6666 { "name": "WEB_ADDRESS_LIMIT", "namespace_id": "4204", "simple": { "limit": 3000, "period": 60 } },
6767 { "name": "GIT_ANONYMOUS_LIMIT", "namespace_id": "4205", "simple": { "limit": 120, "period": 60 } },
68− { "name": "GIT_SIGNED_LIMIT", "namespace_id": "4206", "simple": { "limit": 1200, "period": 60 } }
68+ { "name": "GIT_SIGNED_LIMIT", "namespace_id": "4206", "simple": { "limit": 1200, "period": 60 } },
69+ { "name": "WEB_TOKEN_LIMIT", "namespace_id": "4207", "simple": { "limit": 1000, "period": 60 } }
6970 ],
7071 // Logs of a tenth of requests: every page view is one, too many to keep all.
7172 "observability": { "enabled": true, "head_sampling_rate": 0.1 },
+2−0
111111 repository_selection: input.repositorySelection,
112112 repositories: input.repositories,
113113 permissions: input.permissions,
114+ website: input.website ?? false,
114115 }),
115116 updateToken: (actor, id, change, owner) =>
116117 call("update_token", {
122123 repository_selection: change.repositorySelection ?? null,
123124 repositories: change.repositories ?? null,
124125 permissions: change.permissions ?? null,
126+ website: change.website ?? null,
125127 }),
126128 getTokenPolicy: (slug, viewer) => call("get_token_policy", { slug, viewer }),
127129 setTokenPolicy: (actor, slug, change) =>
+10−1
7979 repo?: string;
8080 /** Set on a workflow job's token (`G1T_TOKEN`): the run and job it was made for. */
8181 job?: { run_id: string; job_id: string; pull_requests?: boolean };
82+ /**
83+ * A person's token whose owner let it use the website as them, sent as
84+ * `Authorization: Bearer` (apps/web, lib/website-token.ts). Not a scope.
85+ */
86+ website?: boolean;
8287 };
8388 /**
8489 * Workspaces the person belongs to but cannot use until they meet its
591596 workspaceOwned?: boolean;
592597 /** A workspace's own token with Repositories: admin, an admin of its repositories. */
593598 admin?: boolean;
599+ /** A personal token its owner let use the website as them. */
600+ website?: boolean;
594601 };
595602
596603 /** Which repositories a token reaches in its workspace: all, the selected ones, or public ones only. */
617624 repositories: string[];
618625 /** Each resource's level; left out is no access. */
619626 permissions: Partial<Record<ScopeResource, ScopeLevel>>;
627+ /** A personal token: whether it may use the website as you. Off unless set. */
628+ website?: boolean;
620629 };
621630
622631 /** A change to a token; what is left out stays. */
623−export type TokenChange = Partial<Pick<TokenInput, "name" | "description" | "repositorySelection" | "repositories" | "permissions">>;
632+export type TokenChange = Partial<Pick<TokenInput, "name" | "description" | "repositorySelection" | "repositories" | "permissions" | "website">>;
624633
625634 /** A workspace's rules for personal access tokens. */
626635 export type TokenPolicy = {
+2−0
5656 WEB_ADDRESS_LIMIT: { worker: "apps/web", namespaceId: 4204, limit: 3000, per: "address, every request that reaches the Worker" },
5757 GIT_ANONYMOUS_LIMIT: { worker: "apps/web", namespaceId: 4205, limit: 120, per: "address, git requests without credentials" },
5858 GIT_SIGNED_LIMIT: { worker: "apps/web", namespaceId: 4206, limit: 1200, per: "credential, git requests with credentials" },
59+ // The same as API_TOKEN_LIMIT: a token counts alike on the website and the API.
60+ WEB_TOKEN_LIMIT: { worker: "apps/web", namespaceId: 4207, limit: 1000, per: "token, pages and data requests with an access token" },
5961 // services/repos (src/lib.rs): what an anonymous clone can cost a repository's owner.
6062 PACK_FILL_LIMIT: { worker: "services/repos", namespaceId: 4301, limit: 30, per: "repository, packs written to the pack cache" },
6163 ANONYMOUS_FETCH_LIMIT: { worker: "services/repos", namespaceId: 4302, limit: 120, per: "repository, anonymous fetches the store answers" },