feat(webui): bypass tokens for trusted proxy auth

This commit is contained in:
concertypin
2026-08-04 21:53:16 +08:00
committed by Xubin Ren
parent 5cd14a42df
commit 465a918cf8
9 changed files with 134 additions and 62 deletions
+11 -7
View File
@@ -70,7 +70,7 @@ type BootState =
status: "ready";
client: NanobotClient;
token: string;
tokenExpiresAt: number;
tokenExpiresAt: number | null;
modelName: string | null;
ingressLimits: BootstrapResponse["limits"] | null;
runtimeSurface: RuntimeSurface;
@@ -733,7 +733,9 @@ export default function App() {
? toRuntimeSurface(boot.runtime_surface)
: fallbackSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
const tokenExpiresAt = boot.expires_in
? bootstrapTokenExpiresAt(boot.expires_in)
: null;
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
@@ -744,7 +746,7 @@ export default function App() {
current.status === "ready" && current.client === client
? {
...current,
token: boot.api_token,
token: boot.api_token ?? "",
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
ingressLimits: boot.limits ?? current.ingressLimits,
@@ -752,7 +754,7 @@ export default function App() {
}
: current,
);
return { token: boot.api_token, url };
return { token: boot.api_token ?? "", url };
},
[],
);
@@ -787,8 +789,10 @@ export default function App() {
setState({
status: "ready",
client,
token: boot.api_token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
token: boot.api_token ?? "",
tokenExpiresAt: boot.expires_in
? bootstrapTokenExpiresAt(boot.expires_in)
: null,
modelName: boot.model_name ?? null,
ingressLimits: boot.limits ?? null,
runtimeSurface,
@@ -813,7 +817,7 @@ export default function App() {
);
useEffect(() => {
if (state.status !== "ready") return;
if (state.status !== "ready" || state.tokenExpiresAt === null) return;
const client = state.client;
const timer = window.setTimeout(async () => {
try {
+5 -9
View File
@@ -87,13 +87,8 @@ export async function fetchBootstrap(
throw new Error(`bootstrap failed: HTTP ${res.status}`);
}
const body = (await res.json()) as BootstrapResponse;
if (!body.token || !body.ws_path) {
throw new Error("bootstrap response missing token or ws_path");
}
if (!body.api_token) {
throw new BootstrapAuthRequiredError(
"bootstrap authentication required: missing api_token",
);
if (!body.ws_path) {
throw new Error("bootstrap response missing ws_path");
}
return body;
}
@@ -107,10 +102,10 @@ export async function fetchBootstrap(
*/
export function deriveWsUrl(
wsPath: string,
token: string,
token: string | null | undefined,
wsUrl?: string | null,
): string {
const query = `?token=${encodeURIComponent(token)}`;
const query = token ? `?token=${encodeURIComponent(token)}` : "";
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
if (typeof window !== "undefined" && window.location.port === "5173") {
const host = window.location.hostname.includes(":")
@@ -127,6 +122,7 @@ export function deriveWsUrl(
return `${scheme}://${authority}${path}${query}`;
}
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
if (!token) return wsUrl;
const join = wsUrl.includes("?") ? "&" : "?";
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
}
+3 -3
View File
@@ -390,11 +390,11 @@ export interface SidebarStatePayload {
}
export interface BootstrapResponse {
token: string;
api_token: string;
token?: string;
api_token?: string;
ws_path: string;
ws_url?: string | null;
expires_in: number;
expires_in?: number;
limits?: WebUIIngressLimits;
model_name?: string | null;
runtime_surface?: RuntimeSurface;
+11 -8
View File
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BootstrapAuthRequiredError,
consumeUrlBootstrapSecret,
deriveWsUrl,
fetchBootstrap,
@@ -57,6 +56,12 @@ describe("bootstrap helpers", () => {
);
});
it("does not append a token for trusted-proxy websocket URLs", () => {
expect(deriveWsUrl("/", undefined, "wss://proxy.example/")).toBe(
"wss://proxy.example/",
);
});
it("times out when the bootstrap endpoint never responds", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
@@ -69,21 +74,19 @@ describe("bootstrap helpers", () => {
await pending;
});
it("treats bootstrap responses without an API token as auth-required", async () => {
it("accepts tokenless trusted-proxy bootstrap responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }),
json: async () => ({ ws_path: "/", ws_url: "wss://proxy.example/" }),
})),
);
const promise = fetchBootstrap();
await expect(promise).rejects.toMatchObject({
name: "BootstrapAuthRequiredError",
message: "bootstrap authentication required: missing api_token",
await expect(fetchBootstrap()).resolves.toMatchObject({
ws_path: "/",
ws_url: "wss://proxy.example/",
});
await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError);
});
it("consumes bootstrap secrets from the URL fragment", () => {