Gateway: disconnect revoked device sessions (#55952)

* Gateway: disconnect revoked device sessions

* Gateway: normalize device disconnect targets

* Gateway: scope token revoke disconnects by role

* Gateway: respond before disconnecting sessions
This commit is contained in:
Jacob Tomlinson 2026-03-27 12:01:26 -07:00 committed by GitHub
parent fef1b1918c
commit 7a801cc451
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 149 additions and 2 deletions

View File

@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deviceHandlers } from "./devices.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const { removePairedDeviceMock, revokeDeviceTokenMock } = vi.hoisted(() => ({
removePairedDeviceMock: vi.fn(),
revokeDeviceTokenMock: vi.fn(),
}));
vi.mock("../../infra/device-pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/device-pairing.js")>(
"../../infra/device-pairing.js",
);
return {
...actual,
removePairedDevice: removePairedDeviceMock,
revokeDeviceToken: revokeDeviceTokenMock,
};
});
function createOptions(
method: string,
params: Record<string, unknown>,
overrides?: Partial<GatewayRequestHandlerOptions>,
): GatewayRequestHandlerOptions {
return {
req: { type: "req", id: "req-1", method, params },
params,
client: null,
isWebchatConnect: () => false,
respond: vi.fn(),
context: {
disconnectClientsForDevice: vi.fn(),
logGateway: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
},
...overrides,
} as unknown as GatewayRequestHandlerOptions;
}
describe("deviceHandlers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("disconnects active clients after removing a paired device", async () => {
removePairedDeviceMock.mockResolvedValue({ deviceId: "device-1", removedAtMs: 123 });
const opts = createOptions("device.pair.remove", { deviceId: " device-1 " });
await deviceHandlers["device.pair.remove"](opts);
await Promise.resolve();
expect(removePairedDeviceMock).toHaveBeenCalledWith(" device-1 ");
expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1");
expect(opts.respond).toHaveBeenCalledWith(
true,
{ deviceId: "device-1", removedAtMs: 123 },
undefined,
);
});
it("does not disconnect clients when device removal fails", async () => {
removePairedDeviceMock.mockResolvedValue(null);
const opts = createOptions("device.pair.remove", { deviceId: "device-1" });
await deviceHandlers["device.pair.remove"](opts);
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "unknown deviceId" }),
);
});
it("disconnects active clients after revoking a device token", async () => {
revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
const opts = createOptions("device.token.revoke", {
deviceId: " device-1 ",
role: " operator ",
});
await deviceHandlers["device.token.revoke"](opts);
await Promise.resolve();
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
deviceId: " device-1 ",
role: " operator ",
});
expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1", {
role: "operator",
});
expect(opts.respond).toHaveBeenCalledWith(
true,
{ deviceId: "device-1", role: "operator", revokedAtMs: 456 },
undefined,
);
});
it("does not disconnect clients when token revocation fails", async () => {
revokeDeviceTokenMock.mockResolvedValue(null);
const opts = createOptions("device.token.revoke", {
deviceId: "device-1",
role: "operator",
});
await deviceHandlers["device.token.revoke"](opts);
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "unknown deviceId/role" }),
);
});
});

View File

@ -173,6 +173,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
}
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
respond(true, removed, undefined);
queueMicrotask(() => {
context.disconnectClientsForDevice?.(removed.deviceId);
});
},
"device.token.rotate": async ({ params, respond, context, client }) => {
if (!validateDeviceTokenRotateParams(params)) {
@ -283,11 +286,19 @@ export const deviceHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`);
const normalizedDeviceId = deviceId.trim();
context.logGateway.info(`device token revoked device=${normalizedDeviceId} role=${entry.role}`);
respond(
true,
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },
{
deviceId: normalizedDeviceId,
role: entry.role,
revokedAtMs: entry.revokedAtMs ?? Date.now(),
},
undefined,
);
queueMicrotask(() => {
context.disconnectClientsForDevice?.(normalizedDeviceId, { role: entry.role });
});
},
};

View File

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

View File

@ -1196,6 +1196,21 @@ export async function startGatewayServer(
}
return false;
},
disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => {
for (const gatewayClient of clients) {
if (gatewayClient.connect.device?.id !== deviceId) {
continue;
}
if (opts?.role && gatewayClient.connect.role !== opts.role) {
continue;
}
try {
gatewayClient.socket.close(4001, "device removed");
} catch {
/* ignore */
}
}
},
nodeRegistry,
agentRunSeq,
chatAbortControllers,