Skip to content
190 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 (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 const match = /^\/([^/]+)\/-\/(?:chat|artifacts)\/live$/.exec(path);
59 if (!match || match[1] === "-") return null;
60 return { path, workspace: match[1]! };
61}
62
63const keys = new Map<string, Promise<CryptoKey>>();
64
65/**
66 * The ticket key, derived from the site's secret for this one use (so it
67 * never doubles as the key the secret is otherwise for).
68 */
69function ticketKey(secret: string): Promise<CryptoKey> {
70 let key = keys.get(secret);
71 if (!key) {
72 key = crypto.subtle
73 .importKey("raw", new TextEncoder().encode(secret), "HKDF", false, ["deriveKey"])
74 .then((base) =>
75 crypto.subtle.deriveKey(
76 { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("g1t"), info: new TextEncoder().encode("socket ticket v1") },
77 base,
78 { name: "AES-GCM", length: 256 },
79 false,
80 ["encrypt", "decrypt"],
81 ),
82 );
83 keys.set(secret, key);
84 }
85 return key;
86}
87
88/** Binds the ciphertext to the path, so a ticket opens nowhere else. */
89function bound(path: string): Uint8Array<ArrayBuffer> {
90 return new TextEncoder().encode(`g1t socket ticket\n${path}`);
91}
92
93function base64url(bytes: Uint8Array): string {
94 let text = "";
95 for (const byte of bytes) text += String.fromCharCode(byte);
96 return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
97}
98
99function fromBase64url(text: string): Uint8Array<ArrayBuffer> | null {
100 if (!/^[A-Za-z0-9_-]+$/.test(text)) return null;
101 try {
102 const raw = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
103 const bytes = new Uint8Array(raw.length);
104 for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
105 return bytes;
106 } catch {
107 return null;
108 }
109}
110
111type Sealed = { t: string; u: string; p: string; e: number };
112
113/**
114 * A ticket for one socket path, for the token a page request carried and
115 * the person it resolved to. `path` is a {@link socketPath}.
116 */
117export async function issueTicket(
118 secret: string,
119 input: { token: string; userId: string; path: string },
120 nowMs = Date.now(),
121): Promise<{ ticket: string; expires_at: string }> {
122 const expires = Math.floor(nowMs / 1000) + TICKET_SECONDS;
123 const sealed: Sealed = { t: input.token, u: input.userId, p: input.path, e: expires };
124 const iv = crypto.getRandomValues(new Uint8Array(12));
125 const body = await crypto.subtle.encrypt(
126 { name: "AES-GCM", iv, additionalData: bound(input.path) },
127 await ticketKey(secret),
128 new TextEncoder().encode(JSON.stringify(sealed)),
129 );
130 const out = new Uint8Array(iv.length + body.byteLength);
131 out.set(iv, 0);
132 out.set(new Uint8Array(body), iv.length);
133 return { ticket: PREFIX + base64url(out), expires_at: new Date(expires * 1000).toISOString() };
134}
135
136/**
137 * The token and person a ticket was made for, when it is genuine, for
138 * this socket path, and not past its minute; else null.
139 */
140export async function openTicket(
141 secret: string,
142 ticket: string,
143 path: string,
144 nowMs = Date.now(),
145): Promise<{ token: string; userId: string } | null> {
146 if (!ticket.startsWith(PREFIX) || ticket.length > 2048) return null;
147 const bytes = fromBase64url(ticket.slice(PREFIX.length));
148 if (!bytes || bytes.length <= 12 + 16) return null;
149 let sealed: Sealed;
150 try {
151 const plain = await crypto.subtle.decrypt(
152 { name: "AES-GCM", iv: bytes.slice(0, 12), additionalData: bound(path) },
153 await ticketKey(secret),
154 bytes.slice(12),
155 );
156 sealed = JSON.parse(new TextDecoder().decode(plain)) as Sealed;
157 } catch {
158 // Tampered with, made for another path, or under another key.
159 return null;
160 }
161 if (typeof sealed?.t !== "string" || typeof sealed.u !== "string" || sealed.p !== path || typeof sealed.e !== "number") return null;
162 if (sealed.e * 1000 <= nowMs) return null;
163 return { token: sealed.t, userId: sealed.u };
164}
165
166/**
167 * The person a socket's ticket signs in, checked as a page request with
168 * the token would be: `lookup` is identity's `user_for_access_token`
169 * narrowed by lib/website-token.ts's `socketUser` for the socket's path.
170 * Null for anything but a WebSocket upgrade to the socket the ticket was
171 * made for, and for a ticket that is not genuine, has expired, or whose
172 * token no longer may use the website.
173 */
174export async function ticketViewer(
175 request: Request,
176 secret: string,
177 lookup: (token: string, path: string) => Promise<User | null>,
178 nowMs = Date.now(),
179): Promise<User | null> {
180 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return null;
181 const url = new URL(request.url);
182 const ticket = url.searchParams.get(TICKET_PARAM);
183 if (!ticket) return null;
184 const socket = socketPath(url.pathname);
185 if (!socket) return null;
186 const opened = await openTicket(secret, ticket, socket.path, nowMs);
187 if (!opened || !opened.token.startsWith("g1t_")) return null;
188 const user = await lookup(opened.token, socket.path);
189 return user && user.id === opened.userId ? user : null;
190}