mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
Require admin scope for node device token management [AI] (#81067)
* fix: require admin for node device token management * addressing review-skill * addressing review-skill * addressing review-skill * addressing review-skill * addressing claude review * addressing ci * docs: add changelog entry for PR merge
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.
|
||||
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
|
||||
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
|
||||
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
|
||||
|
||||
@@ -181,6 +181,40 @@ describe("deviceHandlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects removing mixed-role devices without admin scope", async () => {
|
||||
getPairedDeviceMock.mockResolvedValue({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
roles: ["operator", "node"],
|
||||
tokens: {
|
||||
operator: {
|
||||
token: "operator-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
createdAtMs: 100,
|
||||
},
|
||||
node: {
|
||||
token: "node-token",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
createdAtMs: 100,
|
||||
revokedAtMs: 200,
|
||||
},
|
||||
},
|
||||
});
|
||||
const opts = createOptions(
|
||||
"device.pair.remove",
|
||||
{ deviceId: "device-1" },
|
||||
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
|
||||
);
|
||||
|
||||
await deviceHandlers["device.pair.remove"](opts);
|
||||
|
||||
expect(removePairedDeviceMock).not.toHaveBeenCalled();
|
||||
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
|
||||
expectRespondedErrorMessage(opts, "device pairing removal denied");
|
||||
});
|
||||
|
||||
it("disconnects active clients after revoking a device token", async () => {
|
||||
revokeDeviceTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -234,6 +268,20 @@ describe("deviceHandlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects revoking node tokens without admin scope", async () => {
|
||||
const opts = createOptions(
|
||||
"device.token.revoke",
|
||||
{ deviceId: "device-1", role: "node" },
|
||||
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
|
||||
);
|
||||
|
||||
await deviceHandlers["device.token.revoke"](opts);
|
||||
|
||||
expect(revokeDeviceTokenMock).not.toHaveBeenCalled();
|
||||
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
|
||||
expectRespondedErrorMessage(opts, "device token revocation denied");
|
||||
});
|
||||
|
||||
it("treats normalized device ids as self-owned for token revocation", async () => {
|
||||
revokeDeviceTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -336,6 +384,65 @@ describe("deviceHandlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows pairing-scoped device sessions to manage their own operator token", async () => {
|
||||
rotateDeviceTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
entry: {
|
||||
token: "rotated-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
createdAtMs: 456,
|
||||
rotatedAtMs: 789,
|
||||
},
|
||||
});
|
||||
revokeDeviceTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
entry: { role: "operator", revokedAtMs: 987 },
|
||||
});
|
||||
|
||||
const rotateOpts = createOptions(
|
||||
"device.token.rotate",
|
||||
{ deviceId: "device-1", role: "operator", scopes: ["operator.pairing"] },
|
||||
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
|
||||
);
|
||||
const revokeOpts = createOptions(
|
||||
"device.token.revoke",
|
||||
{ deviceId: "device-1", role: "operator" },
|
||||
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
|
||||
);
|
||||
|
||||
await deviceHandlers["device.token.rotate"](rotateOpts);
|
||||
await deviceHandlers["device.token.revoke"](revokeOpts);
|
||||
|
||||
expect(rotateDeviceTokenMock).toHaveBeenCalledWith({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
callerScopes: ["operator.pairing"],
|
||||
});
|
||||
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
callerScopes: ["operator.pairing"],
|
||||
});
|
||||
expect(rotateOpts.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "rotated-token",
|
||||
scopes: ["operator.pairing"],
|
||||
rotatedAtMs: 789,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(revokeOpts.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ deviceId: "device-1", role: "operator", revokedAtMs: 987 },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("omits rotated tokens when an admin rotates another device token", async () => {
|
||||
mockPairedOperatorDevice();
|
||||
mockRotateOperatorTokenSuccess();
|
||||
@@ -368,8 +475,27 @@ describe("deviceHandlers", () => {
|
||||
});
|
||||
|
||||
it("rejects rotating a token for a role that was never approved", async () => {
|
||||
mockPairedOperatorDevice();
|
||||
rotateDeviceTokenMock.mockResolvedValue({ ok: false, reason: "unknown-device-or-role" });
|
||||
const opts = createOptions(
|
||||
"device.token.rotate",
|
||||
{ deviceId: "device-1", role: "node" },
|
||||
{ client: createClient(["operator.admin"], "admin-device", { isDeviceTokenAuth: true }) },
|
||||
);
|
||||
|
||||
await deviceHandlers["device.token.rotate"](opts);
|
||||
|
||||
expect(rotateDeviceTokenMock).toHaveBeenCalledWith({
|
||||
deviceId: "device-1",
|
||||
role: "node",
|
||||
scopes: undefined,
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
|
||||
expectRespondedErrorMessage(opts, "device token rotation denied");
|
||||
});
|
||||
|
||||
it("rejects rotating node tokens without admin scope", async () => {
|
||||
mockPairedOperatorDevice();
|
||||
const opts = createOptions(
|
||||
"device.token.rotate",
|
||||
{
|
||||
@@ -387,12 +513,7 @@ describe("deviceHandlers", () => {
|
||||
|
||||
await deviceHandlers["device.token.rotate"](opts);
|
||||
|
||||
expect(rotateDeviceTokenMock).toHaveBeenCalledWith({
|
||||
deviceId: "device-1",
|
||||
role: "node",
|
||||
scopes: undefined,
|
||||
callerScopes: ["operator.pairing"],
|
||||
});
|
||||
expect(rotateDeviceTokenMock).not.toHaveBeenCalled();
|
||||
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
|
||||
expectRespondedErrorMessage(opts, "device token rotation denied");
|
||||
});
|
||||
@@ -688,6 +809,27 @@ describe("deviceHandlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects approving node roles for the caller device without admin scope", async () => {
|
||||
getPendingDevicePairingMock.mockResolvedValue({
|
||||
requestId: "req-1",
|
||||
deviceId: " device-1 ",
|
||||
publicKey: "pk-1",
|
||||
role: "node",
|
||||
roles: ["node"],
|
||||
ts: 100,
|
||||
});
|
||||
const opts = createOptions(
|
||||
"device.pair.approve",
|
||||
{ requestId: "req-1" },
|
||||
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
|
||||
);
|
||||
|
||||
await deviceHandlers["device.pair.approve"](opts);
|
||||
|
||||
expect(approveDevicePairingMock).not.toHaveBeenCalled();
|
||||
expectRespondedErrorMessage(opts, "device pairing approval denied");
|
||||
});
|
||||
|
||||
it("rejects rejecting another device from a non-admin device session", async () => {
|
||||
getPendingDevicePairingMock.mockResolvedValue({
|
||||
requestId: "req-2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
approveDevicePairing,
|
||||
formatDevicePairingForbiddenMessage,
|
||||
getPairedDevice,
|
||||
getPendingDevicePairing,
|
||||
listDevicePairing,
|
||||
removePairedDevice,
|
||||
@@ -55,7 +56,11 @@ function logDeviceTokenRotationDenied(params: {
|
||||
log: { warn: (message: string) => void };
|
||||
deviceId: string;
|
||||
role: string;
|
||||
reason: RotateDeviceTokenDenyReason | "unknown-device-or-role" | "device-ownership-mismatch";
|
||||
reason:
|
||||
| RotateDeviceTokenDenyReason
|
||||
| "unknown-device-or-role"
|
||||
| "device-ownership-mismatch"
|
||||
| "role-management-requires-admin";
|
||||
scope?: string | null;
|
||||
}) {
|
||||
const suffix = params.scope ? ` scope=${params.scope}` : "";
|
||||
@@ -68,7 +73,10 @@ function logDeviceTokenRevocationDenied(params: {
|
||||
log: { warn: (message: string) => void };
|
||||
deviceId: string;
|
||||
role: string;
|
||||
reason: RevokeDeviceTokenDenyReason | "device-ownership-mismatch";
|
||||
reason:
|
||||
| RevokeDeviceTokenDenyReason
|
||||
| "device-ownership-mismatch"
|
||||
| "role-management-requires-admin";
|
||||
scope?: string | null;
|
||||
}) {
|
||||
const suffix = params.scope ? ` scope=${params.scope}` : "";
|
||||
@@ -113,6 +121,56 @@ function shouldReturnRotatedDeviceToken(authz: DeviceManagementAuthz): boolean {
|
||||
return Boolean(authz.callerDeviceId && authz.callerDeviceId === authz.normalizedTargetDeviceId);
|
||||
}
|
||||
|
||||
function deniesDeviceTokenRoleManagement(
|
||||
authz: DeviceManagementAuthz,
|
||||
targetRole: string,
|
||||
): boolean {
|
||||
const normalizedTargetRole = targetRole.trim();
|
||||
if (!normalizedTargetRole || authz.isAdminCaller) {
|
||||
return false;
|
||||
}
|
||||
return normalizedTargetRole !== "operator";
|
||||
}
|
||||
|
||||
function hasNonOperatorDeviceRole(input: { role?: string; roles?: string[] }): boolean {
|
||||
const roles = new Set<string>();
|
||||
const role = input.role?.trim();
|
||||
if (role) {
|
||||
roles.add(role);
|
||||
}
|
||||
for (const entry of input.roles ?? []) {
|
||||
const normalized = entry.trim();
|
||||
if (normalized) {
|
||||
roles.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...roles].some((entry) => entry !== "operator");
|
||||
}
|
||||
|
||||
function hasNonOperatorDeviceTokenRole(
|
||||
tokens: Record<string, DeviceAuthToken> | undefined,
|
||||
): boolean {
|
||||
for (const token of Object.values(tokens ?? {})) {
|
||||
const normalized = token.role.trim();
|
||||
if (normalized && normalized !== "operator") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestsNonOperatorDeviceRole(pending: { role?: string; roles?: string[] }): boolean {
|
||||
return hasNonOperatorDeviceRole(pending);
|
||||
}
|
||||
|
||||
function pairedDeviceHasNonOperatorRole(device: {
|
||||
role?: string;
|
||||
roles?: string[];
|
||||
tokens?: Record<string, DeviceAuthToken>;
|
||||
}): boolean {
|
||||
return hasNonOperatorDeviceRole(device) || hasNonOperatorDeviceTokenRole(device.tokens);
|
||||
}
|
||||
|
||||
export const deviceHandlers: GatewayRequestHandlers = {
|
||||
"device.pair.list": async ({ params, respond, client }) => {
|
||||
if (!validateDevicePairListParams(params)) {
|
||||
@@ -185,6 +243,17 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requestsNonOperatorDeviceRole(pending)) {
|
||||
context.logGateway.warn(
|
||||
`device pairing approval denied request=${requestId} reason=role-management-requires-admin`,
|
||||
);
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_PAIR_APPROVAL_DENIED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const approved = await approveDevicePairing(requestId, { callerScopes: authz.callerScopes });
|
||||
if (!approved) {
|
||||
@@ -296,6 +365,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (authz.callerDeviceId && !authz.isAdminCaller) {
|
||||
const paired = await getPairedDevice(authz.normalizedTargetDeviceId);
|
||||
if (paired && pairedDeviceHasNonOperatorRole(paired)) {
|
||||
context.logGateway.warn(
|
||||
`device pairing removal denied device=${deviceId} reason=role-management-requires-admin`,
|
||||
);
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "device pairing removal denied"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const removed = await removePairedDevice(deviceId);
|
||||
if (!removed) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId"));
|
||||
@@ -341,6 +424,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (deniesDeviceTokenRoleManagement(authz, role)) {
|
||||
logDeviceTokenRotationDenied({
|
||||
log: context.logGateway,
|
||||
deviceId,
|
||||
role,
|
||||
reason: "role-management-requires-admin",
|
||||
});
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId,
|
||||
role,
|
||||
@@ -408,6 +505,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (deniesDeviceTokenRoleManagement(authz, role)) {
|
||||
logDeviceTokenRevocationDenied({
|
||||
log: context.logGateway,
|
||||
deviceId,
|
||||
role,
|
||||
reason: "role-management-requires-admin",
|
||||
});
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_REVOCATION_DENIED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const revoked = await revokeDeviceToken({ deviceId, role, callerScopes: authz.callerScopes });
|
||||
if (!revoked.ok) {
|
||||
logDeviceTokenRevocationDenied({
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { getPairedDevice } from "../infra/device-pairing.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { GatewayClient } from "./client.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
loadDeviceIdentity,
|
||||
openTrackedWs,
|
||||
pairDeviceIdentity,
|
||||
resolveDeviceIdentityPath,
|
||||
} from "./device-authz.test-helpers.js";
|
||||
import { connectGatewayClient } from "./test-helpers.e2e.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
@@ -137,6 +143,48 @@ async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Prom
|
||||
};
|
||||
}
|
||||
|
||||
async function issueMixedRolePairingScopedDevice(
|
||||
name: string,
|
||||
opts?: { platform?: string },
|
||||
): Promise<{
|
||||
deviceId: string;
|
||||
identityPath: string;
|
||||
identity: ReturnType<typeof loadDeviceIdentity>["identity"];
|
||||
pairingToken: string;
|
||||
publicKey: string;
|
||||
}> {
|
||||
const loaded = loadDeviceIdentity(name);
|
||||
const request = await requestDevicePairing({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
publicKey: loaded.publicKey,
|
||||
role: "operator",
|
||||
roles: ["operator", "node"],
|
||||
scopes: ["operator.pairing"],
|
||||
...(opts?.platform ? { platform: opts.platform } : {}),
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
const approved = await approveDevicePairing(request.request.requestId, {
|
||||
callerScopes: ["operator.pairing"],
|
||||
});
|
||||
expect(approved?.status).toBe("approved");
|
||||
if (approved?.status !== "approved") {
|
||||
throw new Error("expected mixed-role device approval");
|
||||
}
|
||||
const pairingToken = approved.device.tokens?.operator?.token;
|
||||
if (!pairingToken) {
|
||||
throw new Error(`expected operator token for paired device ${loaded.identity.deviceId}`);
|
||||
}
|
||||
expect(approved.device.tokens?.node?.token).toBeTypeOf("string");
|
||||
return {
|
||||
deviceId: loaded.identity.deviceId,
|
||||
identityPath: loaded.identityPath,
|
||||
identity: loaded.identity,
|
||||
pairingToken,
|
||||
publicKey: loaded.publicKey,
|
||||
};
|
||||
}
|
||||
|
||||
describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
|
||||
test("rejects a device-token caller rotating or revoking another device's token", async () => {
|
||||
const started = await startServer("secret");
|
||||
@@ -219,6 +267,244 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a pairing-scoped operator session rotating a revoked node token", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const device = await issueMixedRolePairingScopedDevice("same-device-node-token-rotate");
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
await connectOk(started.ws);
|
||||
|
||||
const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", {
|
||||
deviceId: device.deviceId,
|
||||
role: "node",
|
||||
});
|
||||
expect(revoke.ok).toBe(true);
|
||||
expect(revoke.payload?.revokedAtMs).toBeTypeOf("number");
|
||||
|
||||
const pairedAfterRevoke = await getPairedDevice(device.deviceId);
|
||||
const revokedNodeToken = pairedAfterRevoke?.tokens?.node;
|
||||
expect(revokedNodeToken?.revokedAtMs).toBeTypeOf("number");
|
||||
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: device.identityPath,
|
||||
deviceToken: device.pairingToken,
|
||||
});
|
||||
|
||||
const rotate = await rpcReq(pairingWs, "device.token.rotate", {
|
||||
deviceId: device.deviceId,
|
||||
role: "node",
|
||||
});
|
||||
expect(rotate.ok).toBe(false);
|
||||
expect(rotate.error?.message).toBe("device token rotation denied");
|
||||
|
||||
const pairedAfterRotate = await getPairedDevice(device.deviceId);
|
||||
expect(pairedAfterRotate?.tokens?.node?.token).toBe(revokedNodeToken?.token);
|
||||
expect(pairedAfterRotate?.tokens?.node?.revokedAtMs).toBe(revokedNodeToken?.revokedAtMs);
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a pairing-scoped operator session approving a refreshed node token", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const device = await issueMixedRolePairingScopedDevice("same-device-node-pair-approve");
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
await connectOk(started.ws);
|
||||
|
||||
const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", {
|
||||
deviceId: device.deviceId,
|
||||
role: "node",
|
||||
});
|
||||
expect(revoke.ok).toBe(true);
|
||||
const pairedAfterRevoke = await getPairedDevice(device.deviceId);
|
||||
const revokedNodeToken = pairedAfterRevoke?.tokens?.node;
|
||||
expect(revokedNodeToken?.revokedAtMs).toBeTypeOf("number");
|
||||
|
||||
const request = await requestDevicePairing({
|
||||
deviceId: device.deviceId,
|
||||
publicKey: device.publicKey,
|
||||
role: "node",
|
||||
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.NODE,
|
||||
});
|
||||
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: device.identityPath,
|
||||
deviceToken: device.pairingToken,
|
||||
});
|
||||
|
||||
const approve = await rpcReq(pairingWs, "device.pair.approve", {
|
||||
requestId: request.request.requestId,
|
||||
});
|
||||
expect(approve.ok).toBe(false);
|
||||
expect(approve.error?.message).toBe("device pairing approval denied");
|
||||
|
||||
const pairedAfterApprove = await getPairedDevice(device.deviceId);
|
||||
expect(pairedAfterApprove?.tokens?.node?.token).toBe(revokedNodeToken?.token);
|
||||
expect(pairedAfterApprove?.tokens?.node?.revokedAtMs).toBe(revokedNodeToken?.revokedAtMs);
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects local node reconnect after node token revocation", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const device = await issueMixedRolePairingScopedDevice("same-device-node-reconnect");
|
||||
|
||||
try {
|
||||
await connectOk(started.ws);
|
||||
|
||||
const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", {
|
||||
deviceId: device.deviceId,
|
||||
role: "node",
|
||||
});
|
||||
expect(revoke.ok).toBe(true);
|
||||
const pairedAfterRevoke = await getPairedDevice(device.deviceId);
|
||||
const revokedNodeToken = pairedAfterRevoke?.tokens?.node;
|
||||
expect(revokedNodeToken?.revokedAtMs).toBeTypeOf("number");
|
||||
|
||||
await expect(
|
||||
connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${started.port}`,
|
||||
token: "secret",
|
||||
role: "node",
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: "node-token-revoked",
|
||||
clientVersion: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
scopes: [],
|
||||
commands: ["system.run"],
|
||||
deviceIdentity: device.identity,
|
||||
timeoutMessage: "timeout waiting for revoked node reconnect",
|
||||
}),
|
||||
).rejects.toThrow("role upgrade pending approval");
|
||||
|
||||
const pairedAfterReconnect = await getPairedDevice(device.deviceId);
|
||||
expect(pairedAfterReconnect?.tokens?.node?.token).toBe(revokedNodeToken?.token);
|
||||
expect(pairedAfterReconnect?.tokens?.node?.revokedAtMs).toBe(revokedNodeToken?.revokedAtMs);
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects local node reconnect with metadata mismatch after node token revocation", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const device = await issueMixedRolePairingScopedDevice("same-device-node-metadata-reconnect", {
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
try {
|
||||
await connectOk(started.ws);
|
||||
|
||||
const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", {
|
||||
deviceId: device.deviceId,
|
||||
role: "node",
|
||||
});
|
||||
expect(revoke.ok).toBe(true);
|
||||
const pairedAfterRevoke = await getPairedDevice(device.deviceId);
|
||||
const revokedNodeToken = pairedAfterRevoke?.tokens?.node;
|
||||
expect(pairedAfterRevoke?.platform).toBe("linux");
|
||||
expect(revokedNodeToken?.revokedAtMs).toBeTypeOf("number");
|
||||
|
||||
await expect(
|
||||
connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${started.port}`,
|
||||
token: "secret",
|
||||
role: "node",
|
||||
clientName: GATEWAY_CLIENT_NAMES.MACOS_APP,
|
||||
clientDisplayName: "node-token-metadata-mismatch",
|
||||
clientVersion: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: GATEWAY_CLIENT_MODES.UI,
|
||||
scopes: [],
|
||||
commands: ["system.run"],
|
||||
deviceIdentity: device.identity,
|
||||
timeoutMessage: "timeout waiting for metadata mismatch node reconnect",
|
||||
}),
|
||||
).rejects.toThrow("device metadata change pending approval");
|
||||
|
||||
const pairedAfterReconnect = await getPairedDevice(device.deviceId);
|
||||
expect(pairedAfterReconnect?.platform).toBe("linux");
|
||||
expect(pairedAfterReconnect?.tokens?.node?.token).toBe(revokedNodeToken?.token);
|
||||
expect(pairedAfterReconnect?.tokens?.node?.revokedAtMs).toBe(revokedNodeToken?.revokedAtMs);
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects self-removal before local node reconnect after node token revocation", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const device = await issueMixedRolePairingScopedDevice("same-device-node-remove-reconnect");
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
await connectOk(started.ws);
|
||||
|
||||
const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", {
|
||||
deviceId: device.deviceId,
|
||||
role: "node",
|
||||
});
|
||||
expect(revoke.ok).toBe(true);
|
||||
const pairedAfterRevoke = await getPairedDevice(device.deviceId);
|
||||
const revokedNodeToken = pairedAfterRevoke?.tokens?.node;
|
||||
expect(revokedNodeToken?.revokedAtMs).toBeTypeOf("number");
|
||||
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: device.identityPath,
|
||||
deviceToken: device.pairingToken,
|
||||
});
|
||||
|
||||
const remove = await rpcReq(pairingWs, "device.pair.remove", {
|
||||
deviceId: device.deviceId,
|
||||
});
|
||||
expect(remove.ok).toBe(false);
|
||||
expect(remove.error?.message).toBe("device pairing removal denied");
|
||||
|
||||
await expect(
|
||||
connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${started.port}`,
|
||||
token: "secret",
|
||||
role: "node",
|
||||
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
clientDisplayName: "node-token-removal-denied",
|
||||
clientVersion: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
scopes: [],
|
||||
commands: ["system.run"],
|
||||
deviceIdentity: device.identity,
|
||||
timeoutMessage: "timeout waiting for denied removal node reconnect",
|
||||
}),
|
||||
).rejects.toThrow("role upgrade pending approval");
|
||||
|
||||
const pairedAfterReconnect = await getPairedDevice(device.deviceId);
|
||||
expect(pairedAfterReconnect?.tokens?.node?.token).toBe(revokedNodeToken?.token);
|
||||
expect(pairedAfterReconnect?.tokens?.node?.revokedAtMs).toBe(revokedNodeToken?.revokedAtMs);
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway device.token.rotate/revoke caller scope guard", () => {
|
||||
|
||||
@@ -1012,14 +1012,19 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
publicKey: devicePublicKey,
|
||||
});
|
||||
}
|
||||
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
isControlUi,
|
||||
isWebchat,
|
||||
isNativeAppUi,
|
||||
reason,
|
||||
});
|
||||
const allowSilentExistingNonOperatorPairing = !(
|
||||
existingPairedDevice && role !== "operator"
|
||||
);
|
||||
const allowSilentLocalPairing =
|
||||
allowSilentExistingNonOperatorPairing &&
|
||||
shouldAllowSilentLocalPairing({
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
isControlUi,
|
||||
isWebchat,
|
||||
isNativeAppUi,
|
||||
reason,
|
||||
});
|
||||
const allowSilentTrustedCidrsNodePairing = shouldAutoApproveNodePairingFromTrustedCidrs(
|
||||
{
|
||||
existingPairedDevice: Boolean(existingPairedDevice),
|
||||
|
||||
Reference in New Issue
Block a user