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.
18 files+513−170/18 viewed
| 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 | + | 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 | + | } | |
| 51 | 72 | context.set(viewerContext, viewer); | |
| 52 | 73 | ||
| 53 | 74 | const { pathname, search } = new URL(request.url); | |
| ⋯ | |||
| 142 | 163 | ||
| 143 | 164 | /** Rejects cross-site form posts; call at the top of every action. */ | |
| 144 | 165 | 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)) { | |
| 147 | 167 | throw new Response("Cross-origin request rejected", { status: 403 }); | |
| 148 | 168 | } | |
| 149 | 169 | } | |
| 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; | |
| 124 | 124 | function anonymousPage(request: Request, pathname: string): boolean { | |
| 125 | 125 | if (request.method !== "GET") return false; | |
| 126 | 126 | 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; | |
| 127 | 129 | return PUBLIC_TOP.test(pathname) || PUBLIC_PROJECT.test(pathname); | |
| 128 | 130 | } | |
| 129 | 131 |
| 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 }, |
| 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" }, |