fix(gateway): avoid echoing rotated device tokens

This commit is contained in:
Peter Steinberger
2026-04-27 15:09:38 +01:00
parent dacf43640a
commit 016a0b4de9
7 changed files with 59 additions and 10 deletions

View File

@@ -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.

View File

@@ -102,7 +102,10 @@ caller already has.
openclaw devices rotate --device <deviceId> --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 <id> --role <role>`

View File

@@ -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.

View File

@@ -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" });

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -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<string>;