fix(gateway): clear pairing state on device token mismatch (#22071)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad38d1a529
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-20 18:21:13 +00:00
committed by GitHub
parent 094dbdaf2b
commit 5dd304d1c6
5 changed files with 73 additions and 6 deletions

View File

@@ -4,6 +4,7 @@ import type { DeviceIdentity } from "../infra/device-identity.js";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
const clearDevicePairingMock = vi.hoisted(() => vi.fn());
const logDebugMock = vi.hoisted(() => vi.fn());
type WsEvent = "open" | "message" | "close" | "error";
@@ -68,6 +69,14 @@ vi.mock("../infra/device-auth-store.js", async (importOriginal) => {
};
});
vi.mock("../infra/device-pairing.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/device-pairing.js")>();
return {
...actual,
clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args),
};
});
vi.mock("../logger.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logger.js")>();
return {
@@ -161,6 +170,8 @@ describe("GatewayClient close handling", () => {
beforeEach(() => {
wsInstances.length = 0;
clearDeviceAuthTokenMock.mockReset();
clearDevicePairingMock.mockReset();
clearDevicePairingMock.mockResolvedValue(true);
logDebugMock.mockReset();
});
@@ -184,6 +195,7 @@ describe("GatewayClient close handling", () => {
);
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" });
expect(clearDevicePairingMock).toHaveBeenCalledWith("dev-1");
expect(onClose).toHaveBeenCalledWith(
1008,
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
@@ -215,6 +227,34 @@ describe("GatewayClient close handling", () => {
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("failed clearing stale device-auth token"),
);
expect(clearDevicePairingMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});
it("does not break close flow when pairing clear rejects", async () => {
clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable"));
const onClose = vi.fn();
const identity: DeviceIdentity = {
deviceId: "dev-3",
privateKeyPem: "private-key",
publicKeyPem: "public-key",
};
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
deviceIdentity: identity,
onClose,
});
client.start();
expect(() => {
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
}).not.toThrow();
await Promise.resolve();
expect(logDebugMock).toHaveBeenCalledWith(
expect.stringContaining("failed clearing stale device pairing"),
);
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});

View File

@@ -11,6 +11,7 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { clearDevicePairing } from "../infra/device-pairing.js";
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js";
@@ -175,21 +176,23 @@ export class GatewayClient {
this.ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
this.ws = null;
// If closed due to device token mismatch, clear the stored token so next attempt can get a fresh one
// If closed due to device token mismatch, clear the stored token and pairing so next attempt can get a fresh one
if (
code === 1008 &&
reasonText.toLowerCase().includes("device token mismatch") &&
this.opts.deviceIdentity
) {
const deviceId = this.opts.deviceIdentity.deviceId;
const role = this.opts.role ?? "operator";
try {
clearDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role });
logDebug(
`cleared stale device-auth token for device ${this.opts.deviceIdentity.deviceId}`,
);
clearDeviceAuthToken({ deviceId, role });
void clearDevicePairing(deviceId).catch((err) => {
logDebug(`failed clearing stale device pairing for device ${deviceId}: ${String(err)}`);
});
logDebug(`cleared stale device-auth token for device ${deviceId}`);
} catch (err) {
logDebug(
`failed clearing stale device-auth token for device ${this.opts.deviceIdentity.deviceId}: ${String(err)}`,
`failed clearing stale device-auth token for device ${deviceId}: ${String(err)}`,
);
}
}