fix(gateway): skip operator pairing on valid shared auth

This commit is contained in:
Peter Steinberger
2026-02-22 19:22:37 +01:00
parent 9da5f9819b
commit b13bba9c35
2 changed files with 83 additions and 101 deletions

View File

@@ -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 { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
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 identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
@@ -1093,12 +1093,10 @@ describe("gateway server auth/connect", () => {
client,
device: buildDevice(["operator.read"], initialNonce),
});
if (!initial.ok) {
await approvePendingPairingIfNeeded();
}
let paired = await getPairedDevice(identity.deviceId);
expect(paired?.scopes).toContain("operator.read");
expect(initial.ok).toBe(true);
let pairing = await listDevicePairing();
expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
expect(await getPairedDevice(identity.deviceId)).toBeNull();
ws.close();
@@ -1110,30 +1108,16 @@ describe("gateway server auth/connect", () => {
client,
device: buildDevice(["operator.admin"], nonce2),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
await approvePendingPairingIfNeeded();
expect(res.ok).toBe(true);
pairing = await listDevicePairing();
expect(pairing.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
expect(await getPairedDevice(identity.deviceId)).toBeNull();
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();
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 { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -1200,26 +1184,23 @@ describe("gateway server auth/connect", () => {
expect(nodeConnect.error?.message ?? "").toContain("pairing required");
const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]);
expect(operatorConnect.ok).toBe(false);
expect(operatorConnect.error?.message ?? "").toContain("pairing required");
expect(operatorConnect.ok).toBe(true);
const pending = await listDevicePairing();
const pendingForTestDevice = pending.pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pendingForTestDevice).toHaveLength(1);
expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(pendingForTestDevice[0]?.scopes).toEqual(
expect.arrayContaining(["operator.read", "operator.write"]),
);
expect(pendingForTestDevice[0]?.roles).toEqual(expect.arrayContaining(["node"]));
expect(pendingForTestDevice[0]?.roles ?? []).not.toContain("operator");
if (!pendingForTestDevice[0]) {
throw new Error("expected pending pairing request");
}
await approveDevicePairing(pendingForTestDevice[0].requestId);
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"]));
expect(paired?.roles).toEqual(expect.arrayContaining(["node"]));
expect(paired?.roles ?? []).not.toContain("operator");
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
expect(approvedOperatorConnect.ok).toBe(true);
@@ -1301,7 +1282,7 @@ describe("gateway server auth/connect", () => {
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 { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -1310,10 +1291,34 @@ describe("gateway server auth/connect", () => {
await import("../infra/device-identity.js");
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-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 identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
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 signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
@@ -1337,32 +1342,6 @@ describe("gateway server auth/connect", () => {
const { server, ws, port, prevToken } = await startServerWithClient("secret");
let ws2: WebSocket | undefined;
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();
const wsReconnect = await openWs(port);
@@ -1377,8 +1356,10 @@ describe("gateway server auth/connect", () => {
expect(reconnect.ok).toBe(true);
const repaired = await getPairedDevice(deviceId);
expect(repaired?.roles).toContain("operator");
expect(repaired?.scopes).toContain("operator.read");
expect(repaired?.roles).toBeUndefined();
expect(repaired?.scopes).toBeUndefined();
const list = await listDevicePairing();
expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]);
} finally {
await server.close();
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 { tmpdir } = await import("node:os");
const { join } = await import("node:path");
@@ -1396,15 +1377,39 @@ describe("gateway server auth/connect", () => {
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } =
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");
let ws2: WebSocket | undefined;
try {
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const client = {
id: GATEWAY_CLIENT_NAMES.TEST,
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();
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);
ws2 = wsUpgrade;
const upgradeNonce = await readConnectChallengeNonce(wsUpgrade);
@@ -1470,15 +1448,13 @@ describe("gateway server auth/connect", () => {
client,
device: buildDevice(["operator.admin"], upgradeNonce),
});
expect(upgraded.ok).toBe(false);
expect(upgraded.error?.message ?? "").toContain("pairing required");
expect(upgraded.ok).toBe(true);
wsUpgrade.close();
const pendingUpgrade = (await listDevicePairing()).pending.find(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pendingUpgrade?.requestId).toBeDefined();
expect(pendingUpgrade?.scopes).toContain("operator.admin");
expect(pendingUpgrade).toBeUndefined();
const repaired = await getPairedDevice(identity.deviceId);
expect(repaired?.role).toBe("operator");
expect(repaired?.roles).toBeUndefined();