mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 00:13:28 +00:00
fix(gateway): skip operator pairing on valid shared auth
This commit is contained in:
@@ -1049,14 +1049,14 @@ describe("gateway server auth/connect", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("requires pairing for scope upgrades", async () => {
|
test("skips pairing for operator scope upgrades when shared token auth is valid", async () => {
|
||||||
const { mkdtemp } = await import("node:fs/promises");
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
const { buildDeviceAuthPayload } = await import("./device-auth.js");
|
const { buildDeviceAuthPayload } = await import("./device-auth.js");
|
||||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||||
await import("../infra/device-identity.js");
|
await import("../infra/device-identity.js");
|
||||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
|
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
|
||||||
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
||||||
@@ -1093,12 +1093,10 @@ describe("gateway server auth/connect", () => {
|
|||||||
client,
|
client,
|
||||||
device: buildDevice(["operator.read"], initialNonce),
|
device: buildDevice(["operator.read"], initialNonce),
|
||||||
});
|
});
|
||||||
if (!initial.ok) {
|
expect(initial.ok).toBe(true);
|
||||||
await approvePendingPairingIfNeeded();
|
let pairing = await listDevicePairing();
|
||||||
}
|
expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
||||||
|
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
||||||
let paired = await getPairedDevice(identity.deviceId);
|
|
||||||
expect(paired?.scopes).toContain("operator.read");
|
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|
||||||
@@ -1110,30 +1108,16 @@ describe("gateway server auth/connect", () => {
|
|||||||
client,
|
client,
|
||||||
device: buildDevice(["operator.admin"], nonce2),
|
device: buildDevice(["operator.admin"], nonce2),
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.error?.message ?? "").toContain("pairing required");
|
pairing = await listDevicePairing();
|
||||||
|
expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
||||||
await approvePendingPairingIfNeeded();
|
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
||||||
ws2.close();
|
ws2.close();
|
||||||
|
|
||||||
const ws3 = await openWs(port);
|
|
||||||
const nonce3 = await readConnectChallengeNonce(ws3);
|
|
||||||
const approved = await connectReq(ws3, {
|
|
||||||
token: "secret",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
client,
|
|
||||||
device: buildDevice(["operator.admin"], nonce3),
|
|
||||||
});
|
|
||||||
expect(approved.ok).toBe(true);
|
|
||||||
paired = await getPairedDevice(identity.deviceId);
|
|
||||||
expect(paired?.scopes).toContain("operator.admin");
|
|
||||||
|
|
||||||
ws3.close();
|
|
||||||
await server.close();
|
await server.close();
|
||||||
restoreGatewayToken(prevToken);
|
restoreGatewayToken(prevToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single approval captures pending node and operator roles for the same device", async () => {
|
test("still requires node pairing while operator shared auth succeeds for the same device", async () => {
|
||||||
const { mkdtemp } = await import("node:fs/promises");
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
@@ -1200,26 +1184,23 @@ describe("gateway server auth/connect", () => {
|
|||||||
expect(nodeConnect.error?.message ?? "").toContain("pairing required");
|
expect(nodeConnect.error?.message ?? "").toContain("pairing required");
|
||||||
|
|
||||||
const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]);
|
const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]);
|
||||||
expect(operatorConnect.ok).toBe(false);
|
expect(operatorConnect.ok).toBe(true);
|
||||||
expect(operatorConnect.error?.message ?? "").toContain("pairing required");
|
|
||||||
|
|
||||||
const pending = await listDevicePairing();
|
const pending = await listDevicePairing();
|
||||||
const pendingForTestDevice = pending.pending.filter(
|
const pendingForTestDevice = pending.pending.filter(
|
||||||
(entry) => entry.deviceId === identity.deviceId,
|
(entry) => entry.deviceId === identity.deviceId,
|
||||||
);
|
);
|
||||||
expect(pendingForTestDevice).toHaveLength(1);
|
expect(pendingForTestDevice).toHaveLength(1);
|
||||||
expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node"]));
|
||||||
expect(pendingForTestDevice[0]?.scopes).toEqual(
|
expect(pendingForTestDevice[0]?.roles ?? []).not.toContain("operator");
|
||||||
expect.arrayContaining(["operator.read", "operator.write"]),
|
|
||||||
);
|
|
||||||
if (!pendingForTestDevice[0]) {
|
if (!pendingForTestDevice[0]) {
|
||||||
throw new Error("expected pending pairing request");
|
throw new Error("expected pending pairing request");
|
||||||
}
|
}
|
||||||
await approveDevicePairing(pendingForTestDevice[0].requestId);
|
await approveDevicePairing(pendingForTestDevice[0].requestId);
|
||||||
|
|
||||||
const paired = await getPairedDevice(identity.deviceId);
|
const paired = await getPairedDevice(identity.deviceId);
|
||||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
expect(paired?.roles).toEqual(expect.arrayContaining(["node"]));
|
||||||
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"]));
|
expect(paired?.roles ?? []).not.toContain("operator");
|
||||||
|
|
||||||
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
|
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
|
||||||
expect(approvedOperatorConnect.ok).toBe(true);
|
expect(approvedOperatorConnect.ok).toBe(true);
|
||||||
@@ -1301,7 +1282,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
restoreGatewayToken(prevToken);
|
restoreGatewayToken(prevToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows legacy paired devices missing role/scope metadata", async () => {
|
test("allows operator shared auth with legacy paired metadata", async () => {
|
||||||
const { mkdtemp } = await import("node:fs/promises");
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
@@ -1310,10 +1291,34 @@ describe("gateway server auth/connect", () => {
|
|||||||
await import("../infra/device-identity.js");
|
await import("../infra/device-identity.js");
|
||||||
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js");
|
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js");
|
||||||
const { writeJsonAtomic } = await import("../infra/json-files.js");
|
const { writeJsonAtomic } = await import("../infra/json-files.js");
|
||||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||||
|
await import("../infra/device-pairing.js");
|
||||||
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-"));
|
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-"));
|
||||||
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
||||||
const deviceId = identity.deviceId;
|
const deviceId = identity.deviceId;
|
||||||
|
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||||
|
const pending = await requestDevicePairing({
|
||||||
|
deviceId,
|
||||||
|
publicKey,
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
clientId: TEST_OPERATOR_CLIENT.id,
|
||||||
|
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||||
|
displayName: "legacy-test",
|
||||||
|
platform: "test",
|
||||||
|
});
|
||||||
|
await approveDevicePairing(pending.request.requestId);
|
||||||
|
|
||||||
|
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
||||||
|
const paired = (await readJsonFile<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
||||||
|
const legacy = paired[deviceId];
|
||||||
|
if (!legacy) {
|
||||||
|
throw new Error(`Expected paired metadata for deviceId=${deviceId}`);
|
||||||
|
}
|
||||||
|
delete legacy.roles;
|
||||||
|
delete legacy.scopes;
|
||||||
|
await writeJsonAtomic(pairedPath, paired);
|
||||||
|
|
||||||
const buildDevice = (nonce: string) => {
|
const buildDevice = (nonce: string) => {
|
||||||
const signedAtMs = Date.now();
|
const signedAtMs = Date.now();
|
||||||
const payload = buildDeviceAuthPayload({
|
const payload = buildDeviceAuthPayload({
|
||||||
@@ -1337,32 +1342,6 @@ describe("gateway server auth/connect", () => {
|
|||||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
let ws2: WebSocket | undefined;
|
let ws2: WebSocket | undefined;
|
||||||
try {
|
try {
|
||||||
const initialNonce = await readConnectChallengeNonce(ws);
|
|
||||||
const initial = await connectReq(ws, {
|
|
||||||
token: "secret",
|
|
||||||
scopes: ["operator.read"],
|
|
||||||
client: TEST_OPERATOR_CLIENT,
|
|
||||||
device: buildDevice(initialNonce),
|
|
||||||
});
|
|
||||||
if (!initial.ok) {
|
|
||||||
await approvePendingPairingIfNeeded();
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialPaired = await getPairedDevice(deviceId);
|
|
||||||
expect(initialPaired?.roles).toContain("operator");
|
|
||||||
expect(initialPaired?.scopes).toContain("operator.read");
|
|
||||||
|
|
||||||
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
|
||||||
const paired =
|
|
||||||
(await readJsonFile<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
|
||||||
const legacy = paired[deviceId];
|
|
||||||
if (!legacy) {
|
|
||||||
throw new Error(`Expected paired metadata for deviceId=${deviceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete legacy.roles;
|
|
||||||
delete legacy.scopes;
|
|
||||||
await writeJsonAtomic(pairedPath, paired);
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|
||||||
const wsReconnect = await openWs(port);
|
const wsReconnect = await openWs(port);
|
||||||
@@ -1377,8 +1356,10 @@ describe("gateway server auth/connect", () => {
|
|||||||
expect(reconnect.ok).toBe(true);
|
expect(reconnect.ok).toBe(true);
|
||||||
|
|
||||||
const repaired = await getPairedDevice(deviceId);
|
const repaired = await getPairedDevice(deviceId);
|
||||||
expect(repaired?.roles).toContain("operator");
|
expect(repaired?.roles).toBeUndefined();
|
||||||
expect(repaired?.scopes).toContain("operator.read");
|
expect(repaired?.scopes).toBeUndefined();
|
||||||
|
const list = await listDevicePairing();
|
||||||
|
expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await server.close();
|
await server.close();
|
||||||
restoreGatewayToken(prevToken);
|
restoreGatewayToken(prevToken);
|
||||||
@@ -1387,7 +1368,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects scope escalation from legacy paired metadata", async () => {
|
test("allows shared-auth scope escalation even when paired metadata is legacy-shaped", async () => {
|
||||||
const { mkdtemp } = await import("node:fs/promises");
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
@@ -1396,15 +1377,39 @@ describe("gateway server auth/connect", () => {
|
|||||||
const { buildDeviceAuthPayload } = await import("./device-auth.js");
|
const { buildDeviceAuthPayload } = await import("./device-auth.js");
|
||||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||||
await import("../infra/device-identity.js");
|
await import("../infra/device-identity.js");
|
||||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||||
await import("../infra/device-pairing.js");
|
await import("../infra/device-pairing.js");
|
||||||
const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } =
|
const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } =
|
||||||
await import("../utils/message-channel.js");
|
await import("../utils/message-channel.js");
|
||||||
|
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-"));
|
||||||
|
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
||||||
|
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||||
|
const seeded = await requestDevicePairing({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
publicKey: devicePublicKey,
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
|
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
|
displayName: "legacy-upgrade-test",
|
||||||
|
platform: "test",
|
||||||
|
});
|
||||||
|
await approveDevicePairing(seeded.request.requestId);
|
||||||
|
|
||||||
|
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
||||||
|
const paired = (await readJsonFile<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
||||||
|
const legacy = paired[identity.deviceId];
|
||||||
|
expect(legacy).toBeTruthy();
|
||||||
|
if (!legacy) {
|
||||||
|
throw new Error(`Expected paired metadata for deviceId=${identity.deviceId}`);
|
||||||
|
}
|
||||||
|
delete legacy.roles;
|
||||||
|
delete legacy.scopes;
|
||||||
|
await writeJsonAtomic(pairedPath, paired);
|
||||||
|
|
||||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
let ws2: WebSocket | undefined;
|
let ws2: WebSocket | undefined;
|
||||||
try {
|
try {
|
||||||
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-"));
|
|
||||||
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
|
||||||
const client = {
|
const client = {
|
||||||
id: GATEWAY_CLIENT_NAMES.TEST,
|
id: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -1432,35 +1437,8 @@ describe("gateway server auth/connect", () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialNonce = await readConnectChallengeNonce(ws);
|
|
||||||
const initial = await connectReq(ws, {
|
|
||||||
token: "secret",
|
|
||||||
scopes: ["operator.read"],
|
|
||||||
client,
|
|
||||||
device: buildDevice(["operator.read"], initialNonce),
|
|
||||||
});
|
|
||||||
if (!initial.ok) {
|
|
||||||
const list = await listDevicePairing();
|
|
||||||
const pending = list.pending.at(0);
|
|
||||||
expect(pending?.requestId).toBeDefined();
|
|
||||||
if (pending?.requestId) {
|
|
||||||
await approveDevicePairing(pending.requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|
||||||
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
|
||||||
const paired =
|
|
||||||
(await readJsonFile<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
|
||||||
const legacy = paired[identity.deviceId];
|
|
||||||
expect(legacy).toBeTruthy();
|
|
||||||
if (!legacy) {
|
|
||||||
throw new Error(`Expected paired metadata for deviceId=${identity.deviceId}`);
|
|
||||||
}
|
|
||||||
delete legacy.roles;
|
|
||||||
delete legacy.scopes;
|
|
||||||
await writeJsonAtomic(pairedPath, paired);
|
|
||||||
|
|
||||||
const wsUpgrade = await openWs(port);
|
const wsUpgrade = await openWs(port);
|
||||||
ws2 = wsUpgrade;
|
ws2 = wsUpgrade;
|
||||||
const upgradeNonce = await readConnectChallengeNonce(wsUpgrade);
|
const upgradeNonce = await readConnectChallengeNonce(wsUpgrade);
|
||||||
@@ -1470,15 +1448,13 @@ describe("gateway server auth/connect", () => {
|
|||||||
client,
|
client,
|
||||||
device: buildDevice(["operator.admin"], upgradeNonce),
|
device: buildDevice(["operator.admin"], upgradeNonce),
|
||||||
});
|
});
|
||||||
expect(upgraded.ok).toBe(false);
|
expect(upgraded.ok).toBe(true);
|
||||||
expect(upgraded.error?.message ?? "").toContain("pairing required");
|
|
||||||
wsUpgrade.close();
|
wsUpgrade.close();
|
||||||
|
|
||||||
const pendingUpgrade = (await listDevicePairing()).pending.find(
|
const pendingUpgrade = (await listDevicePairing()).pending.find(
|
||||||
(entry) => entry.deviceId === identity.deviceId,
|
(entry) => entry.deviceId === identity.deviceId,
|
||||||
);
|
);
|
||||||
expect(pendingUpgrade?.requestId).toBeDefined();
|
expect(pendingUpgrade).toBeUndefined();
|
||||||
expect(pendingUpgrade?.scopes).toContain("operator.admin");
|
|
||||||
const repaired = await getPairedDevice(identity.deviceId);
|
const repaired = await getPairedDevice(identity.deviceId);
|
||||||
expect(repaired?.role).toBe("operator");
|
expect(repaired?.role).toBe("operator");
|
||||||
expect(repaired?.roles).toBeUndefined();
|
expect(repaired?.roles).toBeUndefined();
|
||||||
|
|||||||
@@ -542,7 +542,13 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipPairing = shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk);
|
// Shared token/password auth is already gateway-level trust for operator clients.
|
||||||
|
// In that case, don't force device pairing on first connect.
|
||||||
|
const skipPairingForOperatorSharedAuth =
|
||||||
|
role === "operator" && sharedAuthOk && !isControlUi && !isWebchat;
|
||||||
|
const skipPairing =
|
||||||
|
shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk) ||
|
||||||
|
skipPairingForOperatorSharedAuth;
|
||||||
if (device && devicePublicKey && !skipPairing) {
|
if (device && devicePublicKey && !skipPairing) {
|
||||||
const formatAuditList = (items: string[] | undefined): string => {
|
const formatAuditList = (items: string[] | undefined): string => {
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user