diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index cd0addc79cfc..8ccbf2d341e1 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -1,5 +1,5 @@ /** @vitest-environment node */ -import { createHash } from "node:crypto"; +import { createHash, webcrypto } from "node:crypto"; import { ConnectErrorDetailCodes, GATEWAY_CLIENT_CAPS, @@ -94,6 +94,37 @@ function storeDeviceAuthToken(params: { return storeScopedDeviceAuthToken({ ...params, gatewayUrl: DEFAULT_GATEWAY_URL }); } +function storeDeviceIdentity(deviceId: string) { + localStorage.setItem( + "openclaw-device-identity-v1", + JSON.stringify({ + version: 1, + deviceId, + publicKey: "AA", + privateKey: "AA", + createdAtMs: 1, + }), + ); +} + +function deferDeviceIdentityDigest() { + const digest = createDeferred(); + const digestMock = vi.fn(() => digest.promise); + vi.stubGlobal("crypto", { subtle: { digest: digestMock } }); + return { digest, digestMock }; +} + +function createDeviceTokenState(request: (method: string) => Promise) { + const state = nodes.createInitialDevicesState({ + client: { + request: request as (method: string, params?: unknown) => Promise, + }, + connected: true, + }); + state.requestGeneration = 1; + return state; +} + type HandlerMap = { close: MockWebSocketHandler[]; error: MockWebSocketHandler[]; @@ -1705,6 +1736,81 @@ describe("GatewayBrowserClient", () => { }); }); + it("selects the replacement token after a successful self rotation retires the page epoch", async () => { + localStorage.clear(); + storeDeviceIdentity("00"); + loadOrCreateDeviceIdentityMock.mockResolvedValue({ + deviceId: "00", + privateKey: "private-key", // pragma: allowlist secret + publicKey: "public-key", // pragma: allowlist secret + }); + const { digest, digestMock } = deferDeviceIdentityDigest(); + const state = createDeviceTokenState(async () => ({ + deviceId: "00", + role: "operator", + token: "replacement-device-token", + scopes: ["operator.read"], + rotatedAtMs: 1_800_000_000_000, + tokenDelivery: "in-band", + })); + + const operation = nodes.rotateDeviceToken(state, { + deviceId: "00", + gatewayUrl: DEFAULT_GATEWAY_URL, + role: "operator", + }); + await vi.waitFor(() => expect(digestMock).toHaveBeenCalledOnce()); + state.requestGeneration += 1; + digest.resolve(new Uint8Array([0]).buffer); + await expect(operation).resolves.toEqual({ + delivery: "in-band", + token: "replacement-device-token", + }); + + vi.stubGlobal("crypto", webcrypto); + const nextClient = new GatewayBrowserClient({ url: DEFAULT_GATEWAY_URL }); + const { connectFrame } = await startConnect(nextClient); + expect(connectFrame.params?.auth).toMatchObject({ + token: "replacement-device-token", + deviceToken: "replacement-device-token", + }); + nextClient.stop(); + }); + + it("selects no revoked token after a successful self revocation retires the page epoch", async () => { + localStorage.clear(); + storeDeviceIdentity("00"); + storeDeviceAuthToken({ + deviceId: "00", + role: "operator", + token: "revoked-device-token", + scopes: ["operator.read"], + }); + loadOrCreateDeviceIdentityMock.mockResolvedValue({ + deviceId: "00", + privateKey: "private-key", // pragma: allowlist secret + publicKey: "public-key", // pragma: allowlist secret + }); + const { digest, digestMock } = deferDeviceIdentityDigest(); + const state = createDeviceTokenState(async () => ({})); + + const operation = nodes.revokeDeviceToken(state, { + deviceId: "00", + gatewayUrl: DEFAULT_GATEWAY_URL, + role: "operator", + }); + await vi.waitFor(() => expect(digestMock).toHaveBeenCalledOnce()); + state.requestGeneration += 1; + digest.resolve(new Uint8Array([0]).buffer); + await operation; + + vi.stubGlobal("crypto", webcrypto); + const nextClient = new GatewayBrowserClient({ url: DEFAULT_GATEWAY_URL }); + const { connectFrame } = await startConnect(nextClient); + expect(connectFrame.params?.auth).toBeUndefined(); + nextClient.stop(); + }); + it("uses a scoped device token when legacy cleanup fails", async () => { vi.spyOn(localStorage, "removeItem").mockImplementation(() => { throw new Error("storage cleanup blocked"); diff --git a/ui/src/lib/nodes/device-token.test.ts b/ui/src/lib/nodes/device-token.test.ts index bfb1a4e040de..49bfbd07740f 100644 --- a/ui/src/lib/nodes/device-token.test.ts +++ b/ui/src/lib/nodes/device-token.test.ts @@ -5,7 +5,6 @@ import { createStorageMock } from "../../test-helpers/storage.ts"; import { clearDeviceAuthToken, loadDeviceAuthToken, - revokeDeviceToken, rotateDeviceToken, storeDeviceAuthToken, } from "./index.ts"; @@ -91,34 +90,20 @@ afterEach(() => { describe("device token request lifecycle", () => { // A retired epoch is a reconnect, not a reason to destroy the credential: the previous // token is already dead on the server, so the caller still needs this one to recover. - it("returns a rotate response from a retired request epoch without persisting it", async () => { + it("persists a rotate response after its request epoch retires before success", async () => { + storeIdentity(); + const { digest, digestMock } = deferIdentityFingerprint(); const response = deferred(); const state = createState(() => response.promise); const operation = rotateDeviceToken(state, tokenParams); state.requestGeneration += 1; response.resolve({ token: "rotated-token", tokenDelivery: "in-band", ...rotationResult }); - - expect(await operation).toEqual({ delivery: "in-band", token: "rotated-token" }); - expect(loadDeviceAuthToken(tokenParams)).toBeNull(); - }); - - it("rechecks rotate ownership after loading the local identity", async () => { - storeIdentity(); - const { digest, digestMock } = deferIdentityFingerprint(); - const state = createState(async () => ({ - token: "rotated-token", - tokenDelivery: "in-band", - ...rotationResult, - })); - - const operation = rotateDeviceToken(state, tokenParams); await vi.waitFor(() => expect(digestMock).toHaveBeenCalledOnce()); - state.requestGeneration += 1; digest.resolve(new Uint8Array([0]).buffer); expect(await operation).toEqual({ delivery: "in-band", token: "rotated-token" }); - expect(loadDeviceAuthToken(tokenParams)).toBeNull(); + expect(loadDeviceAuthToken(tokenParams)?.token).toBe("rotated-token"); }); it("reports a cross-device rotation the Gateway withheld the token for", async () => { @@ -228,21 +213,6 @@ describe("device token request lifecycle", () => { expect(loadDeviceAuthToken(tokenParams)).toBeNull(); }); - it("does not clear a current token when a revoke request retires during identity loading", async () => { - storeIdentity(); - storeDeviceAuthToken({ ...tokenParams, token: "current-token", scopes: ["operator.read"] }); - const { digest, digestMock } = deferIdentityFingerprint(); - const state = createState(async () => ({})); - - const operation = revokeDeviceToken(state, tokenParams); - await vi.waitFor(() => expect(digestMock).toHaveBeenCalledOnce()); - state.requestGeneration += 1; - digest.resolve(new Uint8Array([0]).buffer); - await operation; - - expect(loadDeviceAuthToken(tokenParams)?.token).toBe("current-token"); - }); - it("normalizes malformed persisted scopes without breaking token loading", () => { storeDeviceAuthToken({ ...tokenParams, token: "current-token", scopes: [] }); const key = storedTokenKey(); diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts index 9da0ca3f9b5b..4ed14acd1957 100644 --- a/ui/src/lib/nodes/index.ts +++ b/ui/src/lib/nodes/index.ts @@ -549,29 +549,23 @@ export async function rotateDeviceToken( tokenDelivery?: string; }>("device.token.rotate", requestParams); const outcome = classifyRotationOutcome(res, requestParams); - // A retired epoch stops every state write below, but never the return: the previous - // credential is already dead on the server, so discarding this response would leave - // the operator locked out with no way to ask for the replacement again. - if (!isCurrentNodesRequest(state, client, generation)) { - return outcome; - } if (outcome.delivery === "in-band") { const identity = await loadOrCreateDeviceIdentity(); - if (!isCurrentNodesRequest(state, client, generation)) { - return outcome; - } - const role = res.role ?? params.role; - if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) { + // RPC success retires the old bearer and may immediately reconnect the page. + // Commit the exact captured credential scope before fencing render projections. + if (res.deviceId === identity.deviceId || requestParams.deviceId === identity.deviceId) { storeDeviceAuthToken({ deviceId: identity.deviceId, gatewayUrl, - role, + role: requestParams.role, token: outcome.token, - scopes: res.scopes ?? params.scopes ?? [], + scopes: res.scopes ?? requestParams.scopes ?? [], }); } } - await loadDevices(state); + if (isCurrentNodesRequest(state, client, generation)) { + await loadDevices(state); + } return outcome; } catch (err) { if (isCurrentNodesRequest(state, client, generation)) { @@ -593,18 +587,14 @@ export async function revokeDeviceToken( try { const { gatewayUrl, ...requestParams } = params; await client.request("device.token.revoke", requestParams); - if (!isCurrentNodesRequest(state, client, generation)) { - return; - } const identity = await loadOrCreateDeviceIdentity(); - if (!isCurrentNodesRequest(state, client, generation)) { - return; - } - if (params.deviceId === identity.deviceId) { + // Clearing the successfully revoked credential belongs to this captured scope, + // not to the page generation invalidated by the resulting reconnect. + if (requestParams.deviceId === identity.deviceId) { clearDeviceAuthToken({ deviceId: identity.deviceId, gatewayUrl, - role: params.role, + role: requestParams.role, }); } if (isCurrentNodesRequest(state, client, generation)) {