Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

186 linesCodeBlameRaw
1/**
2 * Live sockets for a page opened with an access token (lib/website-token.ts).
3 *
4 * A browser cannot put an `Authorization` header on a WebSocket, so a page
5 * a token opened has no way to sign its sockets in: there is no session
6 * cookie. Instead, just before it opens one, the page asks
7 * `GET /-/live/ticket?path=<socket path>` (a normal request, which carries
8 * the token like any other) for a socket ticket, and adds it to the
9 * socket's address as `?ticket=`. Sessions never ask: their sockets carry
10 * the cookie as they always have.
11 *
12 * A ticket:
13 * - is good for {@link TICKET_SECONDS} seconds, and for one socket path
14 * alone (`/-/live`, `/<workspace>/-/chat/live` or
15 * `/<workspace>/-/library/artifacts/live`);
16 * - is read only by those sockets' upgrade, never by a page, a data
17 * request, a form post or the API ({@link ticketViewer} ignores any
18 * request that is not a WebSocket upgrade);
19 * - holds the token itself, encrypted and authenticated (AES-GCM) under a
20 * key only the site has, so the upgrade checks the token exactly as a
21 * page request does: deleted, expired, revoked by a workspace or with
22 * "Use the website as you" turned off (or, for a token let in by its
23 * `chat:read` scope, without it), it opens nothing, even inside the
24 * ticket's minute;
25 * - is never stored, logged or passed on: the socket handlers build the
26 * service's address afresh, without it.
27 *
28 * No Workers imports, so it is tested under Node.
29 */
30
31import type { User } from "@g1t/contracts";
32
33/** How long a ticket is good for. */
34export const TICKET_SECONDS = 60;
35
36/** The query parameter a socket's address carries a ticket in. */
37export const TICKET_PARAM = "ticket";
38
39/** Where a page asks for one. */
40export const TICKET_ROUTE = "/-/live/ticket";
41
42const PREFIX = "st1.";
43
44/**
45 * A socket path as the routes match it (any case, no doubled or trailing
46 * slashes), when it is one of the site's live sockets; else null.
47 */
48export function socketPath(pathname: string): { path: string; workspace: string | null } | null {
49 let path = pathname;
50 try {
51 path = decodeURIComponent(path);
52 } catch {
53 return null;
54 }
55 path = path.toLowerCase().replace(/\/{2,}/g, "/");
56 if (path.length > 1) path = path.replace(/\/+$/, "");
57 if (path === "/-/live") return { path, workspace: null };
58 // An artifact's socket under the Library, or where it was before, for a page opened then.
59 const match = /^\/([^/]+)\/-\/(?:chat|artifacts|library\/artifacts)\/live$/.exec(path);
60 if (!match || match[1] === "-") return null;
61 return { path, workspace: match[1]! };
62}
63
64const keys = new Map<string, Promise<CryptoKey>>();
65
66/**
67 * The ticket key, derived from the site's secret for this one use (so it
68 * never doubles as the key the secret is otherwise for).
69 */
70function ticketKey(secret: string): Promise<CryptoKey> {
71 let key = keys.get(secret);
72 if (!key) {
73 key = crypto.subtle
74 .importKey("raw", new TextEncoder().encode(secret), "HKDF", false, ["deriveKey"])
75 .then((base) =>
76 crypto.subtle.deriveKey(
77 { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("g1t"), info: new TextEncoder().encode("socket ticket v1") },
78 base,
79 { name: "AES-GCM", length: 256 },
80 false,
81 ["encrypt", "decrypt"],
82 ),
83 );
84 keys.set(secret, key);
85 }
86 return key;
87}
88
89/** Binds the ciphertext to the path, so a ticket opens nowhere else. */
90function bound(path: string): Uint8Array<ArrayBuffer> {
91 return new TextEncoder().encode(`g1t socket ticket\n${path}`);
92}
93
94function base64url(bytes: Uint8Array): string {
95 let text = "";
96 for (const byte of bytes) text += String.fromCharCode(byte);
97 return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
98}
99
100function fromBase64url(text: string): Uint8Array<ArrayBuffer> | null {
101 if (!/^[A-Za-z0-9_-]+$/.test(text)) return null;
102 try {
103 const raw = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
104 const bytes = new Uint8Array(raw.length);
105 for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
106 return bytes;
107 } catch {
108 return null;
109 }
110}
111
112type Sealed = { t: string; u: string; p: string; e: number };
113
114/**
115 * A ticket for one socket path, for the token a page request carried and
116 * the person it resolved to. `path` is a {@link socketPath}.
117 */
118export async function issueTicket(
119 secret: string,
120 input: { token: string; userId: string; path: string },
121 nowMs = Date.now(),
122): Promise<{ ticket: string; expires_at: string }> {
123 const expires = Math.floor(nowMs / 1000) + TICKET_SECONDS;
124 const sealed: Sealed = { t: input.token, u: input.userId, p: input.path, e: expires };
125 const iv = crypto.getRandomValues(new Uint8Array(12));
126 const body = await crypto.subtle.encrypt(
127 { name: "AES-GCM", iv, additionalData: bound(input.path) },
128 await ticketKey(secret),
129 new TextEncoder().encode(JSON.stringify(sealed)),
130 );
131 const out = new Uint8Array(iv.length + body.byteLength);
132 out.set(iv, 0);
133 out.set(new Uint8Array(body), iv.length);
134 return { ticket: PREFIX + base64url(out), expires_at: new Date(expires * 1000).toISOString() };
135}
136
137/**
138 * The token and person a ticket was made for, when it is genuine, for
139 * this socket path, and not past its minute; else null.
140 */
141export async function openTicket(secret: string, ticket: string, path: string, nowMs = Date.now()): Promise<{ token: string; userId: string } | null> {
142 if (!ticket.startsWith(PREFIX) || ticket.length > 2048) return null;
143 const bytes = fromBase64url(ticket.slice(PREFIX.length));
144 if (!bytes || bytes.length <= 12 + 16) return null;
145 let sealed: Sealed;
146 try {
147 const plain = await crypto.subtle.decrypt(
148 { name: "AES-GCM", iv: bytes.slice(0, 12), additionalData: bound(path) },
149 await ticketKey(secret),
150 bytes.slice(12),
151 );
152 sealed = JSON.parse(new TextDecoder().decode(plain)) as Sealed;
153 } catch {
154 // Tampered with, made for another path, or under another key.
155 return null;
156 }
157 if (typeof sealed?.t !== "string" || typeof sealed.u !== "string" || sealed.p !== path || typeof sealed.e !== "number") return null;
158 if (sealed.e * 1000 <= nowMs) return null;
159 return { token: sealed.t, userId: sealed.u };
160}
161
162/**
163 * The person a socket's ticket signs in, checked as a page request with
164 * the token would be: `lookup` is identity's `user_for_access_token`
165 * narrowed by lib/website-token.ts's `socketUser` for the socket's path.
166 * Null for anything but a WebSocket upgrade to the socket the ticket was
167 * made for, and for a ticket that is not genuine, has expired, or whose
168 * token no longer may use the website.
169 */
170export async function ticketViewer(
171 request: Request,
172 secret: string,
173 lookup: (token: string, path: string) => Promise<User | null>,
174 nowMs = Date.now(),
175): Promise<User | null> {
176 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return null;
177 const url = new URL(request.url);
178 const ticket = url.searchParams.get(TICKET_PARAM);
179 if (!ticket) return null;
180 const socket = socketPath(url.pathname);
181 if (!socket) return null;
182 const opened = await openTicket(secret, ticket, socket.path, nowMs);
183 if (!opened || !opened.token.startsWith("g1t_")) return null;
184 const user = await lookup(opened.token, socket.path);
185 return user && user.id === opened.userId ? user : null;
186}