diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 68d459bbc0d..37ee00dd446 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -890,6 +890,36 @@ describe("GatewayClient connect auth payload", () => { }); }); + it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { + loadDeviceAuthTokenMock.mockReturnValue({ + token: "stored-device-token", + scopes: ["operator.read"], + }); + const onReconnectPaused = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + onReconnectPaused, + }); + + const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client }); + expect(firstConnect.params?.auth?.token).toBe("stored-device-token"); + await expectNoReconnectAfterConnectFailure({ + client, + firstWs: ws1, + connectId: firstConnect.id, + failureDetails: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, + }); + expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ + deviceId: expect.any(String), + role: "operator", + }); + expect(onReconnectPaused).toHaveBeenCalledWith({ + code: 1008, + reason: "connect failed", + detailCode: "AUTH_DEVICE_TOKEN_MISMATCH", + }); + }); + it("does not auto-reconnect on token mismatch when retry is not trusted", async () => { loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); const client = new GatewayClient({ diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 97ebc203968..d0f3c5149e9 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -594,6 +594,23 @@ export class GatewayClient { resolvedDeviceToken, storedToken: storedToken ?? undefined, }); + if ( + this.opts.deviceIdentity && + usingStoredDeviceToken && + err instanceof GatewayClientRequestError && + readConnectErrorDetailCode(err.details) === + ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH + ) { + const deviceId = this.opts.deviceIdentity.deviceId; + try { + clearDeviceAuthToken({ deviceId, role }); + logDebug(`cleared stale device-auth token for device ${deviceId}`); + } catch (clearErr) { + logDebug( + `failed clearing stale device-auth token for device ${deviceId}: ${String(clearErr)}`, + ); + } + } if (shouldRetryWithDeviceToken) { this.pendingDeviceTokenRetry = true; this.deviceTokenRetryBudgetUsed = true; @@ -653,6 +670,7 @@ export class GatewayClient { detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || + detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH || detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED || detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED diff --git a/src/gateway/reconnect-gating.test.ts b/src/gateway/reconnect-gating.test.ts index aeb60f2e51c..b33afd0ad98 100644 --- a/src/gateway/reconnect-gating.test.ts +++ b/src/gateway/reconnect-gating.test.ts @@ -45,6 +45,12 @@ describe("isNonRecoverableAuthError", () => { ); }); + it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => { + expect( + isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)), + ).toBe(true); + }); + it("blocks reconnect for PAIRING_REQUIRED", () => { expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe( true, diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index ccdd9085a7e..f749967f09e 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -515,6 +515,36 @@ describe("GatewayBrowserClient", () => { vi.useRealTimers(); }); + + it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => { + vi.useFakeTimers(); + + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + }); + + const { ws, connectFrame } = await startConnect(client); + expect(connectFrame.params?.auth?.token).toBe("stored-device-token"); + + ws.emitMessage({ + type: "res", + id: connectFrame.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized", + details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, + }, + }); + await expectSocketClosed(ws); + ws.emitClose(4008, "connect failed"); + + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull(); + await vi.advanceTimersByTimeAsync(30_000); + expect(wsInstances).toHaveLength(1); + + vi.useRealTimers(); + }); }); describe("shouldRetryWithDeviceToken", () => { diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index a092f75eca8..da670a1ab50 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -86,6 +86,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING || code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH || code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || + code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH || code === ConnectErrorDetailCodes.PAIRING_REQUIRED || code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED @@ -519,8 +520,12 @@ export class GatewayBrowserClient { } else { this.pendingConnectError = undefined; } + const usedStoredDeviceToken = + Boolean(plan.selectedAuth.storedToken) && + (plan.selectedAuth.resolvedDeviceToken === plan.selectedAuth.storedToken || + plan.selectedAuth.authDeviceToken === plan.selectedAuth.storedToken); if ( - plan.selectedAuth.canFallbackToShared && + usedStoredDeviceToken && plan.deviceIdentity && connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ) {