From 016a0b4de945196c75c8a790d8d41aafc6488171 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 15:09:38 +0100 Subject: [PATCH] fix(gateway): avoid echoing rotated device tokens --- CHANGELOG.md | 1 + docs/cli/devices.md | 5 ++- docs/gateway/protocol.md | 4 +++ src/gateway/server-methods/devices.test.ts | 32 ++++++++++++++++++- src/gateway/server-methods/devices.ts | 6 +++- .../server.device-token-rotate-authz.test.ts | 19 +++++++---- ui/src/ui/controllers/devices.ts | 2 +- 7 files changed, 59 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdd223d256..3f4ff903f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI. - Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio. - ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed. - Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct `dist/index.js` without hanging before logs. Fixes #72720. Thanks @sign-2025. diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 9f548ff9dca..31724f9b7bd 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -102,7 +102,10 @@ caller already has. openclaw devices rotate --device --role operator --scope operator.read --scope operator.write ``` -Returns the new token payload as JSON. +Returns rotation metadata as JSON. If the caller is rotating its own token while +authenticated with that device token, the response also includes the replacement +token so the client can persist it before reconnecting. Shared/admin rotations +do not echo the bearer token. ### `openclaw devices revoke --device --role ` diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index f0f694f1163..803e7666b62 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -553,6 +553,10 @@ rather than the pre-handshake defaults. reused when the client is reusing the stored per-device token. - Device tokens can be rotated/revoked via `device.token.rotate` and `device.token.revoke` (requires `operator.pairing` scope). +- `device.token.rotate` returns rotation metadata. It echoes the replacement + bearer token only for same-device calls that are already authenticated with + that device token, so token-only clients can persist their replacement before + reconnecting. Shared/admin rotations do not echo the bearer token. - Token issuance, rotation, and revocation stay bounded to the approved role set recorded in that device's pairing entry; token mutation cannot expand or target a device role that pairing approval never granted. diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index 77737754167..98eb0e7e436 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -294,7 +294,6 @@ describe("deviceHandlers", () => { { deviceId: " device-1 ", role: "operator", - token: "new-token", scopes: ["operator.pairing"], rotatedAtMs: 789, }, @@ -336,6 +335,37 @@ describe("deviceHandlers", () => { ); }); + it("omits rotated tokens when an admin rotates another device token", async () => { + mockPairedOperatorDevice(); + mockRotateOperatorTokenSuccess(); + const opts = createOptions( + "device.token.rotate", + { + deviceId: "device-1", + role: "operator", + scopes: ["operator.pairing"], + }, + { + client: createClient(["operator.admin", "operator.pairing"], "admin-device", { + isDeviceTokenAuth: true, + }), + }, + ); + + await deviceHandlers["device.token.rotate"](opts); + + expect(opts.respond).toHaveBeenCalledWith( + true, + { + deviceId: "device-1", + role: "operator", + scopes: ["operator.pairing"], + rotatedAtMs: 789, + }, + undefined, + ); + }); + it("rejects rotating a token for a role that was never approved", async () => { mockPairedOperatorDevice(); rotateDeviceTokenMock.mockResolvedValue({ ok: false, reason: "unknown-device-or-role" }); diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 9e69d2abee5..a776d907b90 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -109,6 +109,10 @@ function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean { ); } +function shouldReturnRotatedDeviceToken(authz: DeviceManagementAuthz): boolean { + return Boolean(authz.callerDeviceId && authz.callerDeviceId === authz.normalizedTargetDeviceId); +} + export const deviceHandlers: GatewayRequestHandlers = { "device.pair.list": async ({ params, respond, client }) => { if (!validateDevicePairListParams(params)) { @@ -367,7 +371,7 @@ export const deviceHandlers: GatewayRequestHandlers = { { deviceId, role: entry.role, - token: entry.token, + ...(shouldReturnRotatedDeviceToken(authz) ? { token: entry.token } : {}), scopes: entry.scopes, rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs, }, diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 348b1574fe2..e1614f0de4c 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -176,13 +176,20 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { try { await connectOk(started.ws); - const rotate = await rpcReq<{ token?: string }>(started.ws, "device.token.rotate", { - deviceId: device.deviceId, - role: "operator", - scopes: ["operator.pairing"], - }); + const rotate = await rpcReq<{ rotatedAtMs?: number; token?: string }>( + started.ws, + "device.token.rotate", + { + deviceId: device.deviceId, + role: "operator", + scopes: ["operator.pairing"], + }, + ); expect(rotate.ok).toBe(true); - expect(rotate.payload?.token).toBeTruthy(); + expect(rotate.payload?.rotatedAtMs).toBeTypeOf("number"); + expect(rotate.payload?.token).toBeUndefined(); + const pairedAfterRotate = await getPairedDevice(device.deviceId); + expect(pairedAfterRotate?.tokens?.operator?.token).toBeTruthy(); const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", { deviceId: device.deviceId, diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts index e3f0e421e0d..fa14d7caace 100644 --- a/ui/src/ui/controllers/devices.ts +++ b/ui/src/ui/controllers/devices.ts @@ -115,7 +115,7 @@ export async function rotateDeviceToken( } try { const res = await state.client.request<{ - token: string; + token?: string; role?: string; deviceId?: string; scopes?: Array;