Merge branch 'worktree-agent-ad4439ce85a91ecb4' into integrate
32 files+776−310/32 viewed
| 76 | 76 | | Actor | Who did it: a person, an agent, or a workspace token. | | |
| 77 | 77 | | On behalf of | For an agent, the person it worked for: `g1t on behalf of syntaqx`. | | |
| 78 | 78 | | Run | The agent run, with its kind: `implement`, `review`, `update` and so on; or the workflow run whose job's token did it, as `workflow_job`. | | |
| 79 | − | | Credential | The id of the token used. | | |
| 79 | + | | Credential | The id of the token used: on the API, the MCP server or git, or on g1t.sh by a token [used on the website](/guides/authentication/#use-a-token-on-the-website). Empty for a person signed in on g1t.sh. | | |
| 80 | 80 | | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. | | |
| 81 | 81 | | Target | The repository, the issue or pull request number, and for git the refs it moved. | | |
| 82 | 82 | | Outcome | `allowed` or `denied`. | |
| 577 | 577 | ||
| 578 | 578 | ## Access tokens | |
| 579 | 579 | ||
| 580 | − | A token stands in for your password everywhere outside the website: | |
| 580 | + | A token stands in for your password everywhere outside the website, and on | |
| 581 | + | the website too when you turn that on for it: | |
| 581 | 582 | ||
| 582 | 583 | | Where | How to send it | | |
| 583 | 584 | | --- | --- | | |
| 584 | 585 | | git | As the password, with your username. | | |
| 585 | 586 | | API | `Authorization: Bearer g1t_…` | | |
| 586 | 587 | | MCP | The same header, set when you add the server. | | |
| 588 | + | | The website | The same header on every request, from automation that drives a browser. Only a token with **Use the website as you** turned on. See [use a token on the website](#use-a-token-on-the-website). | | |
| 587 | 589 | ||
| 588 | 590 | A token is shown once, when it is created; g1t stores only a hash of it. | |
| 589 | 591 | If you lose one, delete it and create another. Delete a token the moment | |
| ⋯ | |||
| 627 | 629 | 5. Under **Permissions**, set each resource the token needs to a level. | |
| 628 | 630 | **Read only**, **Agent** and **CI** fill in a [preset](#presets); | |
| 629 | 631 | **Clear** sets everything back to no access. | |
| 630 | − | 6. Select **Generate token**, and copy it. It is not shown again. | |
| 632 | + | 6. Leave **Use the website as you**, under **Website**, off unless the | |
| 633 | + | token is for automation that drives a browser. See | |
| 634 | + | [use a token on the website](#use-a-token-on-the-website). | |
| 635 | + | 7. Select **Generate token**, and copy it. It is not shown again. | |
| 631 | 636 | ||
| 632 | 637 | When you make a token for one workspace that | |
| 633 | 638 | [requires approval](#a-workspaces-rules-for-tokens), and you are not one of | |
| ⋯ | |||
| 641 | 646 | The list under Settings → Access tokens shows each token's name, status | |
| 642 | 647 | (pending, denied or revoked, with the owner's note), where it reaches, its | |
| 643 | 648 | permissions, and when it was made, last used and expires. Select a token to | |
| 644 | − | open its page, where you can change its name, description, repositories | |
| 645 | − | and permissions, and select **Save changes**. The token itself stays the | |
| 649 | + | open its page, where you can change its name, description, repositories, | |
| 650 | + | permissions and **Use the website as you**, and select **Save changes**. | |
| 651 | + | A token that can use the website is marked **Uses the website**. The token | |
| 652 | + | itself stays the | |
| 646 | 653 | same; the change applies from its next request. Widening a token made for | |
| 647 | 654 | a workspace that requires approval asks its owners again. Where it reaches | |
| 648 | 655 | and when it expires cannot change; make a new token instead. | |
| 649 | 656 | ||
| 650 | 657 | **Delete token**, at the bottom of its page, stops it working at once. | |
| 651 | 658 | ||
| 659 | + | ### Use a token on the website | |
| 660 | + | ||
| 661 | + | Automation that drives a browser, such as end-to-end tests or an agent | |
| 662 | + | checking how a page looks, can use g1t.sh as you with an access token, so it | |
| 663 | + | never types your password or a two-factor code. Each request it makes | |
| 664 | + | carries the token in the `Authorization` header; no cookie is set and no | |
| 665 | + | session is started. | |
| 666 | + | ||
| 667 | + | 1. Open [Settings → Access tokens](https://g1t.sh/settings/tokens) and | |
| 668 | + | select **New token**, or open a token you have. | |
| 669 | + | 2. Give it an expiration, and the permissions it needs for git, the API and | |
| 670 | + | MCP, if any. | |
| 671 | + | 3. Under **Website**, tick **Use the website as you**. It is off unless you | |
| 672 | + | tick it, and a workspace's own token cannot have it. | |
| 673 | + | 4. Select **Generate token** (or **Save changes**), and keep the token in a | |
| 674 | + | file only the automation can read. | |
| 675 | + | 5. Send `Authorization: Bearer g1t_…` on every request to g1t.sh. | |
| 676 | + | ||
| 677 | + | With [Playwright](https://playwright.dev), set the header on the browser | |
| 678 | + | context, reading the token from a file so it is never printed: | |
| 679 | + | ||
| 680 | + | ```js | |
| 681 | + | import { readFileSync } from "node:fs"; | |
| 682 | + | import { chromium } from "playwright"; | |
| 683 | + | ||
| 684 | + | const token = readFileSync(process.env.G1T_TOKEN_FILE, "utf8").trim(); | |
| 685 | + | const browser = await chromium.launch(); | |
| 686 | + | const context = await browser.newContext({ | |
| 687 | + | extraHTTPHeaders: { authorization: `Bearer ${token}` }, | |
| 688 | + | }); | |
| 689 | + | const page = await context.newPage(); | |
| 690 | + | await page.goto("https://g1t.sh/acme/rocket/pulls"); | |
| 691 | + | await page.screenshot({ path: "pulls.png", fullPage: true }); | |
| 692 | + | await browser.close(); | |
| 693 | + | ``` | |
| 694 | + | ||
| 695 | + | `extraHTTPHeaders` sends the header with every request the page makes, | |
| 696 | + | including to other addresses it loads files from. To send it to g1t.sh | |
| 697 | + | only, add it per request instead: | |
| 698 | + | ||
| 699 | + | ```js | |
| 700 | + | const context = await browser.newContext(); | |
| 701 | + | await context.route("https://g1t.sh/**", (route) => | |
| 702 | + | route.continue({ headers: { ...route.request().headers(), authorization: `Bearer ${token}` } }), | |
| 703 | + | ); | |
| 704 | + | ``` | |
| 705 | + | ||
| 706 | + | Any HTTP client works the same way: | |
| 707 | + | ||
| 708 | + | ```sh | |
| 709 | + | curl -H "Authorization: Bearer $(cat ~/.config/g1t/website-token)" https://g1t.sh/acme/rocket/pulls | |
| 710 | + | ``` | |
| 711 | + | ||
| 712 | + | On the website, the token acts as you in the workspaces it | |
| 713 | + | [reaches](#where-a-token-reaches). Its permissions are made for git, the API | |
| 714 | + | and MCP, and the website does not hold it to them: treat it as able to do | |
| 715 | + | anything there that you can. Keep it as safe as your password, and give it | |
| 716 | + | an expiration. | |
| 717 | + | ||
| 718 | + | - **Only the header counts.** A token in a query string or a cookie is | |
| 719 | + | ignored. A request with the header is the token's, even if it also has a | |
| 720 | + | session cookie. | |
| 721 | + | - **Checked on every request.** Deleting the token, its expiry, or a | |
| 722 | + | workspace [revoking it](#a-workspaces-rules-for-tokens) stops it at once. | |
| 723 | + | - **A token that is not accepted is no one.** One that is not valid, has | |
| 724 | + | expired, or does not have **Use the website as you** loads pages as | |
| 725 | + | someone signed out, with a `WWW-Authenticate` header saying the token was | |
| 726 | + | refused; the data requests and form posts pages make answer `401`. | |
| 727 | + | - **Form posts need nothing more.** Browsers never send the header by | |
| 728 | + | themselves, so a post with it needs no other proof it came from g1t.sh. | |
| 729 | + | A post from another site is still refused. | |
| 730 | + | - **Limits follow the token**: 1,000 requests a minute, as on the API. See | |
| 731 | + | [rate limits](/reference/rate-limits/). | |
| 732 | + | - **The audit log names it.** A change made this way is recorded as yours, | |
| 733 | + | with the token's id under **Credential**. See [the audit log](/guides/audit-log/). | |
| 734 | + | ||
| 735 | + | Some things always need you to sign in on g1t.sh yourself. With a token, | |
| 736 | + | these pages answer **This needs you to sign in** (`403`): | |
| 737 | + | ||
| 738 | + | | What | Where | | |
| 739 | + | | --- | --- | | |
| 740 | + | | Access tokens, yours and a workspace's, and a workspace's rules for and approvals of members' tokens | Settings → Access tokens; a workspace's Settings → Access tokens and Personal access tokens | | |
| 741 | + | | Two-factor authentication | Settings → Two-factor authentication | | |
| 742 | + | | Your username, and deleting your account | Settings → Account | | |
| 743 | + | | Email addresses, which reset your password | Settings → Emails | | |
| 744 | + | | SSH keys | Settings → SSH keys | | |
| 745 | + | | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub | | |
| 746 | + | | Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` | | |
| 747 | + | | Deleting a workspace, and giving it to another owner | A workspace's Settings and People | | |
| 748 | + | | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing | | |
| 749 | + | ||
| 652 | 750 | ### Permissions | |
| 653 | 751 | ||
| 654 | 752 | A permission is a resource and a level. A higher level includes the lower | |
| 106 | 106 | </Card> | |
| 107 | 107 | <Card title="Everything has an API" icon="book-open"> | |
| 108 | 108 | Repositories, issues, pull requests, agents and workflows are all a [REST endpoint](/reference/api/) and an [MCP tool action](/reference/mcp/) away, with | |
| 109 | − | [webhooks](/guides/webhooks/) for every event. | |
| 109 | + | [webhooks](/guides/webhooks/) for every event. Tests that drive a browser | |
| 110 | + | [use the website with a token](/guides/authentication/#use-a-token-on-the-website). | |
| 110 | 111 | </Card> | |
| 111 | 112 | </CardGrid> | |
| 112 | 113 |
| 20 | 20 | | Git over HTTPS, without credentials | Client IP address | 120 | | |
| 21 | 21 | | Anonymous clones of one repository that are not cached | Repository | 120 | | |
| 22 | 22 | | Pages on g1t.sh, signed in | Session | 1,200 | | |
| 23 | + | | Pages on g1t.sh, [with a token](/guides/authentication/#use-a-token-on-the-website) | Token | 1,000 | | |
| 23 | 24 | | Pages on g1t.sh, signed out | Client IP address | 600 | | |
| 24 | 25 | | Archive downloads, workflow run pages, logs and search, signed out | Client IP address | 30 | | |
| 25 | 26 | | [Raw files](/guides/git/#raw-files) on g1tusercontent.com | Client IP address, together with pages signed out | 600 | |
| 28 | 28 | policyNote, | |
| 29 | 29 | tokenPermissions, | |
| 30 | 30 | } from "../lib/access-tokens"; | |
| 31 | − | import { Checkbox } from "./ui/checkbox"; | |
| 31 | + | import { Checkbox, CheckboxOption } from "./ui/checkbox"; | |
| 32 | 32 | import { Hint } from "./ui/hint"; | |
| 33 | 33 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 34 | 34 | import { SelectField } from "./ui/select"; | |
| ⋯ | |||
| 214 | 214 | const [levels, setLevels] = useState<Permissions>(() => | |
| 215 | 215 | editing ? tokenPermissions(editing) : permissionsOf(presetScopes(preset) ?? null), | |
| 216 | 216 | ); | |
| 217 | + | const [website, setWebsite] = useState<boolean>(editing?.website ?? false); | |
| 217 | 218 | ||
| 218 | 219 | // The rules of the workspaces it would reach decide how long it may last. | |
| 219 | 220 | const reached = workspaceOwned ? [] : one ? [chosen] : reach === ALL_WORKSPACES ? workspaces : []; | |
| ⋯ | |||
| 387 | 388 | </p> | |
| 388 | 389 | )} | |
| 389 | 390 | </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 | + | )} | |
| 390 | 424 | </div> | |
| 391 | 425 | ); | |
| 392 | 426 | } | |
| 77 | 77 | <> | |
| 78 | 78 | {badge && <Badge tone={badge.tone}>{badge.label}</Badge>} | |
| 79 | 79 | {token.workspaceOwned && <Badge tone={token.admin ? "danger" : "neutral"}>{token.admin ? "Admin" : "Write"}</Badge>} | |
| 80 | + | {token.website && <Badge tone="warn">Uses the website</Badge>} | |
| 80 | 81 | </> | |
| 81 | 82 | ); | |
| 82 | 83 | } |
| 165 | 165 | assert.deepEqual(changesTo(token, { ...same, repositories: ["acme/web", "acme/api"] }), { repositorySelection: "selected", repositories: ["acme/web", "acme/api"] }); | |
| 166 | 166 | assert.deepEqual(changesTo(token, { ...same, repositorySelection: "all", repositories: [] }), { repositorySelection: "all" }); | |
| 167 | 167 | 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); | |
| 168 | 184 | }); |
| 9 | 9 | * The form posts `name`, `description`, `expires` (days, or `never`), | |
| 10 | 10 | * `workspace` (`*` for every workspace you belong to, `-` for none, or a | |
| 11 | 11 | * 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, | |
| 13 | 14 | * so the form posts the same with or without JavaScript. | |
| 14 | 15 | */ | |
| 15 | 16 | ||
| ⋯ | |||
| 180 | 181 | repositorySelection, | |
| 181 | 182 | repositories: repositorySelection === "selected" ? repositories : [], | |
| 182 | 183 | 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") ?? "")), | |
| 183 | 186 | }, | |
| 184 | 187 | }; | |
| 185 | 188 | } | |
| ⋯ | |||
| 275 | 278 | if (scopesOfPermissions(input.permissions).join(" ") !== scopesOfPermissions(tokenPermissions(token)).join(" ")) { | |
| 276 | 279 | change.permissions = input.permissions; | |
| 277 | 280 | } | |
| 281 | + | if (!token.workspaceOwned && Boolean(input.website) !== Boolean(token.website)) change.website = Boolean(input.website); | |
| 278 | 282 | if (input.repositorySelection !== (token.repositorySelection ?? "all")) change.repositorySelection = input.repositorySelection; | |
| 279 | 283 | if (input.repositorySelection === "selected" && (change.repositorySelection || !sameNames(input.repositories, token.repositories))) { | |
| 280 | 284 | change.repositorySelection = "selected"; | |
| 111 | 111 | assert.equal(refused?.status, 429); | |
| 112 | 112 | }); | |
| 113 | 113 | ||
| 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 | + | ||
| 114 | 128 | test("files the Worker serves itself are never limited", async () => { | |
| 115 | 129 | const env = { WEB_ADDRESS_LIMIT: binding(0), WEB_ANONYMOUS_LIMIT: binding(0) }; | |
| 116 | 130 | for (const path of ["/assets/app-1a2b.js", "/fonts/hanken.woff2", "/favicon.ico", "/robots.txt", "/llms.txt", "/sitemap.xml"]) { |
| 10 | 10 | * costly to answer (archives, run pages, logs, search). Signed in, by a | |
| 11 | 11 | * hash of the session cookie, higher: the session is not checked here, | |
| 12 | 12 | * 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. | |
| 14 | 17 | * | |
| 15 | 18 | * Static assets never reach the Worker (the assets binding answers them), | |
| 16 | 19 | * and the few files it serves itself are left out here too. Every limit | |
| ⋯ | |||
| 29 | 32 | WEB_HEAVY_LIMIT?: RateLimitBinding; | |
| 30 | 33 | WEB_SESSION_LIMIT?: RateLimitBinding; | |
| 31 | 34 | WEB_ADDRESS_LIMIT?: RateLimitBinding; | |
| 35 | + | WEB_TOKEN_LIMIT?: RateLimitBinding; | |
| 32 | 36 | GIT_ANONYMOUS_LIMIT?: RateLimitBinding; | |
| 33 | 37 | GIT_SIGNED_LIMIT?: RateLimitBinding; | |
| 34 | 38 | }; | |
| ⋯ | |||
| 58 | 62 | "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"; | |
| 59 | 63 | 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"; | |
| 60 | 64 | 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"; | |
| 61 | 66 | ||
| 62 | 67 | /** | |
| 63 | 68 | * The 429 for a git request past its limit, or null to go on. Git shows a | |
| ⋯ | |||
| 73 | 78 | return verdict === "limited" ? tooManyRequests(GIT_MESSAGE_ANONYMOUS) : null; | |
| 74 | 79 | } | |
| 75 | 80 | ||
| 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 | + | ||
| 76 | 89 | /** The 429 for a page or data request past its limit, or null to go on. */ | |
| 77 | 90 | export async function pageLimited(env: FrontDoorLimits, request: Request, pathname: string): Promise<Response | null> { | |
| 78 | 91 | if (unlimited(pathname)) return null; | |
| 79 | 92 | const address = `ip:${clientAddress(request)}`; | |
| 93 | + | const token = websiteToken(request.headers.get("authorization")); | |
| 80 | 94 | const session = sessionCookie(request.headers.get("cookie")); | |
| 81 | 95 | 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) { | |
| 83 | 100 | checks.push(secretKey("session", session).then((key) => checkLimit(env.WEB_SESSION_LIMIT, key))); | |
| 84 | 101 | } else { | |
| 85 | 102 | checks.push(checkLimit(env.WEB_ANONYMOUS_LIMIT, address)); | |
| ⋯ | |||
| 87 | 104 | } | |
| 88 | 105 | const verdicts = await Promise.all(checks); | |
| 89 | 106 | 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`); | |
| 91 | 109 | } | |
| 92 | 110 | ||
| 93 | 111 | /** | |
| 116 | 116 | "readable ready_issues references registration repo_access resolve resolve_branch resolve_path resolve_slug " + | |
| 117 | 117 | "routes run run_context run_cost runner_groups runner_settings runners runs scorecards search search_memories " + | |
| 118 | 118 | "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 " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | 121 | "stargazers starred commit_checks shortcuts" | |
| 122 | 122 | ).split(" "), |
| 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 | + | } |
| 15 | 15 | import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice"; | |
| 16 | 16 | import { codeGate } from "./workspace-nav"; | |
| 17 | 17 | import { identity } from "./services.server"; | |
| 18 | + | import { TOKEN_CHALLENGE, bearerToken, tokenVerdict } from "./website-token"; | |
| 19 | + | import { crossOrigin } from "./same-origin"; | |
| 18 | 20 | ||
| 19 | 21 | const SESSION_COOKIE = "g1t_session"; | |
| 20 | 22 | const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; | |
| ⋯ | |||
| 22 | 24 | const viewerContext = createContext<Viewer>(null); | |
| 23 | 25 | ||
| 24 | 26 | 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; | |
| 25 | 29 | const cookies = request.headers.get("cookie") ?? ""; | |
| 26 | 30 | const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies); | |
| 27 | 31 | return match ? match[1] : null; | |
| ⋯ | |||
| 40 | 44 | * Everything on g1t lives in a workspace, so a confirmed account with none | |
| 41 | 45 | * is sent to create one, from wherever it was going, and returned there | |
| 42 | 46 | * 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. | |
| 43 | 51 | */ | |
| 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 | + | // Thrown, so pages and client navigations show it as any error; the | |
| 55 | + | // Worker adds the 401's challenge header (workers/app.ts). | |
| 56 | + | if (verdict.kind === "refused") throw data(verdict.body, { status: verdict.status }); | |
| 57 | + | if (verdict.kind === "signed-out") { | |
| 58 | + | // The page as anyone signed out sees it, saying the token was not taken. | |
| 59 | + | const response = await next(); | |
| 60 | + | response.headers.set("www-authenticate", TOKEN_CHALLENGE); | |
| 61 | + | return response; | |
| 62 | + | } | |
| 63 | + | let viewer: Viewer; | |
| 64 | + | if (verdict.kind === "signed-in") { | |
| 65 | + | viewer = verdict.user; | |
| 66 | + | } else { | |
| 67 | + | const token = sessionToken(request); | |
| 68 | + | if (!token) return; | |
| 69 | + | viewer = await identity.userForSession(token); | |
| 70 | + | } | |
| 51 | 71 | context.set(viewerContext, viewer); | |
| 52 | 72 | ||
| 53 | 73 | const { pathname, search } = new URL(request.url); | |
| ⋯ | |||
| 142 | 162 | ||
| 143 | 163 | /** Rejects cross-site form posts; call at the top of every action. */ | |
| 144 | 164 | export function assertSameOrigin(request: Request): void { | |
| 145 | − | const origin = request.headers.get("origin"); | |
| 146 | − | if (origin && origin !== new URL(request.url).origin) { | |
| 165 | + | if (crossOrigin(request)) { | |
| 147 | 166 | throw new Response("Cross-origin request rejected", { status: 403 }); | |
| 148 | 167 | } | |
| 149 | 168 | } | |
| 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 | + | }); |
| 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 | + | } |
| 71 | 71 | import { useSignUpCopy } from "./lib/registration"; | |
| 72 | 72 | import { RELOADED_KEY, reloadFixes } from "./lib/stale-build"; | |
| 73 | 73 | import { useNonce } from "./lib/nonce"; | |
| 74 | + | import { isNeedsSignIn } from "./lib/website-token"; | |
| 74 | 75 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| 75 | 76 | ||
| 76 | 77 | ||
| ⋯ | |||
| 703 | 704 | if (typeof error.data === "string" && error.data) { | |
| 704 | 705 | details = error.data; | |
| 705 | 706 | } | |
| 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 | + | } | |
| 706 | 712 | } else if (import.meta.env.DEV && error instanceof Error) { | |
| 707 | 713 | details = error.message; | |
| 708 | 714 | stack = error.stack; | |
| 12 | 12 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| 13 | 13 | import { registryWorkspace, servicePath } from "../app/lib/registry-paths"; | |
| 14 | 14 | import { usercontentPath } from "../app/lib/usercontent"; | |
| 15 | + | import { TOKEN_CHALLENGE } from "../app/lib/website-token"; | |
| 15 | 16 | import { serveUsercontent } from "./usercontent"; | |
| 16 | 17 | ||
| 17 | 18 | const loadBuild = () => import("virtual:react-router/server-build"); | |
| ⋯ | |||
| 103 | 104 | // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts). | |
| 104 | 105 | const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request))); | |
| 105 | 106 | if (anonymousPage(request, pathname)) return servePublic(env, request, ctx, render); | |
| 106 | − | return render(); | |
| 107 | + | const answer = await render(); | |
| 108 | + | // A token the site refused (app/lib/website-token.ts) is told so as the | |
| 109 | + | // API tells it: React Router drops the headers of what middleware throws. | |
| 110 | + | if (answer.status === 401 && request.headers.has("authorization")) answer.headers.set("www-authenticate", TOKEN_CHALLENGE); | |
| 111 | + | return answer; | |
| 107 | 112 | } | |
| 108 | 113 | ||
| 109 | 114 | /** | |
| ⋯ | |||
| 124 | 129 | function anonymousPage(request: Request, pathname: string): boolean { | |
| 125 | 130 | if (request.method !== "GET") return false; | |
| 126 | 131 | if (/(?:^|;\s*)g1t_session=/.test(request.headers.get("cookie") ?? "")) return false; | |
| 132 | + | // A token signs the request in (app/lib/website-token.ts): never kept, never served a kept page. | |
| 133 | + | if (request.headers.has("authorization")) return false; | |
| 127 | 134 | return PUBLIC_TOP.test(pathname) || PUBLIC_PROJECT.test(pathname); | |
| 128 | 135 | } | |
| 129 | 136 | ||
| 64 | 64 | WEB_HEAVY_LIMIT?: RateLimitBinding; | |
| 65 | 65 | WEB_SESSION_LIMIT?: RateLimitBinding; | |
| 66 | 66 | WEB_ADDRESS_LIMIT?: RateLimitBinding; | |
| 67 | + | WEB_TOKEN_LIMIT?: RateLimitBinding; | |
| 67 | 68 | GIT_ANONYMOUS_LIMIT?: RateLimitBinding; | |
| 68 | 69 | GIT_SIGNED_LIMIT?: RateLimitBinding; | |
| 69 | 70 | /** |
| 65 | 65 | { "name": "WEB_SESSION_LIMIT", "namespace_id": "4203", "simple": { "limit": 1200, "period": 60 } }, | |
| 66 | 66 | { "name": "WEB_ADDRESS_LIMIT", "namespace_id": "4204", "simple": { "limit": 3000, "period": 60 } }, | |
| 67 | 67 | { "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 } } | |
| 69 | 70 | ], | |
| 70 | 71 | // Logs of a tenth of requests: every page view is one, too many to keep all. | |
| 71 | 72 | "observability": { "enabled": true, "head_sampling_rate": 0.1 }, |
| 122 | 122 | .and_then(|acting| acting.run()) | |
| 123 | 123 | .map(|run| run.kind.as_str().to_owned()) | |
| 124 | 124 | .or_else(|| job.map(|_| WORKFLOW_JOB.to_owned())), | |
| 125 | + | // The token used, whatever the surface: a person's or a | |
| 126 | + | // workspace's on the API, MCP or git, and a person's on the | |
| 127 | + | // website (apps/web, app/lib/website-token.ts). | |
| 125 | 128 | credential_id: acting | |
| 126 | 129 | .map(|acting| acting.credential_id.clone()) | |
| 127 | − | .or_else(|| job.map(|(token, _)| token.token_id.clone())), | |
| 130 | + | .or_else(|| job.map(|(token, _)| token.token_id.clone())) | |
| 131 | + | .or_else(|| user.token.as_deref().map(|token| token.token_id.clone()).filter(|id| !id.is_empty())), | |
| 128 | 132 | } | |
| 129 | 133 | } | |
| 130 | 134 | ||
| ⋯ | |||
| 342 | 346 | }); | |
| 343 | 347 | assert_eq!(person.actor_kind, Some(ActorKind::Person)); | |
| 344 | 348 | assert!(!person.records_reads()); | |
| 349 | + | assert_eq!(person.credential_id, None, "signed in: no token"); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn a_person_using_a_token_is_recorded_with_it_on_any_surface() { | |
| 354 | + | let user = User { | |
| 355 | + | id: "usr_1".to_owned(), | |
| 356 | + | username: "syntaqx".to_owned(), | |
| 357 | + | token: Some(Box::new(crate::scopes::TokenAccess { | |
| 358 | + | token_id: "tok_web".to_owned(), | |
| 359 | + | website: true, | |
| 360 | + | ..crate::scopes::TokenAccess::default() | |
| 361 | + | })), | |
| 362 | + | ..User::default() | |
| 363 | + | }; | |
| 364 | + | let actor = AuditActor::of(&user); | |
| 365 | + | assert_eq!(actor.actor_kind, Some(ActorKind::Person)); | |
| 366 | + | assert_eq!(actor.actor, "syntaqx"); | |
| 367 | + | assert_eq!(actor.credential_id.as_deref(), Some("tok_web")); | |
| 368 | + | assert!(!actor.records_reads()); | |
| 345 | 369 | } | |
| 346 | 370 | ||
| 347 | 371 | #[test] | |
| 78 | 78 | /// admin of the workspace's repositories rather than with Write. | |
| 79 | 79 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 80 | 80 | pub admin: bool, | |
| 81 | + | /// A person's token its owner let use the website (g1t.sh) as them, | |
| 82 | + | /// with `Authorization: Bearer`. See [`crate::scopes::TokenAccess::website`]. | |
| 83 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 84 | + | pub website: bool, | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 83 | 87 | /// `sign_in`: verifies a username, or any confirmed email address of the |
| 681 | 681 | /// `repo` is the one repository it reaches. | |
| 682 | 682 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 683 | 683 | pub deploy_key: Option<String>, | |
| 684 | + | /// Set on a person's token whose owner let it use the website (g1t.sh) | |
| 685 | + | /// as them, sent as `Authorization: Bearer`. Not a scope: no preset, | |
| 686 | + | /// full access or OAuth grant includes it, and git, the API and MCP | |
| 687 | + | /// ignore it. | |
| 688 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 689 | + | pub website: bool, | |
| 684 | 690 | } | |
| 685 | 691 | ||
| 686 | 692 | /// Which repositories a token reaches in the workspace it is made for. | |
| ⋯ | |||
| 1750 | 1756 | })) | |
| 1751 | 1757 | .unwrap(); | |
| 1752 | 1758 | assert_eq!(older, access); | |
| 1759 | + | // Using the website is off unless set, and said only when on. | |
| 1760 | + | assert!(!access.website); | |
| 1761 | + | assert!(wire_of(&access).get("website").is_none()); | |
| 1762 | + | let website = TokenAccess { website: true, ..access }; | |
| 1763 | + | assert_eq!(wire_of(&website)["website"], json!(true)); | |
| 1764 | + | // Never part of full access. | |
| 1765 | + | assert!(!TokenAccess::full().website); | |
| 1766 | + | } | |
| 1767 | + | ||
| 1768 | + | fn wire_of(access: &TokenAccess) -> serde_json::Value { | |
| 1769 | + | serde_json::to_value(access).unwrap() | |
| 1753 | 1770 | } | |
| 1754 | 1771 | ||
| 1755 | 1772 | /// The site's copy of the table, `packages/contracts/src/scopes.ts`, | |
| 111 | 111 | /// out or `none` is no access. See [`crate::scopes::resolve_permissions`]. | |
| 112 | 112 | #[serde(default)] | |
| 113 | 113 | pub permissions: BTreeMap<String, String>, | |
| 114 | + | /// Whether it may be used on the website as its owner: a person's token | |
| 115 | + | /// only. Never part of its permissions, so full access does not include it. | |
| 116 | + | #[serde(default)] | |
| 117 | + | pub website: bool, | |
| 114 | 118 | } | |
| 115 | 119 | ||
| 116 | 120 | /// `update_token`: a person changes a token of theirs, or, as an owner, | |
| ⋯ | |||
| 137 | 141 | pub repositories: Option<Vec<String>>, | |
| 138 | 142 | #[serde(default)] | |
| 139 | 143 | pub permissions: Option<BTreeMap<String, String>>, | |
| 144 | + | /// Whether it may be used on the website; a person's token only. | |
| 145 | + | #[serde(default)] | |
| 146 | + | pub website: Option<bool>, | |
| 140 | 147 | } | |
| 141 | 148 | ||
| 142 | 149 | /// A workspace's rules for personal access tokens. | |
| 560 | 560 | # Signed in: your g1t_session cookie's value, from DevTools; never printed | |
| 561 | 561 | $env:G1T_SESSION = "<64 hex>" | |
| 562 | 562 | powershell -File scripts/perf/measure.ps1 -Runs 7 -Pull 12 -Issue 11 -Out before-signed-in.csv | |
| 563 | + | # Or signed in with an access token that may use the website: the path of | |
| 564 | + | # the file holding it (the token is never printed or put on a command line) | |
| 565 | + | $env:G1T_TOKEN_FILE = "$HOME\.config\g1t\website-token" | |
| 566 | + | powershell -File scripts/perf/measure.ps1 -Runs 7 -Out before-token.csv | |
| 563 | 567 | ``` | |
| 564 | 568 | ||
| 565 | 569 | It prints p50 and p90 of the server's share (TLS handshake done to first |
| 58 | 58 | | `WEB_ADDRESS_LIMIT` | 4204 | 3,000 | address | web: every request that reaches the Worker; stops made-up cookies getting round the signed-out limit | | |
| 59 | 59 | | `GIT_ANONYMOUS_LIMIT` | 4205 | 120 | address | web: git smart HTTP without `Authorization` (~40 clones) | | |
| 60 | 60 | | `GIT_SIGNED_LIMIT` | 4206 | 1,200 | `Authorization` hash | web: git smart HTTP with credentials, sandboxes' included | | |
| 61 | + | | `WEB_TOKEN_LIMIT` | 4207 | 1,000 | token hash | web: pages and data requests with `Authorization: Bearer` (a token used on the website, not checked there); the same limit as `API_TOKEN_LIMIT`, counted apart | | |
| 61 | 62 | | `PACK_FILL_LIMIT` | 4301 | 30 | repository id | repos `src/limits.rs`: packs written to `GIT_PACKS`; past it the pack is streamed, not kept | | |
| 62 | 63 | | `ANONYMOUS_FETCH_LIMIT` | 4302 | 120 | repository id | repos: anonymous fetches the git store answers (cache hits never count) | | |
| 63 | 64 | | `API_ANONYMOUS_LIMIT` | 4401 | 60 | `rest:`/`mcp:` + address | api `src/limits.rs`: no token, or a wrong one | |
| 111 | 111 | repository_selection: input.repositorySelection, | |
| 112 | 112 | repositories: input.repositories, | |
| 113 | 113 | permissions: input.permissions, | |
| 114 | + | website: input.website ?? false, | |
| 114 | 115 | }), | |
| 115 | 116 | updateToken: (actor, id, change, owner) => | |
| 116 | 117 | call("update_token", { | |
| ⋯ | |||
| 122 | 123 | repository_selection: change.repositorySelection ?? null, | |
| 123 | 124 | repositories: change.repositories ?? null, | |
| 124 | 125 | permissions: change.permissions ?? null, | |
| 126 | + | website: change.website ?? null, | |
| 125 | 127 | }), | |
| 126 | 128 | getTokenPolicy: (slug, viewer) => call("get_token_policy", { slug, viewer }), | |
| 127 | 129 | setTokenPolicy: (actor, slug, change) => | |
| 79 | 79 | repo?: string; | |
| 80 | 80 | /** Set on a workflow job's token (`G1T_TOKEN`): the run and job it was made for. */ | |
| 81 | 81 | 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; | |
| 82 | 87 | }; | |
| 83 | 88 | /** | |
| 84 | 89 | * Workspaces the person belongs to but cannot use until they meet its | |
| ⋯ | |||
| 591 | 596 | workspaceOwned?: boolean; | |
| 592 | 597 | /** A workspace's own token with Repositories: admin, an admin of its repositories. */ | |
| 593 | 598 | admin?: boolean; | |
| 599 | + | /** A personal token its owner let use the website as them. */ | |
| 600 | + | website?: boolean; | |
| 594 | 601 | }; | |
| 595 | 602 | ||
| 596 | 603 | /** Which repositories a token reaches in its workspace: all, the selected ones, or public ones only. */ | |
| ⋯ | |||
| 617 | 624 | repositories: string[]; | |
| 618 | 625 | /** Each resource's level; left out is no access. */ | |
| 619 | 626 | permissions: Partial<Record<ScopeResource, ScopeLevel>>; | |
| 627 | + | /** A personal token: whether it may use the website as you. Off unless set. */ | |
| 628 | + | website?: boolean; | |
| 620 | 629 | }; | |
| 621 | 630 | ||
| 622 | 631 | /** 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">>; | |
| 624 | 633 | ||
| 625 | 634 | /** A workspace's rules for personal access tokens. */ | |
| 626 | 635 | export type TokenPolicy = { | |
| 56 | 56 | WEB_ADDRESS_LIMIT: { worker: "apps/web", namespaceId: 4204, limit: 3000, per: "address, every request that reaches the Worker" }, | |
| 57 | 57 | GIT_ANONYMOUS_LIMIT: { worker: "apps/web", namespaceId: 4205, limit: 120, per: "address, git requests without credentials" }, | |
| 58 | 58 | 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" }, | |
| 59 | 61 | // services/repos (src/lib.rs): what an anonymous clone can cost a repository's owner. | |
| 60 | 62 | PACK_FILL_LIMIT: { worker: "services/repos", namespaceId: 4301, limit: 30, per: "repository, packs written to the pack cache" }, | |
| 61 | 63 | ANONYMOUS_FETCH_LIMIT: { worker: "services/repos", namespaceId: 4302, limit: 120, per: "repository, anonymous fetches the store answers" }, |
| 16 | 16 | powershell -File scripts/perf/measure.ps1 -Runs 7 -Out before.csv | |
| 17 | 17 | ||
| 18 | 18 | .EXAMPLE | |
| 19 | + | # Signed in with an access token that has "Use the website as you" on: | |
| 20 | + | # G1T_TOKEN_FILE names the file holding it. The script never prints it. | |
| 21 | + | $env:G1T_TOKEN_FILE = "$HOME\.config\g1t\website-token" | |
| 22 | + | powershell -File scripts/perf/measure.ps1 -Runs 7 | |
| 23 | + | ||
| 24 | + | .EXAMPLE | |
| 19 | 25 | # As a browser sees it: React Router streams to browsers and renders the | |
| 20 | 26 | # whole page first for anything it takes for a crawler, which curl's own | |
| 21 | 27 | # user agent is. Compare the two to see what streaming saves. | |
| ⋯ | |||
| 60 | 66 | $signedIn = [bool]$env:G1T_SESSION | |
| 61 | 67 | $cookieArgs = @() | |
| 62 | 68 | if ($signedIn) { $cookieArgs = @("-H", "Cookie: g1t_session=$($env:G1T_SESSION)") } | |
| 69 | + | # Signed in with an access token that may use the website: G1T_TOKEN_FILE | |
| 70 | + | # names the file holding it. curl reads the header from a temporary file, | |
| 71 | + | # so the token is never on a command line or printed. It wins over | |
| 72 | + | # G1T_SESSION, as on the site. | |
| 73 | + | $tokenHeaderFile = $null | |
| 74 | + | if ($env:G1T_TOKEN_FILE) { | |
| 75 | + | if (-not (Test-Path -LiteralPath $env:G1T_TOKEN_FILE -PathType Leaf)) { throw "G1T_TOKEN_FILE does not name a file." } | |
| 76 | + | $tokenHeaderFile = [System.IO.Path]::GetTempFileName() | |
| 77 | + | $header = "Authorization: Bearer " + (Get-Content -Raw -LiteralPath $env:G1T_TOKEN_FILE).Trim() | |
| 78 | + | [System.IO.File]::WriteAllText($tokenHeaderFile, $header) | |
| 79 | + | Remove-Variable header | |
| 80 | + | $cookieArgs = @("-H", "@$tokenHeaderFile") | |
| 81 | + | $signedIn = $true | |
| 82 | + | } | |
| 63 | 83 | $agentArgs = @() | |
| 64 | 84 | if ($BrowserUA) { | |
| 65 | 85 | $agentArgs = @("-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") | |
| ⋯ | |||
| 116 | 136 | return ($shown -join " ") | |
| 117 | 137 | } | |
| 118 | 138 | ||
| 119 | − | Write-Host "Measuring $Base, $Runs runs each, $(if ($signedIn) { 'signed in' } else { 'signed out' }), $(if ($BrowserUA) { 'as a browser (streamed)' } else { 'as curl (a crawler: whole page first)' })." | |
| 139 | + | Write-Host "Measuring $Base, $Runs runs each, $(if ($tokenHeaderFile) { 'signed in with a token' } elseif ($signedIn) { 'signed in' } else { 'signed out' }), $(if ($BrowserUA) { 'as a browser (streamed)' } else { 'as curl (a crawler: whole page first)' })." | |
| 140 | + | try { | |
| 120 | 141 | $rows = foreach ($path in $Paths) { | |
| 121 | 142 | $url = "$Base$path" | |
| 122 | 143 | $null = Measure-Once $url # warm the connection and the isolate | |
| ⋯ | |||
| 135 | 156 | "Server-Timing (last run)" = Summarize-Timing $last.Timing | |
| 136 | 157 | } | |
| 137 | 158 | } | |
| 159 | + | } finally { | |
| 160 | + | if ($tokenHeaderFile) { Remove-Item -LiteralPath $tokenHeaderFile -ErrorAction SilentlyContinue } | |
| 161 | + | } | |
| 138 | 162 | ||
| 139 | 163 | $rows | Format-Table -AutoSize -Wrap | |
| 140 | 164 | if ($Out) { | |
| 1 | + | -- A person's access token may be let use the website (g1t.sh) as them, | |
| 2 | + | -- sent as `Authorization: Bearer`, so automation driving a browser can | |
| 3 | + | -- work there without signing in. Off unless its owner turns it on when | |
| 4 | + | -- making or changing the token. It is not a scope: full access and every | |
| 5 | + | -- existing token stay off. See src/tokens.rs and apps/web's | |
| 6 | + | -- app/lib/website-token.ts. | |
| 7 | + | ALTER TABLE access_tokens ADD COLUMN website INTEGER NOT NULL DEFAULT 0; |
| 51 | 51 | ||
| 52 | 52 | const DAY_SECONDS: u64 = 86_400; | |
| 53 | 53 | ||
| 54 | + | /// Why a workspace's token cannot use the website: it acts as no person. | |
| 55 | + | const WORKSPACE_TOKEN_NO_WEBSITE: &str = "Only a person's token can use the website: a workspace's token acts as no one who can sign in."; | |
| 56 | + | ||
| 54 | 57 | /// What a token row says about its reach, read with it when it is used. | |
| 55 | 58 | /// Numbers arrive from D1 as floats. | |
| 56 | 59 | #[derive(Clone, Debug, Default, Deserialize)] | |
| ⋯ | |||
| 107 | 110 | review_reason: Option<String>, | |
| 108 | 111 | #[serde(default)] | |
| 109 | 112 | owner_workspace: Option<String>, | |
| 113 | + | /// 1 when its owner let it use the website (migration 0043). | |
| 114 | + | #[serde(default)] | |
| 115 | + | website: Option<f64>, | |
| 110 | 116 | } | |
| 111 | 117 | ||
| 112 | 118 | impl TokenRowMore { | |
| ⋯ | |||
| 120 | 126 | info.repository_selection = self.repository_selection.as_deref().and_then(RepositorySelection::parse).unwrap_or_default(); | |
| 121 | 127 | info.status = TokenStatus::parse(self.status.as_deref().unwrap_or("active")); | |
| 122 | 128 | info.review_reason = self.review_reason.clone(); | |
| 129 | + | info.website = self.website.is_some_and(|on| on >= 1.0) && self.workspace_id.is_none(); | |
| 123 | 130 | } | |
| 124 | 131 | } | |
| 125 | 132 | ||
| ⋯ | |||
| 431 | 438 | Outcome::Ok(id) => id, | |
| 432 | 439 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 433 | 440 | }; | |
| 441 | + | if a.website { | |
| 442 | + | return Ok(Outcome::fail(FailureCode::Invalid, WORKSPACE_TOKEN_NO_WEBSITE)); | |
| 443 | + | } | |
| 434 | 444 | let scopes = match resolve_permissions(&a.permissions, false) { | |
| 435 | 445 | Ok(scopes) => scopes, | |
| 436 | 446 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| ⋯ | |||
| 532 | 542 | let description = tidy(a.description.as_deref()); | |
| 533 | 543 | let mut statements = vec![self | |
| 534 | 544 | .db | |
| 535 | − | .prepare("UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, description = ?, status = ? WHERE id = ?") | |
| 545 | + | .prepare("UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, description = ?, status = ?, website = ? WHERE id = ?") | |
| 536 | 546 | .bind(&[ | |
| 537 | 547 | text(workspace_id.as_deref()), | |
| 538 | 548 | selection.as_str().into(), | |
| 539 | 549 | text(description.as_deref()), | |
| 540 | 550 | status.as_str().into(), | |
| 551 | + | JsValue::from(u8::from(a.website)), | |
| 541 | 552 | created.info.id.as_str().into(), | |
| 542 | 553 | ])?]; | |
| 543 | 554 | statements.extend(self.repository_rows(&created.info.id, &repo_ids)?); | |
| ⋯ | |||
| 547 | 558 | created.info.repository_selection = selection; | |
| 548 | 559 | created.info.repositories = qualified_all(slug.as_deref(), &repo_ids, &a.repositories); | |
| 549 | 560 | created.info.status = status; | |
| 561 | + | created.info.website = a.website; | |
| 550 | 562 | // In the person's security log and their workspaces' audit logs. | |
| 551 | 563 | self.log_security(&a.actor.id, "token_created", Some(&created.info.name), None).await; | |
| 552 | − | self.audit_account(&a.actor, "token.created", &format!("Created access token {}", created.info.name)).await; | |
| 564 | + | let website = if a.website { " that can use the website" } else { "" }; | |
| 565 | + | self.audit_account(&a.actor, "token.created", &format!("Created access token {}{website}", created.info.name)).await; | |
| 553 | 566 | if let (Some(slug), TokenStatus::Pending) = (&slug, status) { | |
| 554 | 567 | self.ask_owners(&a.actor, slug, &created.info).await?; | |
| 555 | 568 | } | |
| ⋯ | |||
| 618 | 631 | sets.push(("scopes", scopes_text(&scopes).into())); | |
| 619 | 632 | widened = true; | |
| 620 | 633 | } | |
| 634 | + | // Using the website: a person's token only. Turning it on is | |
| 635 | + | // asking for more; turning it off is not. | |
| 636 | + | if let Some(website) = a.website { | |
| 637 | + | if website && !personal { | |
| 638 | + | return Ok(Outcome::fail(FailureCode::Invalid, WORKSPACE_TOKEN_NO_WEBSITE)); | |
| 639 | + | } | |
| 640 | + | if personal { | |
| 641 | + | sets.push(("website", JsValue::from(u8::from(website)))); | |
| 642 | + | widened |= website; | |
| 643 | + | } | |
| 644 | + | } | |
| 621 | 645 | if let Some(selection) = a.repository_selection { | |
| 622 | 646 | if selection == RepositorySelection::Selected && repo_workspace.is_none() { | |
| 623 | 647 | return Ok(Outcome::fail(FailureCode::Invalid, "This token is not made for one workspace, so it cannot select repositories.")); | |
| 29 | 29 | access_tokens.last_used_at, users.username AS created_by, access_tokens.scopes, | |
| 30 | 30 | access_tokens.expires_at, access_tokens.description, access_tokens.admin, | |
| 31 | 31 | access_tokens.workspace_id, access_tokens.repository_selection, | |
| 32 | − | access_tokens.status, access_tokens.review_reason, | |
| 32 | + | access_tokens.status, access_tokens.review_reason, access_tokens.website, | |
| 33 | 33 | (SELECT slug FROM workspaces WHERE workspaces.id = access_tokens.owner_workspace_id) AS owner_workspace"; | |
| 34 | 34 | ||
| 35 | 35 | /// Who a new token belongs to. | |
| ⋯ | |||
| 121 | 121 | job_run_id: Option<String>, | |
| 122 | 122 | #[serde(default)] | |
| 123 | 123 | job_pulls: Option<u32>, | |
| 124 | + | /// 1 when its owner let it use the website (migration 0043). | |
| 125 | + | #[serde(default)] | |
| 126 | + | website: Option<f64>, | |
| 124 | 127 | /// The workspace it is made for, its repositories and status, a | |
| 125 | 128 | /// workspace token's Admin, and when it was made and expires, for the | |
| 126 | 129 | /// rules of the workspaces it reaches (token_reach.rs). | |
| ⋯ | |||
| 138 | 141 | value.map_or(JsValue::NULL, JsValue::from) | |
| 139 | 142 | } | |
| 140 | 143 | ||
| 144 | + | /// Whether a token being used may be used on the website as its owner: one | |
| 145 | + | /// a person made and turned that on for, never a workspace's, a job's or an | |
| 146 | + | /// agent's (apps/web, app/lib/website-token.ts). | |
| 147 | + | fn website_allowed(presented: &Presented) -> bool { | |
| 148 | + | presented.website.is_some_and(|on| on >= 1.0) | |
| 149 | + | && presented.user_id.is_some() | |
| 150 | + | && presented.workspace_id.is_none() | |
| 151 | + | && presented.job_id.is_none() | |
| 152 | + | && presented.agent_scope.is_none() | |
| 153 | + | } | |
| 154 | + | ||
| 141 | 155 | impl Identity { | |
| 142 | 156 | pub async fn user_for_access_token(&self, token: &str) -> Result<Viewer> { | |
| 143 | 157 | if !token.starts_with(TOKEN_PREFIX) { | |
| ⋯ | |||
| 148 | 162 | .prepare(format!( | |
| 149 | 163 | "SELECT id, user_id, workspace_id, last_used_at, agent_scope, scopes, name, | |
| 150 | 164 | repo, job_id, job_run_id, job_pulls, created_at, expires_at, | |
| 151 | − | owner_workspace_id, repository_selection, status, admin | |
| 165 | + | owner_workspace_id, repository_selection, status, admin, website | |
| 152 | 166 | FROM access_tokens | |
| 153 | 167 | WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > {SQL_NOW})" | |
| 154 | 168 | )) | |
| ⋯ | |||
| 178 | 192 | let mut viewer = match (&presented.user_id, &presented.workspace_id) { | |
| 179 | 193 | (Some(user_id), _) => { | |
| 180 | 194 | self.find_user( | |
| 181 | − | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| 195 | + | "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified, avatar | |
| 182 | 196 | FROM users WHERE id = ? AND deleted_at IS NULL", | |
| 183 | 197 | user_id, | |
| 184 | 198 | ) | |
| ⋯ | |||
| 204 | 218 | }), | |
| 205 | 219 | _ => None, | |
| 206 | 220 | }, | |
| 221 | + | website: website_allowed(&presented), | |
| 207 | 222 | ..TokenAccess::default() | |
| 208 | 223 | })); | |
| 209 | 224 | // What it reaches: the workspace it is made for and its | |
| ⋯ | |||
| 542 | 557 | assert_eq!(Grant::asked(&None).scopes_column(), "*"); | |
| 543 | 558 | assert_eq!(Grant::asked(&Some(vec![])).scopes_column(), ""); | |
| 544 | 559 | } | |
| 560 | + | ||
| 561 | + | #[test] | |
| 562 | + | fn only_a_persons_own_token_with_it_turned_on_uses_the_website() { | |
| 563 | + | let row = |extra: serde_json::Value| { | |
| 564 | + | let mut value = serde_json::json!({ "id": "tok_1", "user_id": "usr_1", "workspace_id": null, "last_used_at": null, "agent_scope": null, "scopes": "*", "website": 1.0 }); | |
| 565 | + | for (key, field) in extra.as_object().unwrap() { | |
| 566 | + | value[key] = field.clone(); | |
| 567 | + | } | |
| 568 | + | serde_json::from_value::<Presented>(value).unwrap() | |
| 569 | + | }; | |
| 570 | + | assert!(website_allowed(&row(serde_json::json!({})))); | |
| 571 | + | assert!(!website_allowed(&row(serde_json::json!({ "website": 0.0 })))); | |
| 572 | + | assert!(!website_allowed(&row(serde_json::json!({ "website": null })))); | |
| 573 | + | // A workspace's token, a job's token and an agent's never do. | |
| 574 | + | assert!(!website_allowed(&row(serde_json::json!({ "user_id": null, "workspace_id": "wsp_1" })))); | |
| 575 | + | assert!(!website_allowed(&row(serde_json::json!({ "job_id": "job_1" })))); | |
| 576 | + | assert!(!website_allowed(&row(serde_json::json!({ "agent_scope": "{}" })))); | |
| 577 | + | } | |
| 545 | 578 | } | |