Skip to content
189 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>/-/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}