mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(gateway): split Control UI auth test coverage (#122660)
* test(gateway): split control UI auth suite * test(gateway): consolidate control UI auth fixtures * test(gateway): clean up split auth fixtures
This commit is contained in:
committed by
GitHub
parent
282e6a47ae
commit
8060ef8937
@@ -684,7 +684,6 @@ src/gateway/server-restart-sentinel.test.ts
|
||||
src/gateway/server-startup-config.secrets.test.ts
|
||||
src/gateway/server-startup-post-attach.test.ts
|
||||
src/gateway/server-startup-post-attach.ts
|
||||
src/gateway/server.auth.control-ui.suite.ts
|
||||
src/gateway/server.chat.gateway-server-chat-b.test.ts
|
||||
src/gateway/server.chat.gateway-server-chat.test.ts
|
||||
src/gateway/server.config-patch.test.ts
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import {
|
||||
createOperatorIdentityFixture,
|
||||
expectArrayIncludes,
|
||||
REMOTE_BOOTSTRAP_HEADERS,
|
||||
startControlUiServer,
|
||||
} from "./server.auth.control-ui.fixtures.test-support.js";
|
||||
import {
|
||||
connectReq,
|
||||
ConnectErrorDetailCodes,
|
||||
openWs,
|
||||
restoreGatewayToken,
|
||||
waitForWsClose,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function registerControlUiBootstrapLifecycleSuite(): void {
|
||||
test("qr bootstrap retry keeps full operator handoff after paired approval", async () => {
|
||||
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
||||
await import("../infra/device-bootstrap.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { approveBootstrapDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-node-retry-",
|
||||
);
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.3.30",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const pending = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
role: "node",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
clientId: client.id,
|
||||
clientMode: client.mode,
|
||||
displayName: client.id,
|
||||
platform: client.platform,
|
||||
deviceFamily: client.deviceFamily,
|
||||
silent: true,
|
||||
});
|
||||
await approveBootstrapDevicePairing(
|
||||
pending.request.requestId,
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
);
|
||||
|
||||
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const retry = await connectReq(wsRetry, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(retry.ok).toBe(true);
|
||||
const payload = retry.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.auth?.deviceToken).toBeTruthy();
|
||||
const operatorHandoff = payload?.auth?.deviceTokens?.find(
|
||||
(entry) => entry.role === "operator",
|
||||
);
|
||||
expect(operatorHandoff?.deviceToken).toBeTruthy();
|
||||
expect(operatorHandoff?.scopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorHandoff?.scopes).toContain("operator.admin");
|
||||
wsRetry.close();
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejected non-baseline bootstrap request cannot recreate pending node pairing", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-node-reject-",
|
||||
);
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.3.30",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsInitial, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
expect(
|
||||
initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined,
|
||||
).toMatchObject({
|
||||
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
pauseReconnect: false,
|
||||
});
|
||||
wsInitial.close();
|
||||
|
||||
const pending = (await listDevicePairing()).pending.find(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
if (!pending) {
|
||||
throw new Error("expected pending bootstrap pairing request");
|
||||
}
|
||||
await expect(rejectDevicePairing(pending.requestId)).resolves.toEqual({
|
||||
requestId: pending.requestId,
|
||||
deviceId: identity.deviceId,
|
||||
});
|
||||
|
||||
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const retry = await connectReq(wsRetry, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(retry.ok).toBe(false);
|
||||
expect((retry.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
||||
);
|
||||
wsRetry.close();
|
||||
expect(
|
||||
(await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const reconcileModule = await import("./node-connect-reconcile.js");
|
||||
const reconcileSpy = vi
|
||||
.spyOn(reconcileModule, "reconcileNodePairingOnConnect")
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, client } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-reconcile-fail-",
|
||||
);
|
||||
const nodeClient = {
|
||||
...client,
|
||||
id: "openclaw-android",
|
||||
mode: "node",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsInitial, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: nodeClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
wsInitial.close();
|
||||
const pending = (await listDevicePairing()).pending.find(
|
||||
(entry) => entry.clientId === nodeClient.id,
|
||||
);
|
||||
if (!pending) {
|
||||
throw new Error("expected pending bootstrap pairing request");
|
||||
}
|
||||
await approveDevicePairing(pending.requestId, { callerScopes: ["operator.pairing"] });
|
||||
|
||||
const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
await expect(
|
||||
connectReq(wsFail, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: nodeClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
// The full agentic shard can saturate the event loop enough that the
|
||||
// server-side close after a pre-hello failure arrives later than 1s.
|
||||
await expect(waitForWsClose(wsFail, 5_000)).resolves.toBe(true);
|
||||
|
||||
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const retry = await connectReq(wsRetry, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: nodeClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(retry.ok).toBe(true);
|
||||
wsRetry.close();
|
||||
} finally {
|
||||
reconcileSpy.mockRestore();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-role-upgrade-",
|
||||
);
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.3.30",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
|
||||
try {
|
||||
const seededRequest = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
clientId: client.id,
|
||||
clientMode: client.mode,
|
||||
platform: client.platform,
|
||||
deviceFamily: client.deviceFamily,
|
||||
});
|
||||
await approveDevicePairing(seededRequest.request.requestId, {
|
||||
callerScopes: ["operator.read"],
|
||||
});
|
||||
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
const wsUpgrade = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const upgrade = await connectReq(wsUpgrade, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(upgrade.ok).toBe(false);
|
||||
expect(upgrade.error?.message ?? "").toContain("pairing required");
|
||||
expect((upgrade.error?.details as { code?: string; reason?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
expect(
|
||||
(upgrade.error?.details as { code?: string; reason?: string } | undefined)?.reason,
|
||||
).toBe("role-upgrade");
|
||||
expect(
|
||||
(
|
||||
upgrade.error?.details as
|
||||
| {
|
||||
requestedRole?: string;
|
||||
approvedRoles?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.requestedRole,
|
||||
).toBe("node");
|
||||
expect(
|
||||
(
|
||||
upgrade.error?.details as
|
||||
| {
|
||||
requestedRole?: string;
|
||||
approvedRoles?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.approvedRoles,
|
||||
).toEqual(["operator"]);
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.role).toBe("node");
|
||||
expect(pending[0]?.roles).toEqual(["node"]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expectArrayIncludes(paired?.roles, ["operator"]);
|
||||
wsUpgrade.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, identity, client } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-operator-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["operator"],
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
});
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
expect(initial.error?.message ?? "").toContain("pairing required");
|
||||
expect((initial.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.role).toBe("operator");
|
||||
expectArrayIncludes(pending[0]?.scopes, ["operator.read"]);
|
||||
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
||||
wsBootstrap.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { startControlUiServerWithClient } from "./server.auth.control-ui.fixtures.test-support.js";
|
||||
import {
|
||||
connectReq,
|
||||
ConnectErrorDetailCodes,
|
||||
ensurePairedDeviceTokenForCurrentIdentity,
|
||||
openWs,
|
||||
restoreGatewayToken,
|
||||
startRateLimitedTokenServerWithPairedDeviceToken,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function registerControlUiDeviceTokenSuite(): void {
|
||||
test("device token auth matrix", async () => {
|
||||
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret");
|
||||
const { identity, deviceToken, deviceIdentityPath } =
|
||||
await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
||||
ws.close();
|
||||
|
||||
const scenarios: Array<{
|
||||
name: string;
|
||||
opts: Parameters<typeof connectReq>[1];
|
||||
assert: (res: Awaited<ReturnType<typeof connectReq>>) => void;
|
||||
}> = [
|
||||
{
|
||||
name: "accepts device token auth for paired device",
|
||||
opts: { token: deviceToken },
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accepts explicit auth.deviceToken when shared token is omitted",
|
||||
opts: {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken,
|
||||
},
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses explicit auth.deviceToken fallback when shared token is wrong",
|
||||
opts: {
|
||||
token: "wrong",
|
||||
deviceToken,
|
||||
},
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps shared token mismatch reason when fallback device-token check fails",
|
||||
opts: { token: "wrong" },
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("gateway token mismatch");
|
||||
expect(res.error?.message ?? "").not.toContain("device token mismatch");
|
||||
const details = res.error?.details as
|
||||
| {
|
||||
code?: string;
|
||||
canRetryWithDeviceToken?: boolean;
|
||||
recommendedNextStep?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH);
|
||||
expect(details?.canRetryWithDeviceToken).toBe(true);
|
||||
expect(details?.recommendedNextStep).toBe("retry_with_device_token");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reports device token mismatch when explicit auth.deviceToken is wrong",
|
||||
opts: {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: "not-a-valid-device-token",
|
||||
},
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("device token mismatch");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
for (const scenario of scenarios) {
|
||||
const ws2 = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws2, {
|
||||
...scenario.opts,
|
||||
deviceIdentityPath,
|
||||
});
|
||||
scenario.assert(res);
|
||||
} finally {
|
||||
ws2.close();
|
||||
}
|
||||
}
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.lastSeenReason).toBe("connect");
|
||||
expect(typeof paired?.lastSeenAtMs).toBe("number");
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps shared-secret lockout separate from device-token auth", async () => {
|
||||
const { server, port, prevToken, deviceToken, deviceIdentityPath } =
|
||||
await startRateLimitedTokenServerWithPairedDeviceToken();
|
||||
try {
|
||||
const wsBadShared = await openWs(port);
|
||||
const badShared = await connectReq(wsBadShared, { token: "wrong", device: null });
|
||||
expect(badShared.ok).toBe(false);
|
||||
wsBadShared.close();
|
||||
|
||||
const wsSharedLocked = await openWs(port);
|
||||
const sharedLocked = await connectReq(wsSharedLocked, { token: "secret", device: null });
|
||||
expect(sharedLocked.ok).toBe(false);
|
||||
expect(sharedLocked.error?.message ?? "").toContain("retry later");
|
||||
wsSharedLocked.close();
|
||||
|
||||
const wsDevice = await openWs(port);
|
||||
const deviceOk = await connectReq(wsDevice, { token: deviceToken, deviceIdentityPath });
|
||||
expect(deviceOk.ok).toBe(true);
|
||||
wsDevice.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps device-token lockout separate from shared-secret auth", async () => {
|
||||
const { server, port, prevToken, deviceToken, deviceIdentityPath } =
|
||||
await startRateLimitedTokenServerWithPairedDeviceToken();
|
||||
try {
|
||||
const wsBadDevice = await openWs(port);
|
||||
const badDevice = await connectReq(wsBadDevice, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: "wrong",
|
||||
deviceIdentityPath,
|
||||
});
|
||||
expect(badDevice.ok).toBe(false);
|
||||
wsBadDevice.close();
|
||||
|
||||
const wsDeviceLocked = await openWs(port);
|
||||
const deviceLocked = await connectReq(wsDeviceLocked, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: "wrong",
|
||||
deviceIdentityPath,
|
||||
});
|
||||
expect(deviceLocked.ok).toBe(false);
|
||||
expect(deviceLocked.error?.message ?? "").toContain("retry later");
|
||||
wsDeviceLocked.close();
|
||||
|
||||
const wsShared = await openWs(port);
|
||||
const sharedOk = await connectReq(wsShared, { token: "secret", device: null });
|
||||
expect(sharedOk.ok).toBe(true);
|
||||
wsShared.close();
|
||||
|
||||
const wsDeviceReal = await openWs(port);
|
||||
const deviceStillLocked = await connectReq(wsDeviceReal, {
|
||||
token: deviceToken,
|
||||
deviceIdentityPath,
|
||||
});
|
||||
expect(deviceStillLocked.ok).toBe(false);
|
||||
expect(deviceStillLocked.error?.message ?? "").toContain("retry later");
|
||||
wsDeviceReal.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects revoked device token", async () => {
|
||||
const { revokeDeviceToken } = await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret");
|
||||
const { identity, deviceToken, deviceIdentityPath } =
|
||||
await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const res2 = await connectReq(ws2, { token: deviceToken, deviceIdentityPath });
|
||||
expect(res2.ok).toBe(false);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
createSignedDevice,
|
||||
restoreGatewayToken,
|
||||
startTestGatewayServer,
|
||||
startServer,
|
||||
startServerWithClient,
|
||||
TEST_OPERATOR_CLIENT,
|
||||
withGatewayServer,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function expectArrayIncludes(actual: unknown, expectedValues: string[]): void {
|
||||
expect(Array.isArray(actual)).toBe(true);
|
||||
const values = actual as unknown[];
|
||||
for (const expected of expectedValues) {
|
||||
expect(values).toContain(expected);
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSignedDeviceForIdentity = async (params: {
|
||||
identityPath: string;
|
||||
client: { id: string; mode: string };
|
||||
nonce: string;
|
||||
scopes: string[];
|
||||
role?: "operator" | "node";
|
||||
}) => {
|
||||
const { device } = await createSignedDevice({
|
||||
token: "secret",
|
||||
scopes: params.scopes,
|
||||
clientId: params.client.id,
|
||||
clientMode: params.client.mode,
|
||||
role: params.role ?? "operator",
|
||||
identityPath: params.identityPath,
|
||||
nonce: params.nonce,
|
||||
});
|
||||
return device;
|
||||
};
|
||||
|
||||
export const REMOTE_BOOTSTRAP_HEADERS = {
|
||||
"x-forwarded-for": "10.0.0.14",
|
||||
};
|
||||
|
||||
export const createOperatorIdentityFixture = async (identityPrefix: string) => {
|
||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("OPENCLAW_STATE_DIR must be set by the gateway test hooks");
|
||||
}
|
||||
const identityPath = path.join(stateDir, `${identityPrefix}${randomUUID()}.sqlite`);
|
||||
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
|
||||
return {
|
||||
identityPath,
|
||||
identity,
|
||||
client: { ...TEST_OPERATOR_CLIENT },
|
||||
};
|
||||
};
|
||||
|
||||
export const startControlUiServerWithOperatorIdentity = async (
|
||||
identityPrefix = "openclaw-device-scope-",
|
||||
) => {
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix);
|
||||
return { server, port, prevToken, identityPath, identity, client };
|
||||
};
|
||||
|
||||
export const withControlUiGatewayServer = async <T>(
|
||||
fn: (ctx: {
|
||||
port: number;
|
||||
server: Awaited<ReturnType<typeof startTestGatewayServer>>;
|
||||
}) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
return await withGatewayServer(fn, {
|
||||
serverOptions: { controlUiEnabled: true },
|
||||
});
|
||||
};
|
||||
|
||||
export const withControlUiServer = async <T>(
|
||||
fn: (ctx: { port: number }) => Promise<T>,
|
||||
token = "secret",
|
||||
opts?: Parameters<typeof startServer>[1],
|
||||
): Promise<T> => {
|
||||
const { server, port, prevToken } = await startServer(token, {
|
||||
...opts,
|
||||
controlUiEnabled: true,
|
||||
});
|
||||
try {
|
||||
return await fn({ port });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
};
|
||||
|
||||
export const startControlUiServerWithClient = async (
|
||||
token?: string,
|
||||
opts?: Parameters<typeof startServerWithClient>[1],
|
||||
) => {
|
||||
return await startServerWithClient(token, {
|
||||
...opts,
|
||||
controlUiEnabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const startControlUiServer = async (
|
||||
token?: string,
|
||||
opts?: Parameters<typeof startServer>[1],
|
||||
) => {
|
||||
return await startServer(token, {
|
||||
...opts,
|
||||
controlUiEnabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const seedApprovedOperatorReadPairing = async (params: {
|
||||
identityPrefix: string;
|
||||
clientId: string;
|
||||
clientMode: string;
|
||||
displayName: string;
|
||||
platform: string;
|
||||
scopes?: string[];
|
||||
}): Promise<{ identityPath: string; identity: { deviceId: string } }> => {
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix);
|
||||
const scopes = params.scopes ?? ["operator.read"];
|
||||
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const seeded = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: devicePublicKey,
|
||||
role: "operator",
|
||||
scopes,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
displayName: params.displayName,
|
||||
platform: params.platform,
|
||||
});
|
||||
await approveDevicePairing(seeded.request.requestId, {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
return { identityPath, identity: { deviceId: identity.deviceId } };
|
||||
};
|
||||
@@ -0,0 +1,550 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
createOperatorIdentityFixture,
|
||||
REMOTE_BOOTSTRAP_HEADERS,
|
||||
startControlUiServer,
|
||||
withControlUiServer,
|
||||
} from "./server.auth.control-ui.fixtures.test-support.js";
|
||||
import {
|
||||
connectReq,
|
||||
ConnectErrorDetailCodes,
|
||||
openWs,
|
||||
restoreGatewayToken,
|
||||
rpcReq,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function registerControlUiMobileBootstrapSuite(): void {
|
||||
const FULL_OPERATOR_SCOPES = [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.questions",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
];
|
||||
|
||||
const connectSetupCodeBootstrapNode = async (params: {
|
||||
identityPrefix: string;
|
||||
client: {
|
||||
id: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
mode: "node";
|
||||
deviceFamily: string;
|
||||
};
|
||||
limited?: boolean;
|
||||
identityFixture?: Awaited<ReturnType<typeof createOperatorIdentityFixture>>;
|
||||
}) => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const identityFixture =
|
||||
params.identityFixture ?? (await createOperatorIdentityFixture(params.identityPrefix));
|
||||
const { identityPath, identity } = identityFixture;
|
||||
return await withControlUiServer(async ({ port }) => {
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: params.limited
|
||||
? PAIRING_SETUP_BOOTSTRAP_PROFILE
|
||||
: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: params.client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
return { identity, initial };
|
||||
} finally {
|
||||
wsBootstrap.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
test("voice-node setup code reconnects with node and Talk-only operator tokens", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-voice-node-",
|
||||
);
|
||||
const client = {
|
||||
id: "node-host",
|
||||
version: "1.0.0",
|
||||
platform: "esp32",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "ESP32",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
if (!initial.ok) {
|
||||
throw new Error(`voice-node bootstrap failed: ${JSON.stringify(initial.error)}`);
|
||||
}
|
||||
expect(initial.ok).toBe(true);
|
||||
const auth = (
|
||||
initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceToken?: string;
|
||||
deviceTokens?: Array<{
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceToken?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.auth;
|
||||
expect(auth?.role).toBe("node");
|
||||
expect(auth?.scopes).toEqual([]);
|
||||
const nodeToken = auth?.deviceToken;
|
||||
if (!nodeToken) {
|
||||
throw new Error("expected issued voice-node device token");
|
||||
}
|
||||
const operatorHandoff = auth?.deviceTokens?.find((entry) => entry.role === "operator");
|
||||
expect(operatorHandoff).toMatchObject({
|
||||
scopes: ["operator.read", "operator.talk"],
|
||||
deviceToken: expect.any(String),
|
||||
});
|
||||
const operatorToken = operatorHandoff?.deviceToken;
|
||||
if (!operatorToken) {
|
||||
throw new Error("expected handed-off voice-node operator token");
|
||||
}
|
||||
expect((await listDevicePairing()).pending).toEqual([]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||
expect(paired?.approvedScopes).toEqual(["operator.read", "operator.talk"]);
|
||||
wsBootstrap.close();
|
||||
|
||||
const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const nodeReconnect = await connectReq(wsNode, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: nodeToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(nodeReconnect.ok).toBe(true);
|
||||
wsNode.close();
|
||||
|
||||
const wsOperator = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const operatorReconnect = await connectReq(wsOperator, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: operatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.talk"],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(operatorReconnect.ok).toBe(true);
|
||||
expect((await rpcReq(wsOperator, "health")).ok).toBe(true);
|
||||
const talkMode = await rpcReq(wsOperator, "talk.mode", {
|
||||
enabled: true,
|
||||
phase: "listening",
|
||||
});
|
||||
expect(talkMode.ok).toBe(true);
|
||||
expect(talkMode.payload).toMatchObject({ enabled: true, phase: "listening" });
|
||||
const adminMutation = await rpcReq(wsOperator, "set-heartbeats", { enabled: false });
|
||||
expect(adminMutation.ok).toBe(false);
|
||||
expect(adminMutation.error?.message ?? "").toContain("missing scope");
|
||||
wsOperator.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("qr setup code returns node token plus full operator handoff", async () => {
|
||||
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
||||
await import("../infra/device-bootstrap.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-node-",
|
||||
);
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.3.30",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const approvedPayload = initial.payload as
|
||||
| {
|
||||
type?: string;
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
recoveryScope?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceTokens?: Array<{
|
||||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(approvedPayload?.type).toBe("hello-ok");
|
||||
const issuedDeviceToken = approvedPayload?.auth?.deviceToken;
|
||||
if (!issuedDeviceToken) {
|
||||
throw new Error("expected issued device token");
|
||||
}
|
||||
expect(approvedPayload?.auth?.role).toBe("node");
|
||||
expect(approvedPayload?.auth?.scopes ?? []).toEqual([]);
|
||||
const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find(
|
||||
(entry) => entry.role === "operator",
|
||||
);
|
||||
const issuedOperatorToken = operatorHandoff?.deviceToken;
|
||||
if (!issuedOperatorToken) {
|
||||
throw new Error("expected handed-off operator device token");
|
||||
}
|
||||
expect(operatorHandoff?.scopes).toEqual(FULL_OPERATOR_SCOPES);
|
||||
|
||||
const pendingAfterInitial = await listDevicePairing();
|
||||
const pendingForDevice = pendingAfterInitial.pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pendingForDevice).toEqual([]);
|
||||
wsBootstrap.close();
|
||||
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||
expect(paired?.approvedScopes).toEqual(FULL_OPERATOR_SCOPES);
|
||||
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
|
||||
expect(paired?.tokens?.node?.scopes).toEqual([]);
|
||||
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(FULL_OPERATOR_SCOPES);
|
||||
|
||||
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const replay = await connectReq(wsReplay, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(replay.ok).toBe(false);
|
||||
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
||||
);
|
||||
wsReplay.close();
|
||||
|
||||
const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const reconnect = await connectReq(wsReconnect, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: issuedDeviceToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(reconnect.ok).toBe(true);
|
||||
wsReconnect.close();
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
role: "node",
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: issuedDeviceToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: issuedOperatorToken,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: issuedOperatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: issuedOperatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "Android",
|
||||
identityPrefix: "openclaw-bootstrap-android-node-",
|
||||
client: {
|
||||
id: "openclaw-android",
|
||||
version: "2026.6.2",
|
||||
platform: "Android 16",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "Android",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "iPadOS",
|
||||
identityPrefix: "openclaw-bootstrap-ipados-node-",
|
||||
client: {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.6.2",
|
||||
platform: "iPadOS 26.3.1",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "iPad",
|
||||
},
|
||||
},
|
||||
])(
|
||||
"qr setup code auto-approves $name clients when mobile metadata matches",
|
||||
async ({ client, identityPrefix }) => {
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identity, initial } = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix,
|
||||
client,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const approvedPayload = initial.payload as
|
||||
| {
|
||||
type?: string;
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(approvedPayload?.type).toBe("hello-ok");
|
||||
expect(approvedPayload?.auth?.deviceToken).toBeTruthy();
|
||||
expect(approvedPayload?.auth?.role).toBe("node");
|
||||
expect(approvedPayload?.auth?.scopes ?? []).toEqual([]);
|
||||
const operatorHandoff = approvedPayload?.auth?.deviceTokens?.find(
|
||||
(entry) => entry.role === "operator",
|
||||
);
|
||||
expect(operatorHandoff?.deviceToken).toBeTruthy();
|
||||
expect(operatorHandoff?.scopes).toEqual(FULL_OPERATOR_SCOPES);
|
||||
|
||||
const pendingAfterInitial = await listDevicePairing();
|
||||
expect(
|
||||
pendingAfterInitial.pending.filter((entry) => entry.deviceId === identity.deviceId),
|
||||
).toEqual([]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||
expect(paired?.approvedScopes).toEqual(FULL_OPERATOR_SCOPES);
|
||||
},
|
||||
);
|
||||
|
||||
test("limited qr setup keeps the previous bounded operator handoff", async () => {
|
||||
const { identity, initial } = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix: "openclaw-bootstrap-limited-node-",
|
||||
client: {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.7.13",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
},
|
||||
limited: true,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const payload = initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const operatorHandoff = payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator");
|
||||
const operatorToken = operatorHandoff?.deviceToken;
|
||||
if (!operatorToken) {
|
||||
throw new Error("expected handed-off limited operator device token");
|
||||
}
|
||||
expect(operatorHandoff?.scopes).toEqual([
|
||||
"operator.approvals",
|
||||
"operator.questions",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
|
||||
|
||||
const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.approvedScopes).not.toContain("operator.admin");
|
||||
expect(paired?.tokens?.operator?.scopes).not.toContain("operator.admin");
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: operatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: operatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
});
|
||||
|
||||
test("full qr setup upgrades an existing limited mobile pairing", async () => {
|
||||
const identityPrefix = "openclaw-bootstrap-limited-upgrade-node-";
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.7.13",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
const identityFixture = await createOperatorIdentityFixture(identityPrefix);
|
||||
const limited = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix,
|
||||
client,
|
||||
limited: true,
|
||||
identityFixture,
|
||||
});
|
||||
const upgraded = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix,
|
||||
client,
|
||||
identityFixture,
|
||||
});
|
||||
expect(upgraded.identity.deviceId).toBe(limited.identity.deviceId);
|
||||
expect(upgraded.initial.ok).toBe(true);
|
||||
|
||||
const payload = upgraded.initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceTokens?: Array<{ role?: string; scopes?: string[] }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(
|
||||
payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
|
||||
).toContain("operator.admin");
|
||||
|
||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
||||
const paired = await getPairedDevice(upgraded.identity.deviceId);
|
||||
expect(paired?.approvedScopes).toContain("operator.admin");
|
||||
expect(paired?.tokens?.operator?.scopes).toContain("operator.admin");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "mobile client id with mismatched platform metadata",
|
||||
identityPrefix: "openclaw-bootstrap-mobile-spoof-",
|
||||
client: {
|
||||
id: "openclaw-android",
|
||||
version: "2026.6.2",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "iPhone",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid non-mobile client id with mobile metadata",
|
||||
identityPrefix: "openclaw-bootstrap-node-host-spoof-",
|
||||
client: {
|
||||
id: "node-host",
|
||||
version: "2026.6.2",
|
||||
platform: "Android 16",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "Android",
|
||||
},
|
||||
},
|
||||
])(
|
||||
"requires owner approval for setup-code bootstrap spoof: $name",
|
||||
async ({ client, identityPrefix }) => {
|
||||
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identity, initial } = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix,
|
||||
client,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
expect(initial.error?.message ?? "").toContain("pairing required");
|
||||
expect(
|
||||
initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined,
|
||||
).toMatchObject({
|
||||
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
pauseReconnect: false,
|
||||
});
|
||||
|
||||
const pending = (await listDevicePairing()).pending.find(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toMatchObject({
|
||||
clientId: client.id,
|
||||
clientMode: client.mode,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
createOperatorIdentityFixture,
|
||||
seedApprovedOperatorReadPairing,
|
||||
startControlUiServer,
|
||||
} from "./server.auth.control-ui.fixtures.test-support.js";
|
||||
import {
|
||||
connectReq,
|
||||
CONTROL_UI_CLIENT,
|
||||
ConnectErrorDetailCodes,
|
||||
openWs,
|
||||
restoreGatewayToken,
|
||||
rpcReq,
|
||||
testState,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function registerControlUiOwnerBootstrapSuite(): void {
|
||||
test("silently approves host-authorized control ui owner bootstrap tokens", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { resolveSharedGatewaySessionGeneration } =
|
||||
await import("./server/ws-shared-generation.js");
|
||||
testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] };
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-control-ui-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const wsBootstrap = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.50",
|
||||
});
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const payload = initial.payload as
|
||||
| {
|
||||
type?: string;
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
recoveryScope?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.type).toBe("hello-ok");
|
||||
expect(payload?.auth?.role).toBe("operator");
|
||||
expect(payload?.auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]);
|
||||
const deviceToken = payload?.auth?.deviceToken;
|
||||
const recoveryScope = payload?.auth?.recoveryScope;
|
||||
if (!deviceToken) {
|
||||
throw new Error("expected control ui owner device token");
|
||||
}
|
||||
expect(recoveryScope).toMatch(/^[A-Za-z0-9_-]+$/u);
|
||||
expect((await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false })).ok).toBe(true);
|
||||
wsBootstrap.close();
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toEqual([]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["operator"]);
|
||||
expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]);
|
||||
const wsReload = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.50",
|
||||
});
|
||||
const reload = await connectReq(wsReload, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(reload.ok).toBe(true);
|
||||
expect(
|
||||
(reload.payload as { auth?: { recoveryScope?: string } } | undefined)?.auth?.recoveryScope,
|
||||
).toBe(recoveryScope);
|
||||
wsReload.close();
|
||||
|
||||
const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration({
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
allowTailscale: false,
|
||||
});
|
||||
if (!sharedGatewaySessionGeneration) {
|
||||
throw new Error("expected shared gateway session generation");
|
||||
}
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: deviceToken,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
requiredSharedGatewaySessionGeneration: sharedGatewaySessionGeneration,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
issuer: {
|
||||
kind: "shared-gateway-auth",
|
||||
generation: sharedGatewaySessionGeneration,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: deviceToken,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
requiredSharedGatewaySessionGeneration: "rotated-generation",
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "issuer-generation-stale" });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps generic control ui bootstrap tokens on the bounded profile", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] };
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-control-ui-bounded-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["operator"],
|
||||
scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
purpose: "control-ui",
|
||||
},
|
||||
});
|
||||
const wsBootstrap = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.51",
|
||||
});
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const auth = (
|
||||
initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
scopes?: string[];
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.auth;
|
||||
expect(auth?.scopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]);
|
||||
expect(auth?.scopes).not.toContain("operator.admin");
|
||||
expect(auth?.scopes).not.toContain("operator.pairing");
|
||||
const adminMutation = await rpcReq(wsBootstrap, "set-heartbeats", { enabled: false });
|
||||
expect(adminMutation.ok).toBe(false);
|
||||
expect(adminMutation.error?.message ?? "").toContain("missing scope");
|
||||
wsBootstrap.close();
|
||||
|
||||
expect(
|
||||
(await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId),
|
||||
).toEqual([]);
|
||||
expect((await getPairedDevice(identity.deviceId))?.approvedScopes).toEqual([
|
||||
...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("silently upgrades the same control ui key with a host-authorized bootstrap", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-control-ui-owner-upgrade-",
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
displayName: "control-ui-owner-upgrade",
|
||||
platform: CONTROL_UI_CLIENT.platform,
|
||||
});
|
||||
const before = await getPairedDevice(identity.deviceId);
|
||||
const previousToken = before?.tokens?.operator?.token;
|
||||
if (!previousToken) {
|
||||
throw new Error("expected limited operator token");
|
||||
}
|
||||
|
||||
testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] };
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath: secondIdentityPath } = await createOperatorIdentityFixture(
|
||||
"openclaw-control-ui-owner-upgrade-second-browser-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: CONTROL_UI_OWNER_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const wsUpgrade = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.52",
|
||||
});
|
||||
const upgraded = await connectReq(wsUpgrade, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(upgraded.ok).toBe(true);
|
||||
const auth = (
|
||||
upgraded.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.auth;
|
||||
const upgradedToken = auth?.deviceToken;
|
||||
if (!upgradedToken) {
|
||||
throw new Error("expected upgraded operator token");
|
||||
}
|
||||
expect(upgradedToken).not.toBe(previousToken);
|
||||
expect(auth?.scopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]);
|
||||
expect((await rpcReq(wsUpgrade, "set-heartbeats", { enabled: false })).ok).toBe(true);
|
||||
wsUpgrade.close();
|
||||
|
||||
expect(
|
||||
(await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId),
|
||||
).toEqual([]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.approvedScopes).toEqual([...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES]);
|
||||
expect(paired?.tokens?.operator?.token).toBe(upgradedToken);
|
||||
|
||||
const wsReload = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.52",
|
||||
});
|
||||
const reload = await connectReq(wsReload, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: upgradedToken,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(reload.ok).toBe(true);
|
||||
wsReload.close();
|
||||
|
||||
const wsSecondBrowser = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.53",
|
||||
});
|
||||
const replay = await connectReq(wsSecondBrowser, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: [...CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: secondIdentityPath,
|
||||
});
|
||||
expect(replay.ok).toBe(false);
|
||||
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
||||
);
|
||||
wsSecondBrowser.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("requires pairing for control ui bootstrap token without control-ui purpose", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] };
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-control-ui-missing-purpose-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["operator"],
|
||||
scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
},
|
||||
});
|
||||
const wsBootstrap = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.51",
|
||||
});
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
expect(initial.error?.message ?? "").toContain("pairing required");
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.role).toBe("operator");
|
||||
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
||||
wsBootstrap.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("requires pairing for control ui node bootstrap tokens", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] };
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-control-ui-node-profile-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
purpose: "control-ui",
|
||||
},
|
||||
});
|
||||
const wsBootstrap = await openWs(port, {
|
||||
origin: "https://localhost",
|
||||
"x-forwarded-for": "203.0.113.52",
|
||||
});
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: CONTROL_UI_CLIENT,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
expect(initial.error?.message ?? "").toContain("pairing required");
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.role).toBe("node");
|
||||
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
||||
wsBootstrap.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
import {
|
||||
buildSignedDeviceForIdentity,
|
||||
createOperatorIdentityFixture,
|
||||
expectArrayIncludes,
|
||||
seedApprovedOperatorReadPairing,
|
||||
startControlUiServer,
|
||||
startControlUiServerWithOperatorIdentity,
|
||||
withControlUiServer,
|
||||
} from "./server.auth.control-ui.fixtures.test-support.js";
|
||||
import {
|
||||
BACKEND_GATEWAY_CLIENT,
|
||||
connectReq,
|
||||
CONTROL_UI_CLIENT,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
openTailscaleWs,
|
||||
openWs,
|
||||
originForPort,
|
||||
readConnectChallengeNonce,
|
||||
restoreGatewayToken,
|
||||
TEST_OPERATOR_CLIENT,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function registerControlUiPairingSuite(): void {
|
||||
const tamperPairedMetadata = async (
|
||||
deviceId: string,
|
||||
mutate: (metadata: Record<string, unknown>) => void,
|
||||
) => {
|
||||
const { withPairedDeviceRecords } = await import("../infra/device-pairing.js");
|
||||
await withPairedDeviceRecords(undefined, (pairedByDeviceId) => {
|
||||
const metadata = pairedByDeviceId[deviceId] as Record<string, unknown> | undefined;
|
||||
if (!metadata) {
|
||||
throw new Error(`Expected paired metadata for deviceId=${deviceId}`);
|
||||
}
|
||||
mutate(metadata);
|
||||
return { value: undefined, persist: true };
|
||||
});
|
||||
};
|
||||
|
||||
const stripPairedMetadataRolesAndScopes = async (deviceId: string) => {
|
||||
await tamperPairedMetadata(deviceId, (metadata) => {
|
||||
delete metadata.roles;
|
||||
delete metadata.scopes;
|
||||
});
|
||||
};
|
||||
|
||||
const overwritePairedPublicKey = async (deviceId: string, publicKey: string) => {
|
||||
await tamperPairedMetadata(deviceId, (metadata) => {
|
||||
metadata.publicKey = publicKey;
|
||||
});
|
||||
};
|
||||
|
||||
const injectMalformedPairedAccessLists = async (deviceId: string) => {
|
||||
await tamperPairedMetadata(deviceId, (metadata) => {
|
||||
metadata.roles = ["operator", null, 42, ""];
|
||||
metadata.scopes = ["operator.read", null, 42, ""];
|
||||
metadata.approvedScopes = ["operator.read", null, 42, ""];
|
||||
});
|
||||
};
|
||||
test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => {
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken, identityPath, identity, client } =
|
||||
await startControlUiServerWithOperatorIdentity();
|
||||
|
||||
const wsRemoteRead = await openWs(port, { host: "gateway.example" });
|
||||
const initialNonce = await readConnectChallengeNonce(wsRemoteRead);
|
||||
const initial = await connectReq(wsRemoteRead, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
client,
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client,
|
||||
scopes: ["operator.read"],
|
||||
nonce: initialNonce,
|
||||
}),
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
let pairing = await listDevicePairing();
|
||||
const pendingAfterRead = pairing.pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pendingAfterRead).toHaveLength(0);
|
||||
const pairedAfterRead = await getPairedDevice(identity.deviceId);
|
||||
if (!pairedAfterRead) {
|
||||
throw new Error(`expected paired device ${identity.deviceId}`);
|
||||
}
|
||||
expect(pairedAfterRead.lastSeenReason).toBe("connect");
|
||||
expect(typeof pairedAfterRead.lastSeenAtMs).toBe("number");
|
||||
wsRemoteRead.close();
|
||||
|
||||
const ws2 = await openWs(port, { host: "gateway.example" });
|
||||
const nonce2 = await readConnectChallengeNonce(ws2);
|
||||
const res = await connectReq(ws2, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client,
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client,
|
||||
scopes: ["operator.admin"],
|
||||
nonce: nonce2,
|
||||
}),
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("pairing required");
|
||||
pairing = await listDevicePairing();
|
||||
const pendingAfterAdmin = pairing.pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pendingAfterAdmin).toHaveLength(1);
|
||||
expectArrayIncludes(pendingAfterAdmin[0]?.scopes, ["operator.admin"]);
|
||||
if (!(await getPairedDevice(identity.deviceId))) {
|
||||
throw new Error(`expected paired device ${identity.deviceId}`);
|
||||
}
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("requires approval for loopback scope upgrades for control ui clients", async () => {
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-token-scope-",
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
displayName: "loopback-control-ui-upgrade",
|
||||
platform: CONTROL_UI_CLIENT.platform,
|
||||
});
|
||||
|
||||
const ws2 = await openWs(port, { origin: originForPort(port) });
|
||||
const nonce2 = await readConnectChallengeNonce(ws2);
|
||||
const upgraded = await connectReq(ws2, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client: CONTROL_UI_CLIENT,
|
||||
scopes: ["operator.admin"],
|
||||
nonce: nonce2,
|
||||
}),
|
||||
});
|
||||
expect(upgraded.ok).toBe(false);
|
||||
expect(upgraded.error?.message ?? "").toContain("pairing required");
|
||||
const pending = await listDevicePairing();
|
||||
const pendingUpgrade = pending.pending.filter((entry) => entry.deviceId === identity.deviceId);
|
||||
expect(pendingUpgrade).toHaveLength(1);
|
||||
expectArrayIncludes(pendingUpgrade[0]?.scopes, ["operator.admin"]);
|
||||
const updated = await getPairedDevice(identity.deviceId);
|
||||
expect(updated?.tokens?.operator?.scopes ?? []).not.toContain("operator.admin");
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("returns pairing-required for malformed persisted access lists", async () => {
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-malformed-access-",
|
||||
clientId: TEST_OPERATOR_CLIENT.id,
|
||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||
displayName: "malformed-access-upgrade",
|
||||
platform: TEST_OPERATOR_CLIENT.platform,
|
||||
});
|
||||
await injectMalformedPairedAccessLists(identity.deviceId);
|
||||
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
let ws: WebSocket | undefined;
|
||||
try {
|
||||
ws = await openWs(port);
|
||||
const nonce = await readConnectChallengeNonce(ws);
|
||||
const result = await connectReq(ws, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client: { ...TEST_OPERATOR_CLIENT },
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
scopes: ["operator.admin"],
|
||||
nonce,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.message ?? "").toContain("pairing required");
|
||||
expect((result.error?.details as { reason?: string } | undefined)?.reason).toBe(
|
||||
"scope-upgrade",
|
||||
);
|
||||
} finally {
|
||||
ws?.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not expose approved access when a paired device id reconnects with a different key", async () => {
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-key-mismatch-",
|
||||
clientId: TEST_OPERATOR_CLIENT.id,
|
||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||
displayName: "remote-key-mismatch",
|
||||
platform: TEST_OPERATOR_CLIENT.platform,
|
||||
});
|
||||
await overwritePairedPublicKey(identity.deviceId, "mismatched-public-key");
|
||||
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const ws2 = await openTailscaleWs(port);
|
||||
try {
|
||||
const nonce2 = await readConnectChallengeNonce(ws2);
|
||||
const mismatched = await connectReq(ws2, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client: { ...TEST_OPERATOR_CLIENT },
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
scopes: ["operator.admin"],
|
||||
nonce: nonce2,
|
||||
}),
|
||||
});
|
||||
expect(mismatched.ok).toBe(false);
|
||||
expect(mismatched.error?.message ?? "").toContain("pairing required");
|
||||
expect(
|
||||
(
|
||||
mismatched.error?.details as
|
||||
| {
|
||||
reason?: string;
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
approvedRoles?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.reason,
|
||||
).toBe("not-paired");
|
||||
expect(
|
||||
(
|
||||
mismatched.error?.details as
|
||||
| {
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.requestedRole,
|
||||
).toBe("operator");
|
||||
expect(
|
||||
(
|
||||
mismatched.error?.details as
|
||||
| {
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.requestedScopes,
|
||||
).toEqual(["operator.admin"]);
|
||||
expect(
|
||||
(
|
||||
mismatched.error?.details as
|
||||
| {
|
||||
approvedRoles?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.approvedRoles,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
mismatched.error?.details as
|
||||
| {
|
||||
approvedRoles?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.approvedScopes,
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-approves local-direct node pairing, then queues operator scope approval", async () => {
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identityPath, identity, client } =
|
||||
await createOperatorIdentityFixture("openclaw-device-scope-");
|
||||
await withControlUiServer(async ({ port }) => {
|
||||
const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => {
|
||||
const socket = await openWs(port, { host: "gateway.example" });
|
||||
try {
|
||||
const nonce = await readConnectChallengeNonce(socket);
|
||||
return await connectReq(socket, {
|
||||
token: "secret",
|
||||
role,
|
||||
scopes,
|
||||
client,
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client,
|
||||
role,
|
||||
scopes,
|
||||
nonce,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
|
||||
const nodeConnect = await connectWithNonce("node", []);
|
||||
expect(nodeConnect.ok).toBe(true);
|
||||
|
||||
const operatorConnect = await connectWithNonce("operator", [
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorConnect.ok).toBe(false);
|
||||
expect(operatorConnect.error?.message ?? "").toContain("pairing required");
|
||||
|
||||
const pending = await listDevicePairing();
|
||||
const pendingForTestDevice = pending.pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pendingForTestDevice).toHaveLength(1);
|
||||
expectArrayIncludes(pendingForTestDevice[0]?.scopes, ["operator.read", "operator.write"]);
|
||||
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expectArrayIncludes(paired?.roles, ["node", "operator"]);
|
||||
expectArrayIncludes(paired?.approvedScopes, ["operator.read", "operator.write"]);
|
||||
|
||||
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
|
||||
expect(approvedOperatorConnect.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("allows operator.read connect when device is paired with operator.admin", async () => {
|
||||
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identityPath, identity } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-admin-superset-",
|
||||
clientId: TEST_OPERATOR_CLIENT.id,
|
||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||
displayName: "operator-admin-superset",
|
||||
platform: TEST_OPERATOR_CLIENT.platform,
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const nonce2 = await readConnectChallengeNonce(ws2);
|
||||
const res = await connectReq(ws2, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
scopes: ["operator.read"],
|
||||
nonce: nonce2,
|
||||
}),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
ws2.close();
|
||||
|
||||
const list = await listDevicePairing();
|
||||
expect(list.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
||||
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("allows operator shared auth with legacy paired metadata", async () => {
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-device-legacy-meta-",
|
||||
);
|
||||
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, {
|
||||
callerScopes: pending.request.scopes ?? ["operator.admin"],
|
||||
});
|
||||
|
||||
await stripPairedMetadataRolesAndScopes(deviceId);
|
||||
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
let ws2: WebSocket | undefined;
|
||||
try {
|
||||
const wsReconnect = await openWs(port);
|
||||
ws2 = wsReconnect;
|
||||
const reconnectNonce = await readConnectChallengeNonce(wsReconnect);
|
||||
const reconnect = await connectReq(wsReconnect, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
scopes: ["operator.read"],
|
||||
nonce: reconnectNonce,
|
||||
}),
|
||||
});
|
||||
expect(reconnect.ok).toBe(true);
|
||||
|
||||
const repaired = await getPairedDevice(deviceId);
|
||||
expect(repaired?.role).toBe("operator");
|
||||
expect(repaired?.approvedScopes ?? []).toContain("operator.read");
|
||||
expect(repaired?.tokens?.operator?.scopes ?? []).toContain("operator.read");
|
||||
const list = await listDevicePairing();
|
||||
expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]);
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
ws2?.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("requires approval for local scope upgrades even when paired metadata is legacy-shaped", async () => {
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-legacy-",
|
||||
clientId: TEST_OPERATOR_CLIENT.id,
|
||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||
displayName: "legacy-upgrade-test",
|
||||
platform: "test",
|
||||
});
|
||||
|
||||
await stripPairedMetadataRolesAndScopes(identity.deviceId);
|
||||
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
let ws2: WebSocket | undefined;
|
||||
try {
|
||||
const client = { ...TEST_OPERATOR_CLIENT };
|
||||
|
||||
const wsUpgrade = await openWs(port);
|
||||
ws2 = wsUpgrade;
|
||||
const upgradeNonce = await readConnectChallengeNonce(wsUpgrade);
|
||||
const upgraded = await connectReq(wsUpgrade, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client,
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client,
|
||||
scopes: ["operator.admin"],
|
||||
nonce: upgradeNonce,
|
||||
}),
|
||||
});
|
||||
expect(upgraded.ok).toBe(false);
|
||||
expect(upgraded.error?.message ?? "").toContain("pairing required");
|
||||
expect(
|
||||
(
|
||||
upgraded.error?.details as
|
||||
| {
|
||||
reason?: string;
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.reason,
|
||||
).toBe("scope-upgrade");
|
||||
expect(
|
||||
(
|
||||
upgraded.error?.details as
|
||||
| {
|
||||
reason?: string;
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.requestedRole,
|
||||
).toBe("operator");
|
||||
expect(
|
||||
(
|
||||
upgraded.error?.details as
|
||||
| {
|
||||
reason?: string;
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.requestedScopes,
|
||||
).toEqual(["operator.admin"]);
|
||||
expect(
|
||||
(
|
||||
upgraded.error?.details as
|
||||
| {
|
||||
reason?: string;
|
||||
requestedRole?: string;
|
||||
requestedScopes?: string[];
|
||||
approvedScopes?: string[];
|
||||
}
|
||||
| undefined
|
||||
)?.approvedScopes,
|
||||
).toEqual(["operator.read"]);
|
||||
wsUpgrade.close();
|
||||
|
||||
const pendingUpgrade = (await listDevicePairing()).pending.find(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
if (!pendingUpgrade) {
|
||||
throw new Error(`expected pending upgrade for device ${identity.deviceId}`);
|
||||
}
|
||||
expectArrayIncludes(pendingUpgrade.scopes, ["operator.admin"]);
|
||||
const repaired = await getPairedDevice(identity.deviceId);
|
||||
expect(repaired?.role).toBe("operator");
|
||||
expectArrayIncludes(repaired?.approvedScopes, ["operator.read"]);
|
||||
} finally {
|
||||
ws2?.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "allows gateway backend loopback shared-auth connections without device pairing",
|
||||
client: BACKEND_GATEWAY_CLIENT,
|
||||
hosts: [undefined, "gateway.example", "172.17.0.2:18789"],
|
||||
},
|
||||
{
|
||||
name: "allows CLI clients on loopback even when the host header is not private-or-loopback",
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CLI,
|
||||
version: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
},
|
||||
hosts: ["gateway.example"],
|
||||
},
|
||||
])("$name", async ({ client, hosts }) => {
|
||||
await withControlUiServer(async ({ port }) => {
|
||||
for (const host of hosts) {
|
||||
const socket = await openWs(port, host ? { host } : undefined);
|
||||
try {
|
||||
const result = await connectReq(socket, { token: "secret", client });
|
||||
expect(result.ok, host ?? "default host").toBe(true);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => {
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" });
|
||||
try {
|
||||
const { identity, identityPath } =
|
||||
await createOperatorIdentityFixture("openclaw-cli-docker-");
|
||||
const nonce = await readConnectChallengeNonce(wsDockerCli);
|
||||
const dockerCli = await connectReq(wsDockerCli, {
|
||||
token: "secret",
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CLI,
|
||||
version: "1.0.0",
|
||||
platform: "linux",
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
},
|
||||
device: await buildSignedDeviceForIdentity({
|
||||
identityPath,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
},
|
||||
scopes: ["operator.admin"],
|
||||
nonce,
|
||||
}),
|
||||
});
|
||||
expect(dockerCli.ok).toBe(true);
|
||||
const pending = await listDevicePairing();
|
||||
expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
||||
if (!(await getPairedDevice(identity.deviceId))) {
|
||||
throw new Error(`expected paired device ${identity.deviceId}`);
|
||||
}
|
||||
} finally {
|
||||
wsDockerCli.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,12 @@
|
||||
* Gateway Control UI auth pairing tests.
|
||||
*/
|
||||
import { describe } from "vitest";
|
||||
import { registerControlUiAndPairingSuite } from "./server.auth.control-ui.suite.js";
|
||||
import { registerControlUiBootstrapLifecycleSuite } from "./server.auth.control-ui.bootstrap-lifecycle.suite.js";
|
||||
import { registerControlUiDeviceTokenSuite } from "./server.auth.control-ui.device-token.suite.js";
|
||||
import { registerControlUiMobileBootstrapSuite } from "./server.auth.control-ui.mobile-bootstrap.suite.js";
|
||||
import { registerControlUiOwnerBootstrapSuite } from "./server.auth.control-ui.owner-bootstrap.suite.js";
|
||||
import { registerControlUiPairingSuite } from "./server.auth.control-ui.pairing.suite.js";
|
||||
import { registerControlUiTrustedProxySuite } from "./server.auth.control-ui.trusted-proxy.suite.js";
|
||||
import { installGatewayTestHooks } from "./server.auth.test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
@@ -15,5 +20,10 @@ await Promise.all([
|
||||
]);
|
||||
|
||||
describe("gateway server auth/connect", () => {
|
||||
registerControlUiAndPairingSuite();
|
||||
registerControlUiTrustedProxySuite();
|
||||
registerControlUiDeviceTokenSuite();
|
||||
registerControlUiPairingSuite();
|
||||
registerControlUiMobileBootstrapSuite();
|
||||
registerControlUiBootstrapLifecycleSuite();
|
||||
registerControlUiOwnerBootstrapSuite();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { beforeAll, expect, test } from "vitest";
|
||||
import {
|
||||
createOperatorIdentityFixture,
|
||||
seedApprovedOperatorReadPairing,
|
||||
withControlUiGatewayServer,
|
||||
} from "./server.auth.control-ui.fixtures.test-support.js";
|
||||
import {
|
||||
connectReq,
|
||||
configureTrustedProxyControlUiAuth,
|
||||
CONTROL_UI_CLIENT,
|
||||
ConnectErrorDetailCodes,
|
||||
createSignedDevice,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
openWs,
|
||||
readConnectChallengeNonce,
|
||||
rpcReq,
|
||||
testState,
|
||||
TRUSTED_PROXY_CONTROL_UI_HEADERS,
|
||||
} from "./server.auth.test-helpers.js";
|
||||
|
||||
export function registerControlUiTrustedProxySuite(): void {
|
||||
const trustedProxyControlUiCases: Array<{
|
||||
name: string;
|
||||
role: "operator" | "node";
|
||||
withUnpairedNodeDevice: boolean;
|
||||
expectedOk: boolean;
|
||||
expectedErrorSubstring?: string;
|
||||
expectedErrorCode?: string;
|
||||
}> = [
|
||||
{
|
||||
name: "rejects loopback trusted-proxy control ui operator without device identity",
|
||||
role: "operator",
|
||||
withUnpairedNodeDevice: false,
|
||||
expectedOk: false,
|
||||
expectedErrorSubstring: "control ui requires device identity",
|
||||
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
},
|
||||
{
|
||||
name: "rejects trusted-proxy control ui node role without device identity",
|
||||
role: "node",
|
||||
withUnpairedNodeDevice: false,
|
||||
expectedOk: false,
|
||||
expectedErrorSubstring: "control ui requires device identity",
|
||||
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
},
|
||||
{
|
||||
name: "rejects loopback trusted-proxy control ui node role before pairing",
|
||||
role: "node",
|
||||
withUnpairedNodeDevice: true,
|
||||
expectedOk: false,
|
||||
expectedErrorSubstring: "unauthorized",
|
||||
},
|
||||
];
|
||||
const trustedProxyControlUiResults = new Map<string, Awaited<ReturnType<typeof connectReq>>>();
|
||||
|
||||
const withTrustedProxyControlUiServer = async (
|
||||
run: (port: number) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const { replaceConfigFile } = await import("../config/config.js");
|
||||
testState.gatewayAuth = undefined;
|
||||
testState.gatewayControlUi = {
|
||||
...testState.gatewayControlUi,
|
||||
allowedOrigins: ["https://localhost"],
|
||||
};
|
||||
await replaceConfigFile({
|
||||
nextConfig: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
requiredHeaders: ["x-forwarded-proto"],
|
||||
allowLoopback: true,
|
||||
},
|
||||
},
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
controlUi: { allowedOrigins: ["https://localhost"] },
|
||||
},
|
||||
},
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
await withControlUiGatewayServer(async ({ port }) => await run(port));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
for (const tc of trustedProxyControlUiCases) {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
try {
|
||||
const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
|
||||
let device: Awaited<ReturnType<typeof createSignedDevice>>["device"] | null = null;
|
||||
if (tc.withUnpairedNodeDevice) {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
if (!challengeNonce) {
|
||||
throw new Error(`expected connect challenge nonce for ${tc.name}`);
|
||||
}
|
||||
({ device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
nonce: challengeNonce,
|
||||
}));
|
||||
}
|
||||
trustedProxyControlUiResults.set(
|
||||
tc.name,
|
||||
await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: tc.role,
|
||||
scopes,
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each(trustedProxyControlUiCases)("$name", (tc) => {
|
||||
const res = trustedProxyControlUiResults.get(tc.name);
|
||||
if (!res) {
|
||||
throw new Error(`missing trusted-proxy result for ${tc.name}`);
|
||||
}
|
||||
expect(res.ok, tc.name).toBe(tc.expectedOk);
|
||||
if (!tc.expectedOk) {
|
||||
if (tc.expectedErrorSubstring) {
|
||||
expect(res.error?.message ?? "", tc.name).toContain(tc.expectedErrorSubstring);
|
||||
}
|
||||
if (tc.expectedErrorCode) {
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code, tc.name).toBe(
|
||||
tc.expectedErrorCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects trusted-proxy control ui without device identity even with self-declared scopes", async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { rejectDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { identity } = await createOperatorIdentityFixture("openclaw-control-ui-trusted-proxy-");
|
||||
const pendingRequest = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
});
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
scopes: ["operator.admin"],
|
||||
device: null,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("control ui requires device identity");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
);
|
||||
} finally {
|
||||
ws.close();
|
||||
await rejectDevicePairing(pendingRequest.request.requestId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("requires pairing for trusted-proxy control ui device identity", async () => {
|
||||
await withTrustedProxyControlUiServer(async (port) => {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
try {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
const { device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
nonce: challengeNonce,
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("pairing required");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("clears trusted-proxy control ui scopes without device identity", async () => {
|
||||
await withTrustedProxyControlUiServer(async (port) => {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
device: null,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as
|
||||
| {
|
||||
auth?: { scopes?: string[]; deviceToken?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.auth?.scopes).toEqual([]);
|
||||
expect(payload?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
||||
expect(admin.ok).toBe(false);
|
||||
expect(admin.error?.message ?? "").toContain("missing scope");
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("bounds trusted-proxy control ui scopes to proxy-declared scope header", async () => {
|
||||
await withTrustedProxyControlUiServer(async (port) => {
|
||||
const seeded = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-control-ui-trusted-proxy-bounded-",
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
displayName: "Control UI",
|
||||
platform: "web",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
});
|
||||
const ws = await openWs(port, {
|
||||
...TRUSTED_PROXY_CONTROL_UI_HEADERS,
|
||||
"x-openclaw-scopes": "operator.read",
|
||||
});
|
||||
try {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
const { device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
identityPath: seeded.identityPath,
|
||||
nonce: challengeNonce,
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as
|
||||
| {
|
||||
auth?: { scopes?: string[]; deviceToken?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.auth?.scopes).toEqual(["operator.read"]);
|
||||
expect(payload?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
||||
expect(admin.ok).toBe(false);
|
||||
expect(admin.error?.message ?? "").toContain("missing scope");
|
||||
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -41,7 +41,9 @@ describe("changed path facts", () => {
|
||||
isTestOnly: true,
|
||||
isNativeOnly: false,
|
||||
});
|
||||
expect(getChangedPathFacts("src/gateway/server.auth.control-ui.suite.ts")).toMatchObject({
|
||||
expect(
|
||||
getChangedPathFacts("src/gateway/server.auth.control-ui.trusted-proxy.suite.ts"),
|
||||
).toMatchObject({
|
||||
surface: "source",
|
||||
isChangedLaneTest: true,
|
||||
isTestOnly: true,
|
||||
|
||||
@@ -78,9 +78,11 @@ describe("CI changed Node test plan", () => {
|
||||
expect(hasBuildArtifactAffectingChange(["src/agents/foo.test.ts", "test/helpers/x.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hasBuildArtifactAffectingChange(["src/gateway/server.auth.control-ui.suite.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
hasBuildArtifactAffectingChange([
|
||||
"src/gateway/server.auth.control-ui.trusted-proxy.suite.ts",
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(hasBuildArtifactAffectingChange(["src/agents/foo.ts"])).toBe(true);
|
||||
// Build-input classification: only sources and the build pipeline can
|
||||
// change dist bytes; repo scripts, workflows, and qa scenarios cannot.
|
||||
|
||||
Reference in New Issue
Block a user