Skip to content

Commit 8bbb143

Browse files
committed
fix: enforce device token scope containment
1 parent 26e4eb8 commit 8bbb143

9 files changed

Lines changed: 243 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai
7676

7777
- Update: ignore bundled plugin `.openclaw-install-stage` directories during global install verification and packaged dist pruning so leftover runtime-dep staging files do not turn successful updates into `unexpected packaged dist file` failures. Fixes #71752. Thanks @waynegault.
7878
- Gateway/plugins: stop persisted WhatsApp auth state from activating bundled channel runtime-dependency repair during startup when `channels.whatsapp` is absent, avoiding npm/git stalls on packaged Linux installs. Fixes #71994. Thanks @xiao398008.
79+
- Gateway/device tokens: enforce caller-scope containment inside token rotation and revocation so pairing-only sessions cannot mutate higher-scope operator tokens. Fixes #71990. Thanks @coygeek.
7980
- CLI/model runs: keep `openclaw infer model run` on explicit OpenRouter models from loading the full provider catalog or inheriting chat-agent silent-reply policy, restoring non-empty one-shot probe output. Fixes #68791. Thanks @limpredator.
8081
- Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io.
8182
- Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj.

docs/channels/pairing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ That bootstrap token carries the built-in pairing bootstrap profile:
8383
- bootstrap scope checks are role-prefixed, not one flat scope pool:
8484
operator scope entries only satisfy operator requests, and non-operator roles
8585
must still request scopes under their own role prefix
86+
- later token rotation/revocation remains bounded by both the device's approved
87+
role contract and the caller session's operator scopes
8688

8789
Treat the setup code like a password while it is valid.
8890

docs/cli/devices.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ If you omit `--scope`, later reconnects with the stored rotated token reuse that
9595
token's cached approved scopes. If you pass explicit `--scope` values, those
9696
become the stored scope set for future cached-token reconnects.
9797
Non-admin paired-device callers can rotate only their **own** device token.
98-
Also, any explicit `--scope` values must stay within the caller session's own
99-
operator scopes; rotation cannot mint a broader operator token than the caller
100-
already has.
98+
The target token scope set must stay within the caller session's own operator
99+
scopes; rotation cannot mint or preserve a broader operator token than the
100+
caller already has.
101101

