fix(gateway): revoke bootstrap tokens after handshake commit

This commit is contained in:
Ayaan Zaidi
2026-04-03 16:41:22 +05:30
committed by Peter Steinberger
parent b08d58c917
commit 5e3a3c42ca
2 changed files with 88 additions and 24 deletions

View File

@@ -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 } =

View File

@@ -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)}`),