fix: Device Token Scope Escalation via Rotate Endpoint (#20703)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 4f2c2ecef4
Co-authored-by: coygeek <65363919+coygeek@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Coy Geek
2026-02-20 09:38:58 -08:00
committed by GitHub
parent 40a292619e
commit 914a7c5359
4 changed files with 80 additions and 9 deletions

View File

@@ -97,7 +97,7 @@ describe("device pairing tokens", () => {
expect(Buffer.from(token, "base64url")).toHaveLength(32);
});
test("preserves existing token scopes when rotating without scopes", async () => {
test("allows down-scoping from admin and preserves approved scope baseline", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
@@ -109,7 +109,8 @@ describe("device pairing tokens", () => {
});
let paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
expect(paired?.scopes).toEqual(["operator.read"]);
expect(paired?.scopes).toEqual(["operator.admin"]);
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
await rotateDeviceToken({
deviceId: "device-1",
@@ -120,6 +121,26 @@ describe("device pairing tokens", () => {
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
});
test("rejects scope escalation when rotating a token and leaves state unchanged", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const before = await getPairedDevice("device-1", baseDir);
const rotated = await rotateDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.admin"],
baseDir,
});
expect(rotated).toBeNull();
const after = await getPairedDevice("device-1", baseDir);
expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token);
expect(after?.tokens?.operator?.scopes).toEqual(["operator.read"]);
expect(after?.scopes).toEqual(["operator.read"]);
expect(after?.approvedScopes).toEqual(["operator.read"]);
});
test("verifies token and rejects mismatches", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.read"]);