Skip to content

Commit ec990fe

Browse files
committed
fix: stop scope mismatch reconnect loops
1 parent 16f7a5a commit ec990fe

5 files changed

Lines changed: 68 additions & 0 deletions

File tree

src/gateway/client.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@ describe("GatewayClient connect auth payload", () => {
711711
beforeEach(() => {
712712
vi.useRealTimers();
713713
wsInstances.length = 0;
714+
clearDeviceAuthTokenMock.mockReset();
714715
loadDeviceAuthTokenMock.mockReset();
715716
storeDeviceAuthTokenMock.mockReset();
716717
logDebugMock.mockClear();
@@ -1207,6 +1208,33 @@ describe("GatewayClient connect auth payload", () => {
12071208
});
12081209
});
12091210

1211+
it("does not clear stored device tokens or reconnect on AUTH_SCOPE_MISMATCH", async () => {
1212+
loadDeviceAuthTokenMock.mockReturnValue({
1213+
token: "stored-device-token",
1214+
scopes: ["operator.read"],
1215+
});
1216+
const onReconnectPaused = vi.fn();
1217+
const client = new GatewayClient({
1218+
url: "ws://127.0.0.1:18789",
1219+
onReconnectPaused,
1220+
});
1221+
1222+
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
1223+
expect(firstConnect.params?.auth?.token).toBe("stored-device-token");
1224+
await expectNoReconnectAfterConnectFailure({
1225+
client,
1226+
firstWs: ws1,
1227+
connectId: firstConnect.id,
1228+
failureDetails: { code: "AUTH_SCOPE_MISMATCH" },
1229+
});
1230+
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
1231+
expect(onReconnectPaused).toHaveBeenCalledWith({
1232+
code: 1008,
1233+
reason: "connect failed",
1234+
detailCode: "AUTH_SCOPE_MISMATCH",
1235+
});
1236+
});
1237+
12101238
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
12111239
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
12121240
const client = new GatewayClient({

src/gateway/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,7 @@ export class GatewayClient {
693693
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
694694
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
695695
detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
696+
detailCode === ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH ||
696697
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
697698
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
698699
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED

src/gateway/reconnect-gating.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ describe("isNonRecoverableAuthError", () => {
5151
).toBe(true);
5252
});
5353

54+
it("blocks reconnect for AUTH_SCOPE_MISMATCH", () => {
55+
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH))).toBe(
56+
true,
57+
);
58+
});
59+
5460
it("blocks reconnect for PAIRING_REQUIRED", () => {
5561
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
5662
true,

ui/src/ui/gateway.node.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,38 @@ describe("GatewayBrowserClient", () => {
839839

840840
vi.useRealTimers();
841841
});
842+
843+
it("does not clear stored device tokens or reconnect on AUTH_SCOPE_MISMATCH", async () => {
844+
useNodeFakeTimers();
845+
846+
const client = new GatewayBrowserClient({
847+
url: "ws://127.0.0.1:18789",
848+
});
849+
850+
const { ws, connectFrame } = await startConnect(client);
851+
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
852+
853+
ws.emitMessage({
854+
type: "res",
855+
id: connectFrame.id,
856+
ok: false,
857+
error: {
858+
code: "INVALID_REQUEST",
859+
message: "unauthorized",
860+
details: { code: "AUTH_SCOPE_MISMATCH" },
861+
},
862+
});
863+
await expectSocketClosed(ws);
864+
ws.emitClose(4008, "connect failed");
865+
866+
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
867+
"stored-device-token",
868+
);
869+
await vi.advanceTimersByTimeAsync(30_000);
870+
expect(wsInstances).toHaveLength(1);
871+
872+
vi.useRealTimers();
873+
});
842874
});
843875

844876
describe("shouldRetryWithDeviceToken", () => {

ui/src/ui/gateway.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
9191
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
9292
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
9393
code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
94+
code === ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH ||
9495
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
9596
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
9697
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED

0 commit comments

Comments
 (0)