Skip to content
189 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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