Gateway: disconnect shared-auth sessions on auth change

This commit is contained in:
Michael Appel
2026-04-03 15:00:04 +00:00
committed by Peter Steinberger
parent e0580e6863
commit 54b269b2cb
6 changed files with 127 additions and 2 deletions

View File

@@ -47,7 +47,7 @@ import {
} from "../protocol/index.js";
import { resolveBaseHashParam } from "./base-hash.js";
import { parseRestartRequestParams } from "./restart-request.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
const MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE = 3;
@@ -220,6 +220,28 @@ function parseValidateConfigFromRawOrRespond(
return { config: validated.config, schema };
}
function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean {
const prevAuth = prev.gateway?.auth;
const nextAuth = next.gateway?.auth;
return (
prevAuth?.mode !== nextAuth?.mode ||
prevAuth?.token !== nextAuth?.token ||
prevAuth?.password !== nextAuth?.password
);
}
function queueSharedGatewayAuthDisconnect(
shouldDisconnect: boolean,
context?: GatewayRequestContext,
): void {
if (!shouldDisconnect) {
return;
}
queueMicrotask(() => {
context?.disconnectClientsUsingSharedGatewayAuth?.();
});
}
function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationIssue>): string {
const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE);
const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true })
@@ -371,7 +393,7 @@ export const configHandlers: GatewayRequestHandlers = {
}
respond(true, result, undefined);
},
"config.set": async ({ params, respond }) => {
"config.set": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateConfigSetParams, "config.set", respond)) {
return;
}
@@ -386,6 +408,7 @@ export const configHandlers: GatewayRequestHandlers = {
if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) {
return;
}
const disconnectSharedAuthClients = didSharedGatewayAuthChange(snapshot.config, parsed.config);
await writeConfigFile(parsed.config, writeOptions);
respond(
true,
@@ -396,6 +419,7 @@ export const configHandlers: GatewayRequestHandlers = {
},
undefined,
);
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
},
"config.patch": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateConfigPatchParams, "config.patch", respond)) {
@@ -501,6 +525,10 @@ export const configHandlers: GatewayRequestHandlers = {
context?.logGateway?.info(
`config.patch write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.patch`,
);
const disconnectSharedAuthClients = didSharedGatewayAuthChange(
snapshot.config,
validated.config,
);
await writeConfigFile(validated.config, writeOptions);
const { sessionKey, note, restartDelayMs, deliveryContext, threadId } =
@@ -543,6 +571,7 @@ export const configHandlers: GatewayRequestHandlers = {
},
undefined,
);
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
},
"config.apply": async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateConfigApplyParams, "config.apply", respond)) {
@@ -564,6 +593,7 @@ export const configHandlers: GatewayRequestHandlers = {
context?.logGateway?.info(
`config.apply write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.apply`,
);
const disconnectSharedAuthClients = didSharedGatewayAuthChange(snapshot.config, parsed.config);
await writeConfigFile(parsed.config, writeOptions);
const { sessionKey, note, restartDelayMs, deliveryContext, threadId } =
@@ -606,6 +636,7 @@ export const configHandlers: GatewayRequestHandlers = {
},
undefined,
);
queueSharedGatewayAuthDisconnect(disconnectSharedAuthClients, context);
},
"config.openFile": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) {

View File

@@ -58,6 +58,7 @@ export type GatewayRequestContext = {
hasConnectedMobileNode: () => boolean;
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
disconnectClientsForDevice?: (deviceId: string, opts?: { role?: string }) => void;
disconnectClientsUsingSharedGatewayAuth?: () => void;
nodeRegistry: NodeRegistry;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;

View File

@@ -1277,6 +1277,18 @@ export async function startGatewayServer(
}
}
},
disconnectClientsUsingSharedGatewayAuth: () => {
for (const gatewayClient of clients) {
if (!gatewayClient.usesSharedGatewayAuth) {
continue;
}
try {
gatewayClient.socket.close(4001, "gateway auth changed");
} catch {
/* ignore */
}
}
},
nodeRegistry,
agentRunSeq,
chatAbortControllers,

View File

@@ -0,0 +1,79 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
rpcReq,
startGatewayServer,
testState,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const ORIGINAL_GATEWAY_AUTH = testState.gatewayAuth;
const OLD_TOKEN = "shared-token-old";
const NEW_TOKEN = "shared-token-new";
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
beforeAll(async () => {
port = await getFreePort();
testState.gatewayAuth = { mode: "token", token: OLD_TOKEN };
server = await startGatewayServer(port, { controlUiEnabled: true });
});
afterAll(async () => {
testState.gatewayAuth = ORIGINAL_GATEWAY_AUTH;
await server.close();
});
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
describe("gateway shared auth rotation", () => {
it("disconnects existing shared-token websocket sessions after config rotation", async () => {
const ws = await openAuthenticatedWs(OLD_TOKEN);
try {
const current = await rpcReq<{ hash?: string }>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
const closed = waitForClose(ws);
const res = await rpcReq<{ restart?: { scheduled?: boolean } }>(ws, "config.patch", {
baseHash: current.payload?.hash,
raw: JSON.stringify({
gateway: {
auth: {
token: NEW_TOKEN,
},
},
}),
restartDelayMs: 60_000,
});
expect(res.ok).toBe(true);
await expect(closed).resolves.toMatchObject({
code: 4001,
reason: "gateway auth changed",
});
} finally {
ws.close();
}
});
});

View File

@@ -1103,6 +1103,7 @@ export function attachGatewayWsMessageHandler(params: {
socket,
connect: connectParams,
connId,
usesSharedGatewayAuth: authMethod === "token" || authMethod === "password",
presenceKey,
clientIp: reportedClientIp,
canvasHostUrl,

View File

@@ -5,6 +5,7 @@ export type GatewayWsClient = {
socket: WebSocket;
connect: ConnectParams;
connId: string;
usesSharedGatewayAuth?: boolean;
presenceKey?: string;
clientIp?: string;
canvasHostUrl?: string;