mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 10:30:21 +00:00
fix(gateway): revoke bootstrap tokens after handshake commit
This commit is contained in:
committed by
Peter Steinberger
parent
b08d58c917
commit
5e3a3c42ca
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
approvePendingPairingIfNeeded,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TEST_OPERATOR_CLIENT,
|
||||
testState,
|
||||
TRUSTED_PROXY_CONTROL_UI_HEADERS,
|
||||
waitForWsClose,
|
||||
withGatewayServer,
|
||||
writeTrustedProxyControlUiConfig,
|
||||
} from "./server.auth.shared.js";
|
||||
@@ -919,6 +920,64 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const reconcileModule = await import("./node-connect-reconcile.js");
|
||||
const reconcileSpy = vi
|
||||
.spyOn(reconcileModule, "reconcileNodePairingOnConnect")
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
ws.close();
|
||||
|
||||
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 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();
|
||||
await expect(waitForWsClose(wsFail, 1_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 } =
|
||||
|
||||
@@ -987,29 +987,6 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const deviceToken = device
|
||||
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
|
||||
: null;
|
||||
if (
|
||||
authMethod === "bootstrap-token" &&
|
||||
bootstrapProfile &&
|
||||
bootstrapTokenCandidate &&
|
||||
device
|
||||
) {
|
||||
const redemption = await redeemDeviceBootstrapTokenProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
role,
|
||||
scopes,
|
||||
});
|
||||
if (redemption.fullyRedeemed) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "node") {
|
||||
const reconciliation = await reconcileNodePairingOnConnect({
|
||||
cfg: loadConfig(),
|
||||
@@ -1181,6 +1158,34 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
stateVersion: snapshot.stateVersion.presence,
|
||||
});
|
||||
|
||||
if (
|
||||
authMethod === "bootstrap-token" &&
|
||||
bootstrapProfile &&
|
||||
bootstrapTokenCandidate &&
|
||||
device
|
||||
) {
|
||||
try {
|
||||
const redemption = await redeemDeviceBootstrapTokenProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
role,
|
||||
scopes,
|
||||
});
|
||||
if (redemption.fullyRedeemed) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logGateway.warn(
|
||||
`bootstrap token redemption bookkeeping failed device=${device.id}: ${formatForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
send({ type: "res", id: frame.id, ok: true, payload: helloOk });
|
||||
void refreshGatewayHealthSnapshot({ probe: true }).catch((err) =>
|
||||
logHealth.error(`post-connect health refresh failed: ${formatError(err)}`),
|
||||
|
||||
Reference in New Issue
Block a user