102102
```
103103
openclaw devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
@@ -111,6 +111,8 @@ Revoke a device token for a specific role.
111111

112112
Non-admin paired-device callers can revoke only their **own** device token.
113113
Revoking some other device's token requires `operator.admin`.
114+
The target token scope set must also fit within the caller session's own
115+
operator scopes; pairing-only callers cannot revoke admin/write operator tokens.
114116

115117
```
116118
openclaw devices revoke --device <deviceId> --role node
@@ -135,12 +137,15 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er
135137
- These commands require `operator.pairing` (or `operator.admin`) scope.
136138
- `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for
137139
fresh node device pairing only; it does not change CLI approval authority.
138-
- Token rotation stays inside the approved pairing role set and approved scope
139-
baseline for that device. A stray cached token entry does not grant a new
140-
rotate target.
140+
- Token rotation and revocation stay inside the approved pairing role set and
141+
approved scope baseline for that device. A stray cached token entry does not
142+
grant a token-management target.
141143
- For paired-device token sessions, cross-device management is admin-only:
142144
`remove`, `rotate`, and `revoke` are self-only unless the caller has
143145
`operator.admin`.
146+
- Token mutation is also caller-scope contained: a pairing-only session cannot
147+
rotate or revoke a token that currently carries `operator.admin` or
148+
`operator.write`.
144149
- `devices clear` is intentionally gated by `--yes`.
145150
- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback.
146151
- `devices approve` requires an explicit request ID before minting tokens; omitting `requestId` or passing `--latest` only previews the newest pending request.

docs/gateway/protocol.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
360360
<Accordion title="Device pairing and device tokens">
361361
- `device.pair.list` returns pending and approved paired devices.
362362
- `device.pair.approve`, `device.pair.reject`, and `device.pair.remove` manage device-pairing records.
363-
- `device.token.rotate` rotates a paired device token within its approved role and scope bounds.
364-
- `device.token.revoke` revokes a paired device token.
363+
- `device.token.rotate` rotates a paired device token within its approved role and caller scope bounds.
364+
- `device.token.revoke` revokes a paired device token within its approved role and caller scope bounds.
365365
</Accordion>
366366

367367
<Accordion title="Node pairing, invoke, and pending work">
@@ -549,15 +549,15 @@ rather than the pre-handshake defaults.
549549
reused when the client is reusing the stored per-device token.
550550
- Device tokens can be rotated/revoked via `device.token.rotate` and
551551
`device.token.revoke` (requires `operator.pairing` scope).
552-
- Token issuance/rotation stays bounded to the approved role set recorded in
553-
that device's pairing entry; rotating a token cannot expand the device into a
554-
role that pairing approval never granted.
552+
- Token issuance, rotation, and revocation stay bounded to the approved role set
553+
recorded in that device's pairing entry; token mutation cannot expand or
554+
target a device role that pairing approval never granted.
555555
- For paired-device token sessions, device management is self-scoped unless the
556556
caller also has `operator.admin`: non-admin callers can remove/revoke/rotate
557557
only their **own** device entry.
558-
- `device.token.rotate` also checks the requested operator scope set against the
559-
caller's current session scopes. Non-admin callers cannot rotate a token into
560-
a broader operator scope set than they already hold.
558+
- `device.token.rotate` and `device.token.revoke` also check the target operator
559+
token scope set against the caller's current session scopes. Non-admin callers
560+
cannot rotate or revoke a broader operator token than they already hold.
561561
- Auth failures include `error.details.code` plus recovery hints:
562562
- `error.details.canRetryWithDeviceToken` (boolean)
563563
- `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`)

src/gateway/server-methods/devices.test.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,10 @@ describe("deviceHandlers", () => {
181181
});
182182

183183
it("disconnects active clients after revoking a device token", async () => {
184-
revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
184+
revokeDeviceTokenMock.mockResolvedValue({
185+
ok: true,
186+
entry: { role: "operator", revokedAtMs: 456 },
187+
});
185188
const opts = createOptions("device.token.revoke", {
186189
deviceId: " device-1 ",
187190
role: " operator ",
@@ -193,6 +196,7 @@ describe("deviceHandlers", () => {
193196
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
194197
deviceId: " device-1 ",
195198
role: " operator ",
199+
callerScopes: [],
196200
});
197201
expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1", {
198202
role: "operator",
@@ -205,7 +209,10 @@ describe("deviceHandlers", () => {
205209
});
206210

207211
it("allows admin-scoped callers to revoke another device's token", async () => {
208-
revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
212+
revokeDeviceTokenMock.mockResolvedValue({
213+
ok: true,
214+
entry: { role: "operator", revokedAtMs: 456 },
215+
});
209216
const opts = createOptions(
210217
"device.token.revoke",
211218
{ deviceId: "device-2", role: "operator" },
@@ -217,6 +224,7 @@ describe("deviceHandlers", () => {
217224
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
218225
deviceId: "device-2",
219226
role: "operator",
227+
callerScopes: ["operator.admin"],
220228
});
221229
expect(opts.respond).toHaveBeenCalledWith(
222230
true,
@@ -226,7 +234,10 @@ describe("deviceHandlers", () => {
226234
});
227235

228236
it("treats normalized device ids as self-owned for token revocation", async () => {
229-
revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
237+
revokeDeviceTokenMock.mockResolvedValue({
238+
ok: true,
239+
entry: { role: "operator", revokedAtMs: 456 },
240+
});
230241
const opts = createOptions(
231242
"device.token.revoke",
232243
{ deviceId: " device-1 ", role: "operator" },
@@ -238,6 +249,7 @@ describe("deviceHandlers", () => {
238249
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
239250
deviceId: " device-1 ",
240251
role: "operator",
252+
callerScopes: ["operator.pairing"],
241253
});
242254
expect(opts.respond).toHaveBeenCalledWith(
243255
true,
@@ -272,6 +284,7 @@ describe("deviceHandlers", () => {
272284
deviceId: " device-1 ",
273285
role: " operator ",
274286
scopes: ["operator.pairing"],
287+
callerScopes: ["operator.pairing"],
275288
});
276289
expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1", {
277290
role: "operator",
@@ -308,6 +321,7 @@ describe("deviceHandlers", () => {
308321
deviceId: " device-1 ",
309322
role: "operator",
310323
scopes: ["operator.pairing"],
324+
callerScopes: ["operator.pairing"],
311325
});
312326
expect(opts.respond).toHaveBeenCalledWith(
313327
true,
@@ -324,6 +338,7 @@ describe("deviceHandlers", () => {
324338

325339
it("rejects rotating a token for a role that was never approved", async () => {
326340
mockPairedOperatorDevice();
341+
rotateDeviceTokenMock.mockResolvedValue({ ok: false, reason: "unknown-device-or-role" });
327342
const opts = createOptions(
328343
"device.token.rotate",
329344
{
@@ -341,7 +356,12 @@ describe("deviceHandlers", () => {
341356

342357
await deviceHandlers["device.token.rotate"](opts);
343358

344-
expect(rotateDeviceTokenMock).not.toHaveBeenCalled();
359+
expect(rotateDeviceTokenMock).toHaveBeenCalledWith({
360+
deviceId: "device-1",
361+
role: "node",
362+
scopes: undefined,
363+
callerScopes: ["operator.pairing"],
364+
});
345365
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
346366
expect(opts.respond).toHaveBeenCalledWith(
347367
false,
@@ -351,7 +371,7 @@ describe("deviceHandlers", () => {
351371
});
352372

353373
it("does not disconnect clients when token revocation fails", async () => {
354-
revokeDeviceTokenMock.mockResolvedValue(null);
374+
revokeDeviceTokenMock.mockResolvedValue({ ok: false, reason: "unknown-device-or-role" });
355375
const opts = createOptions("device.token.revoke", {
356376
deviceId: "device-1",
357377
role: "operator",
@@ -363,7 +383,7 @@ describe("deviceHandlers", () => {
363383
expect(opts.respond).toHaveBeenCalledWith(
364384
false,
365385
undefined,
366-
expect.objectContaining({ message: "unknown deviceId/role" }),
386+
expect.objectContaining({ message: "device token revocation denied" }),
367387
);
368388
});
369389

src/gateway/server-methods/devices.ts

Lines changed: 32 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
import {
22
approveDevicePairing,
33
formatDevicePairingForbiddenMessage,
4-
getPairedDevice,
54
getPendingDevicePairing,
6-
listApprovedPairedDeviceRoles,
75
listDevicePairing,
86
removePairedDevice,
97
type DeviceAuthToken,
8+
type RevokeDeviceTokenDenyReason,
109
type RotateDeviceTokenDenyReason,
1110
rejectDevicePairing,
1211
revokeDeviceToken,
1312
rotateDeviceToken,
1413
summarizeDeviceTokens,
1514
} from "../../infra/device-pairing.js";
16-
import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js";
17-
import { resolveMissingRequestedScope } from "../../shared/operator-scope-compat.js";
1815
import {
1916
ErrorCodes,
2017
errorShape,
@@ -29,11 +26,7 @@ import {
2926
import type { GatewayClient, GatewayRequestHandlers } from "./types.js";
3027

3128
const DEVICE_TOKEN_ROTATION_DENIED_MESSAGE = "device token rotation denied";
32-
33-
type DeviceTokenRotateTarget = {
34-
pairedDevice: NonNullable<Awaited<ReturnType<typeof getPairedDevice>>>;
35-
normalizedRole: string;
36-
};
29+
const DEVICE_TOKEN_REVOCATION_DENIED_MESSAGE = "device token revocation denied";
3730

3831
type DeviceSessionAuthz = {
3932
callerDeviceId: string | null;
@@ -62,11 +55,7 @@ function logDeviceTokenRotationDenied(params: {
6255
log: { warn: (message: string) => void };
6356
deviceId: string;
6457
role: string;
65-
reason:
66-
| RotateDeviceTokenDenyReason
67-
| "caller-missing-scope"
68-
| "unknown-device-or-role"
69-
| "device-ownership-mismatch";
58+
reason: RotateDeviceTokenDenyReason | "unknown-device-or-role" | "device-ownership-mismatch";
7059
scope?: string | null;
7160
}) {
7261
const suffix = params.scope ? ` scope=${params.scope}` : "";
@@ -75,23 +64,17 @@ function logDeviceTokenRotationDenied(params: {
7564
);
7665
}
7766

78-
async function loadDeviceTokenRotateTarget(params: {
67+
function logDeviceTokenRevocationDenied(params: {
68+
log: { warn: (message: string) => void };
7969
deviceId: string;
8070
role: string;
81-
log: { warn: (message: string) => void };
82-
}): Promise<DeviceTokenRotateTarget | null> {
83-
const normalizedRole = params.role.trim();
84-
const pairedDevice = await getPairedDevice(params.deviceId);
85-
if (!pairedDevice || !listApprovedPairedDeviceRoles(pairedDevice).includes(normalizedRole)) {
86-
logDeviceTokenRotationDenied({
87-
log: params.log,
88-
deviceId: params.deviceId,
89-
role: params.role,
90-
reason: "unknown-device-or-role",
91-
});
92-
return null;
93-
}
94-
return { pairedDevice, normalizedRole };
71+
reason: RevokeDeviceTokenDenyReason | "device-ownership-mismatch";
72+
scope?: string | null;
73+
}) {
74+
const suffix = params.scope ? ` scope=${params.scope}` : "";
75+
params.log.warn(
76+
`device token revocation denied device=${params.deviceId} role=${params.role} reason=${params.reason}${suffix}`,
77+
);
9578
}
9679

9780
function resolveDeviceManagementAuthz(
@@ -354,50 +337,19 @@ export const deviceHandlers: GatewayRequestHandlers = {
354337
);
355338
return;
356339
}
357-
const rotateTarget = await loadDeviceTokenRotateTarget({
340+
const rotated = await rotateDeviceToken({
358341
deviceId,
359342
role,
360-
log: context.logGateway,
361-
});
362-
if (!rotateTarget) {
363-
respond(
364-
false,
365-
undefined,
366-
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
367-
);
368-
return;
369-
}
370-
const { pairedDevice, normalizedRole } = rotateTarget;
371-
const requestedScopes = normalizeDeviceAuthScopes(
372-
scopes ?? pairedDevice.tokens?.[normalizedRole]?.scopes ?? pairedDevice.scopes,
373-
);
374-
const missingScope = resolveMissingRequestedScope({
375-
role,
376-
requestedScopes,
377-
allowedScopes: authz.callerScopes,
343+
scopes,
344+
callerScopes: authz.callerScopes,
378345
});
379-
if (missingScope) {
380-
logDeviceTokenRotationDenied({
381-
log: context.logGateway,
382-
deviceId,
383-
role,
384-
reason: "caller-missing-scope",
385-
scope: missingScope,
386-
});
387-
respond(
388-
false,
389-
undefined,
390-
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
391-
);
392-
return;
393-
}
394-
const rotated = await rotateDeviceToken({ deviceId, role, scopes });
395346
if (!rotated.ok) {
396347
logDeviceTokenRotationDenied({
397348
log: context.logGateway,
398349
deviceId,
399350
role,
400351
reason: rotated.reason,
352+
scope: rotated.scope,
401353
});
402354
respond(
403355
false,
@@ -448,15 +400,27 @@ export const deviceHandlers: GatewayRequestHandlers = {
448400
respond(
449401
false,
450402
undefined,
451-
errorShape(ErrorCodes.INVALID_REQUEST, "device token revocation denied"),
403+
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_REVOCATION_DENIED_MESSAGE),
452404
);
453405
return;
454406
}
455-
const entry = await revokeDeviceToken({ deviceId, role });
456-
if (!entry) {
457-
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
407+
const revoked = await revokeDeviceToken({ deviceId, role, callerScopes: authz.callerScopes });
408+
if (!revoked.ok) {
409+
logDeviceTokenRevocationDenied({
410+
log: context.logGateway,
411+
deviceId,
412+
role,
413+
reason: revoked.reason,
414+
scope: revoked.scope,
415+
});
416+
respond(
417+
false,
418+
undefined,
419+
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_REVOCATION_DENIED_MESSAGE),
420+
);
458421
return;
459422
}
423+
const entry = revoked.entry;
460424
const normalizedDeviceId = deviceId.trim();
461425
context.logGateway.info(`device token revoked device=${normalizedDeviceId} role=${entry.role}`);
462426
respond(

0 commit comments

Comments
 (0)