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 | ||
| 30 | import type { User } from "@g1t/contracts"; | |
| 31 | ||
| 32 | /** How long a ticket is good for. */ | |
| 33 | export const TICKET_SECONDS = 60; | |
| 34 | ||
| 35 | /** The query parameter a socket's address carries a ticket in. */ | |
| 36 | export const TICKET_PARAM = "ticket"; | |
| 37 | ||
| 38 | /** Where a page asks for one. */ | |
| 39 | export const TICKET_ROUTE = "/-/live/ticket"; | |
| 40 | ||
| 41 | const 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 | */ | |
| 47 | export 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 | ||
| 62 | const 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 | */ | |
| 68 | function 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. */ | |
| 88 | function bound(path: string): Uint8Array<ArrayBuffer> { | |
| 89 | return new TextEncoder().encode(`g1t socket ticket\n${path}`); | |
| 90 | } | |
| 91 | ||
| 92 | function 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 | ||
| 98 | function 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 | ||
| 110 | type 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 | */ | |
| 116 | export 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 | */ | |
| 139 | export 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 | */ | |
| 173 | export 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 | } |