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:
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user