Files
openclaw/src/gateway/device-authz.test-helpers.ts
Jacob Tomlinson 4ee4960de2 Pairing: forward caller scopes during approval (#55950)
* Pairing: require caller scopes on approvals

* Gateway: reject forbidden silent pairing results
2026-03-27 18:55:33 +00:00

124 lines
3.3 KiB
TypeScript

import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { trackConnectChallengeNonce } from "./test-helpers.js";
export function resolveDeviceIdentityPath(name: string): string {
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(root, "test-device-identities", `${name}.json`);
}
export function loadDeviceIdentity(name: string): {
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
} {
const identityPath = resolveDeviceIdentityPath(name);
const identity = loadOrCreateDeviceIdentity(identityPath);
return {
identityPath,
identity,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
};
}
export async function pairDeviceIdentity(params: {
name: string;
role: "node" | "operator";
scopes: string[];
clientId?: string;
clientMode?: string;
}): Promise<{
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
}> {
const loaded = loadDeviceIdentity(params.name);
const request = await requestDevicePairing({
deviceId: loaded.identity.deviceId,
publicKey: loaded.publicKey,
role: params.role,
scopes: params.scopes,
clientId: params.clientId,
clientMode: params.clientMode,
});
await approveDevicePairing(request.request.requestId, {
callerScopes: params.scopes,
});
return loaded;
}
export async function issueOperatorToken(params: {
name: string;
approvedScopes: string[];
tokenScopes?: string[];
clientId?: string;
clientMode?: string;
}): Promise<{
deviceId: string;
identityPath: string;
token: string;
}> {
const paired = await pairDeviceIdentity({
name: params.name,
role: "operator",
scopes: params.approvedScopes,
clientId: params.clientId,
clientMode: params.clientMode,
});
if (params.tokenScopes) {
const rotated = await rotateDeviceToken({
deviceId: paired.identity.deviceId,
role: "operator",
scopes: params.tokenScopes,
});
expect(rotated.ok).toBe(true);
const token = rotated.ok ? rotated.entry.token : "";
expect(token).toBeTruthy();
return {
deviceId: paired.identity.deviceId,
identityPath: paired.identityPath,
token,
};
}
const device = await getPairedDevice(paired.identity.deviceId);
const token = device?.tokens?.operator?.token ?? "";
expect(token).toBeTruthy();
expect(device?.approvedScopes).toEqual(params.approvedScopes);
return {
deviceId: paired.identity.deviceId,
identityPath: paired.identityPath,
token,
};
}
export async function openTrackedWs(port: number): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
ws.once("open", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
return ws;
}