Authenticate sockets by header or one-time ticket instead of ?token=

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-07 08:58:22 +02:00
co-authored by Claude Opus 5.5
parent eb45fb2763
commit 5259b8db0a
21 changed files with 336 additions and 44 deletions
+16
View File
@@ -3692,6 +3692,22 @@ export const ShareRequestSchema = {
title: 'ShareRequest'
} as const;
export const SocketTicketSchema = {
properties: {
ticket: {
type: 'string',
title: 'Ticket'
},
expires_in: {
type: 'number',
title: 'Expires In'
}
},
type: 'object',
required: ['ticket', 'expires_in'],
title: 'SocketTicket'
} as const;
export const StoredKeySchema = {
properties: {
key: {
File diff suppressed because one or more lines are too long
+7
View File
@@ -1241,6 +1241,11 @@ export type ShareRequest = {
lib_name: string;
};
export type SocketTicket = {
ticket: string;
expires_in: number;
};
export type StoredKey = {
key: string;
size: number;
@@ -1835,6 +1840,8 @@ export type FlowsReadMessageHistoryData = {
export type FlowsReadMessageHistoryResponse = (MessageHistory);
export type FlowsIssueSocketTicketResponse = (SocketTicket);
export type LoginLoginAccessTokenData = {
formData: Body_login_login_access_token;
};
+39 -15
View File
@@ -2,13 +2,12 @@ import type { QueryClient } from "@tanstack/react-query"
import { useQueryClient } from "@tanstack/react-query"
import { useEffect } from "react"
import { OpenAPI } from "@/client"
import { ApiError, FlowsService, OpenAPI } from "@/client"
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { healthKeys } from "@/components/Health/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
import { parseMediaFrame } from "@/lib/media"
import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
import { flowKeys } from "./queries"
@@ -126,17 +125,19 @@ type FlowEvent =
ts?: number
}
function socketUrl(): string {
async function socketUrl(): Promise<string> {
const base = String(OpenAPI.BASE || window.location.origin)
// Concatenated rather than resolved: an absolute path as the second argument
// to `new URL` discards the base's own path, which under a portal
// (`https://host/i/{id}`) would aim the socket at the wrong place entirely.
const url = new URL(`${base.replace(/\/$/, "")}/api/v1/flows/ws`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
// Browsers cannot set headers on a websocket handshake, so the token rides
// in the query string. Read per attempt, so a reconnect after the old one
// expired carries whatever is current rather than what we started with.
url.searchParams.set("token", apiToken())
// Browsers cannot set headers on a websocket handshake, and a token in the
// URL ends up in every access log on the way. So the token is traded for a
// one-time ticket, fetched per attempt: it is good for one connect, and the
// call carries whatever token is current.
const { ticket } = await FlowsService.issueSocketTicket()
url.searchParams.set("ticket", ticket)
return url.toString()
}
@@ -159,6 +160,8 @@ let retry = RECONNECT_MIN
let watchers = 0
/** Consecutive 1008s, cleared by a handshake the server accepts. */
let rejected = 0
/** A ticket is being fetched, so a socket is on its way. */
let connecting = false
let client: QueryClient | null = null
const authHandlers = new Set<() => void>()
/** Floor re-reads still owed, per node, so a recovery can cancel its own. */
@@ -206,9 +209,36 @@ function sendWanted() {
liveStore.onWanted(sendWanted)
/** The token was refused, by the socket or by the ticket call before it. */
function refused(): boolean {
rejected += 1
if (rejected < AUTH_ATTEMPTS) return false
for (const handler of authHandlers) handler()
return true
}
function connect() {
if (watchers === 0 || socket || timer || connecting) return
connecting = true
socketUrl().then(
(url) => {
connecting = false
open(url)
},
(error: unknown) => {
connecting = false
if (watchers === 0) return
// A 401 is the 1008 of the ticket call; anything else is the engine
// not answering yet, which the backoff is for.
if (error instanceof ApiError && error.status === 401 && refused()) return
schedule()
},
)
}
function open(url: string) {
if (watchers === 0 || socket || timer) return
const ws = new WebSocket(socketUrl())
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
socket = ws
@@ -422,13 +452,7 @@ function connect() {
liveStore.setConnected(false)
connectionStore.setSocketOpen(false)
if (watchers === 0) return
if (event.code === 1008) {
rejected += 1
if (rejected >= AUTH_ATTEMPTS) {
for (const handler of authHandlers) handler()
return
}
}
if (event.code === 1008 && refused()) return
// 1013 is the portal saying the instance is not attached — the one
// close code that means "offline" rather than "the socket dropped".
if (event.code === 1013) {