fix(gateway): avoid echoing rotated device tokens · openclaw/openclaw@016a0b4
steipete
·
2026-04-27
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
24 | 24 | |
25 | 25 | ### Fixes |
26 | 26 | |
| 27 | +- 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. |
27 | 28 | - Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio. |
28 | 29 | - 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. |
29 | 30 | - 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. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -102,7 +102,10 @@ caller already has.
|
102 | 102 | openclaw devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write |
103 | 103 | ``` |
104 | 104 | |
105 | | -Returns the new token payload as JSON. |
| 105 | +Returns rotation metadata as JSON. If the caller is rotating its own token while |
| 106 | +authenticated with that device token, the response also includes the replacement |
| 107 | +token so the client can persist it before reconnecting. Shared/admin rotations |
| 108 | +do not echo the bearer token. |
106 | 109 | |
107 | 110 | ### `openclaw devices revoke --device <id> --role <role>` |
108 | 111 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -553,6 +553,10 @@ rather than the pre-handshake defaults.
|
553 | 553 | reused when the client is reusing the stored per-device token. |
554 | 554 | - Device tokens can be rotated/revoked via `device.token.rotate` and |
555 | 555 | `device.token.revoke` (requires `operator.pairing` scope). |
| 556 | +- `device.token.rotate` returns rotation metadata. It echoes the replacement |
| 557 | + bearer token only for same-device calls that are already authenticated with |
| 558 | + that device token, so token-only clients can persist their replacement before |
| 559 | + reconnecting. Shared/admin rotations do not echo the bearer token. |
556 | 560 | - Token issuance, rotation, and revocation stay bounded to the approved role set |
557 | 561 | recorded in that device's pairing entry; token mutation cannot expand or |
558 | 562 | target a device role that pairing approval never granted. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -294,7 +294,6 @@ describe("deviceHandlers", () => {
|
294 | 294 | { |
295 | 295 | deviceId: " device-1 ", |
296 | 296 | role: "operator", |
297 | | -token: "new-token", |
298 | 297 | scopes: ["operator.pairing"], |
299 | 298 | rotatedAtMs: 789, |
300 | 299 | }, |
@@ -336,6 +335,37 @@ describe("deviceHandlers", () => {
|
336 | 335 | ); |
337 | 336 | }); |
338 | 337 | |
| 338 | +it("omits rotated tokens when an admin rotates another device token", async () => { |
| 339 | +mockPairedOperatorDevice(); |
| 340 | +mockRotateOperatorTokenSuccess(); |
| 341 | +const opts = createOptions( |
| 342 | +"device.token.rotate", |
| 343 | +{ |
| 344 | +deviceId: "device-1", |
| 345 | +role: "operator", |
| 346 | +scopes: ["operator.pairing"], |
| 347 | +}, |
| 348 | +{ |
| 349 | +client: createClient(["operator.admin", "operator.pairing"], "admin-device", { |
| 350 | +isDeviceTokenAuth: true, |
| 351 | +}), |
| 352 | +}, |
| 353 | +); |
| 354 | + |
| 355 | +await deviceHandlers["device.token.rotate"](opts); |
| 356 | + |
| 357 | +expect(opts.respond).toHaveBeenCalledWith( |
| 358 | +true, |
| 359 | +{ |
| 360 | +deviceId: "device-1", |
| 361 | +role: "operator", |
| 362 | +scopes: ["operator.pairing"], |
| 363 | +rotatedAtMs: 789, |
| 364 | +}, |
| 365 | +undefined, |
| 366 | +); |
| 367 | +}); |
| 368 | + |
339 | 369 | it("rejects rotating a token for a role that was never approved", async () => { |
340 | 370 | mockPairedOperatorDevice(); |
341 | 371 | rotateDeviceTokenMock.mockResolvedValue({ ok: false, reason: "unknown-device-or-role" }); |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -109,6 +109,10 @@ function deniesCrossDeviceManagement(authz: DeviceManagementAuthz): boolean {
|
109 | 109 | ); |
110 | 110 | } |
111 | 111 | |
| 112 | +function shouldReturnRotatedDeviceToken(authz: DeviceManagementAuthz): boolean { |
| 113 | +return Boolean(authz.callerDeviceId && authz.callerDeviceId === authz.normalizedTargetDeviceId); |
| 114 | +} |
| 115 | + |
112 | 116 | export const deviceHandlers: GatewayRequestHandlers = { |
113 | 117 | "device.pair.list": async ({ params, respond, client }) => { |
114 | 118 | if (!validateDevicePairListParams(params)) { |
@@ -367,7 +371,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
367 | 371 | { |
368 | 372 | deviceId, |
369 | 373 | role: entry.role, |
370 | | -token: entry.token, |
| 374 | +...(shouldReturnRotatedDeviceToken(authz) ? { token: entry.token } : {}), |
371 | 375 | scopes: entry.scopes, |
372 | 376 | rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs, |
373 | 377 | }, |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -176,13 +176,20 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
|
176 | 176 | try { |
177 | 177 | await connectOk(started.ws); |
178 | 178 | |
179 | | -const rotate = await rpcReq<{ token?: string }>(started.ws, "device.token.rotate", { |
180 | | -deviceId: device.deviceId, |
181 | | -role: "operator", |
182 | | -scopes: ["operator.pairing"], |
183 | | -}); |
| 179 | +const rotate = await rpcReq<{ rotatedAtMs?: number; token?: string }>( |
| 180 | +started.ws, |
| 181 | +"device.token.rotate", |
| 182 | +{ |
| 183 | +deviceId: device.deviceId, |
| 184 | +role: "operator", |
| 185 | +scopes: ["operator.pairing"], |
| 186 | +}, |
| 187 | +); |
184 | 188 | expect(rotate.ok).toBe(true); |
185 | | -expect(rotate.payload?.token).toBeTruthy(); |
| 189 | +expect(rotate.payload?.rotatedAtMs).toBeTypeOf("number"); |
| 190 | +expect(rotate.payload?.token).toBeUndefined(); |
| 191 | +const pairedAfterRotate = await getPairedDevice(device.deviceId); |
| 192 | +expect(pairedAfterRotate?.tokens?.operator?.token).toBeTruthy(); |
186 | 193 | |
187 | 194 | const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", { |
188 | 195 | deviceId: device.deviceId, |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -115,7 +115,7 @@ export async function rotateDeviceToken(
|
115 | 115 | } |
116 | 116 | try { |
117 | 117 | const res = await state.client.request<{ |
118 | | -token: string; |
| 118 | +token?: string; |
119 | 119 | role?: string; |
120 | 120 | deviceId?: string; |
121 | 121 | scopes?: Array<string>; |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。