Skip to content
281 linesCodeBlameRaw
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 person's own token without "Use the website as you" is still
23 * accepted on a few JSON routes and live sockets, when it holds their
24 * scope: Chat's (`chat:read`, `chat:write`) and the person's push and
25 * notification settings (`notifications:read`, `notifications:write`).
26 * That is how an application signed in with OAuth, such as the phone
27 * app, reaches Chat, which has no API of its own ({@link scopedRoute}).
28 * Only scopes given by name count, not full access. Nowhere else does
29 * such a token sign in.
30 * - A token that is not accepted is no one: a page loads signed out, and a
31 * data request or form post is refused with a 401 that says why.
32 */
33
34import type { User, Viewer } from "@g1t/contracts";
35import { type Scope, tokenAllows } from "@g1t/contracts/scopes";
36
37/**
38 * The page a request is for, as routes match it: decoded, any case, no
39 * doubled or trailing slashes, and a client navigation's `.data` as its
40 * page (as lib/confirm-gate.ts's `pageOf`).
41 */
42function pageOf(pathname: string): string {
43 let path = decodedPath(pathname);
44 if (path.endsWith(".data")) {
45 path = path.slice(0, -".data".length);
46 if (path === "/_root") path = "/";
47 }
48 if (path.length > 1) path = path.replace(/\/+$/, "");
49 return path || "/";
50}
51
52/** A path decoded, in any case, without doubled slashes. */
53function decodedPath(pathname: string): string {
54 let path = pathname;
55 try {
56 path = decodeURIComponent(path);
57 } catch {
58 // Left as it came: routes cannot match a malformed escape either.
59 }
60 return path.toLowerCase().replace(/\/{2,}/g, "/");
61}
62
63/** Where the docs explain it. */
64export const WEBSITE_TOKEN_DOCS = "https://docs.g1t.sh/guides/authentication/#use-a-token-on-the-website";
65
66/**
67 * The token in `Authorization: Bearer <token>`, or null when the request
68 * has no such header. Any other scheme is not a token.
69 */
70export function bearerToken(request: Request): string | null {
71 const header = request.headers.get("authorization");
72 if (!header) return null;
73 const match = /^\s*bearer\s+(\S+)\s*$/i.exec(header);
74 return match ? match[1] : null;
75}
76
77/**
78 * The person a token resolved to, when it may use the website: a person
79 * (not a workspace, an agent or a job) whose token has the website
80 * permission. Null otherwise.
81 */
82export function websiteUser(viewer: Viewer): User | null {
83 const user = personsToken(viewer);
84 return user?.token?.website ? user : null;
85}
86
87/**
88 * A person resolved from their own token: not a workspace, an agent or
89 * someone acting through one, a workflow job or a deploy key. Null
90 * otherwise, and for a session.
91 */
92function personsToken(viewer: Viewer): User | null {
93 if (!viewer || (viewer.kind ?? "user") !== "user" || viewer.acting) return null;
94 const token = viewer.token;
95 if (!token || token.job || token.deploy_key) return null;
96 return viewer;
97}
98
99/** A workspace in a path: any segment but `-`, which is no workspace's name. */
100const WORKSPACE = "(?!-/)[^/]+";
101
102/**
103 * The routes a person's token reaches by its scopes, without the website
104 * permission: each path (decoded, any case, no doubled or trailing
105 * slashes), the method, and the scope it needs. JSON and sockets only,
106 * never a page: a client navigation's `.data` is not one of them, since it
107 * would run the loaders of the pages around the route as well.
108 */
109const SCOPED_ROUTES: { path: RegExp; method: "GET" | "POST"; needs: Scope }[] = [
110 // Chat's JSON: the sidebar, messages, threads, emoji, browsing channels.
111 { path: new RegExp(`^/${WORKSPACE}/-/chat/api$`), method: "GET", needs: "chat:read" },
112 // Sending, editing and deleting, reactions, read marks, cards' actions.
113 { path: new RegExp(`^/${WORKSPACE}/-/chat/api$`), method: "POST", needs: "chat:write" },
114 // A person's card in Chat.
115 { path: new RegExp(`^/${WORKSPACE}/-/chat/person/[^/]+$`), method: "GET", needs: "chat:read" },
116 // Tickets for Chat's live socket and the person's feed (lib/socket-ticket.ts).
117 { path: /^\/-\/live\/ticket$/, method: "GET", needs: "chat:read" },
118 // Push registration and notification settings (routes/notify/api.ts).
119 { path: /^\/-\/notify$/, method: "GET", needs: "notifications:read" },
120 { path: /^\/-\/notify$/, method: "POST", needs: "notifications:write" },
121];
122
123/**
124 * The scope a token needs to reach this request without the website
125 * permission, or null when such a token reaches nothing here.
126 */
127export function scopedRoute(pathname: string, method: string): Scope | null {
128 let path = decodedPath(pathname);
129 if (path.length > 1) path = path.replace(/\/+$/, "");
130 if (path.endsWith(".data")) return null;
131 const wanted = method.toUpperCase();
132 return SCOPED_ROUTES.find((route) => route.method === wanted && route.path.test(path))?.needs ?? null;
133}
134
135/**
136 * The person a token resolved to when it may make this request by its
137 * scopes alone ({@link scopedRoute}): their own token, holding the scope
138 * the route needs, by name (see {@link grants}). Null otherwise.
139 */
140export function scopedUser(viewer: Viewer, pathname: string, method: string): User | null {
141 const needs = scopedRoute(pathname, method);
142 const user = needs ? personsToken(viewer) : null;
143 return user && needs && grants(user.token?.scopes, needs) ? user : null;
144}
145
146/**
147 * Whether a token was given `needed` by name. A full-access token (no
148 * scopes, or made before scopes) is not: those were made for git, the API
149 * and MCP, and reading someone's conversations is not what they were
150 * given, so they reach Chat only with "Use the website as you".
151 */
152function grants(scopes: readonly string[] | null | undefined, needed: Scope): boolean {
153 return Array.isArray(scopes) && tokenAllows(scopes, needed);
154}
155
156/**
157 * The person a token may open a live socket as (`path` is a
158 * lib/socket-ticket.ts `socketPath`): a token with the website permission
159 * opens any of them; a person's token with `chat:read` opens Chat's live
160 * socket and the person's feed (`/-/live`), never an artifact's.
161 */
162export function socketUser(viewer: Viewer, path: string): User | null {
163 const website = websiteUser(viewer);
164 if (website) return website;
165 const user = personsToken(viewer);
166 if (!user || !grants(user.token?.scopes, "chat:read")) return null;
167 return path === "/-/live" || new RegExp(`^/${WORKSPACE}/-/chat/live$`).test(path) ? user : null;
168}
169
170/** Why a token was not accepted, for the 401. */
171export const TOKEN_REFUSED =
172 "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. " +
173 `See ${WEBSITE_TOKEN_DOCS}`;
174
175/** The `WWW-Authenticate` header for a refused token. */
176export const TOKEN_CHALLENGE = 'Bearer realm="g1t", error="invalid_token"';
177
178/** Pages a token never opens, whatever the method. */
179const ALWAYS = [
180 // Your tokens, two-factor authentication, emails, keys, the account
181 // itself (its password and deleting it), applications you let in, and
182 // how you sign in.
183 /^\/settings\/(?:tokens|two-factor|emails|keys|account|applications|github)(?:\/|$)/,
184 // Letting a device or an application in makes a token.
185 /^\/(?:device|oauth\/authorize|auth\/github)(?:\/|$)/,
186 // A workspace's own tokens, and its rules for and approvals of members' tokens.
187 /^\/[^/]+\/-\/(?:tokens|personal-access-tokens)(?:\/|$)/,
188];
189
190/** Form posts a token never makes: a page, the field that names the change, and the changes. */
191const CHANGES: { page: RegExp; field: string; values: string[] }[] = [
192 // Deleting a workspace.
193 { page: /^\/[^/]+\/-\/settings$/, field: "intent", values: ["delete"] },
194 // Giving a workspace to another owner.
195 { page: /^\/[^/]+\/-\/(?:members|people)$/, field: "action", values: ["transfer"] },
196 // Payment methods: the card on file, and the payment pages that take one.
197 { page: /^\/[^/]+\/-\/billing$/, field: "intent", values: ["portal", "card-check", "subscribe", "buy-ai-credit"] },
198];
199
200/** Whether a page opens nothing for a token whatever is posted to it. */
201export function alwaysNeedsSignIn(pathname: string): boolean {
202 const page = pageOf(pathname);
203 return ALWAYS.some((pattern) => pattern.test(page));
204}
205
206/**
207 * Whether a request needs a real sign-in rather than a token. `form` is
208 * the posted form, read only for the few pages where one change of many
209 * does (null for none, or when it could not be read).
210 */
211export function needsRealSignIn(pathname: string, method: string, form: { get(name: string): unknown } | null): boolean {
212 if (alwaysNeedsSignIn(pathname)) return true;
213 if (method === "GET" || method === "HEAD" || !form) return false;
214 const page = pageOf(pathname);
215 return CHANGES.some((change) => change.page.test(page) && change.values.includes(String(form.get(change.field) ?? "")));
216}
217
218/** Whether a posted form must be read to decide: a form post to one of {@link CHANGES}' pages. */
219export function readsForm(pathname: string, method: string): boolean {
220 if (method === "GET" || method === "HEAD") return false;
221 const page = pageOf(pathname);
222 return CHANGES.some((change) => change.page.test(page));
223}
224
225/** What a token is told on a page that needs a real sign-in. */
226export type NeedsSignIn = { needs_sign_in: true; message: string };
227
228export const NEEDS_SIGN_IN: NeedsSignIn = {
229 needs_sign_in: true,
230 message:
231 "You are using g1t with an access token. Tokens, two-factor authentication, your password, email addresses and keys, " +
232 "deleting an account or a workspace, giving a workspace away, and payment methods need you to sign in on g1t.sh yourself.",
233};
234
235/** Whether an error's data is {@link NEEDS_SIGN_IN}, for the error page. */
236export function isNeedsSignIn(value: unknown): value is NeedsSignIn {
237 return typeof value === "object" && value !== null && (value as { needs_sign_in?: unknown }).needs_sign_in === true;
238}
239
240/** Whether a request wants data (a loader's `.data` or a form post) rather than a page. */
241export function wantsData(pathname: string, method: string): boolean {
242 return pathname.endsWith(".data") || (method !== "GET" && method !== "HEAD");
243}
244
245/** What to do with a request, as far as a token on it goes. */
246export type TokenVerdict =
247 /** No token: the session cookie, if any, decides. */
248 | { kind: "none" }
249 /** Signed in as the token's owner, for this request. */
250 | { kind: "signed-in"; user: User }
251 /** A page with a token not accepted: shown signed out, with a challenge header. */
252 | { kind: "signed-out" }
253 /** Refused: a 401 for a token not accepted, a 403 for what needs a real sign-in. */
254 | { kind: "refused"; status: 401; body: string }
255 | { kind: "refused"; status: 403; body: NeedsSignIn };
256
257/**
258 * Decides a request's token. `lookup` resolves a token to whoever it
259 * names (identity's `user_for_access_token`), checked on every request.
260 */
261export async function tokenVerdict(request: Request, lookup: (token: string) => Promise<Viewer>): Promise<TokenVerdict> {
262 const token = bearerToken(request);
263 if (token === null) return { kind: "none" };
264 const { pathname } = new URL(request.url);
265 const method = request.method.toUpperCase();
266 const viewer = token.startsWith("g1t_") ? await lookup(token) : null;
267 // The website permission opens the site; a token's scopes open only the
268 // few routes that need them.
269 const user = websiteUser(viewer) ?? scopedUser(viewer, pathname, method);
270 if (!user) return wantsData(pathname, method) ? { kind: "refused", status: 401, body: TOKEN_REFUSED } : { kind: "signed-out" };
271 let form: { get(name: string): unknown } | null = null;
272 if (readsForm(pathname, method)) {
273 try {
274 form = await request.clone().formData();
275 } catch {
276 form = null;
277 }
278 }
279 if (needsRealSignIn(pathname, method, form)) return { kind: "refused", status: 403, body: NEEDS_SIGN_IN };
280 return { kind: "signed-in", user };
281}