Skip to content

Commit e024eac

Browse files
fix(ui): preserve login across same-origin gateways (#101352)
* fix(ui): scope device auth by gateway * fix(ui): migrate legacy device auth storage * test(ui): cover gateway device-token isolation
1 parent 9cf5079 commit e024eac

8 files changed

Lines changed: 579 additions & 50 deletions

File tree

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

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,21 @@ import {
66
PROTOCOL_VERSION,
77
} from "../../../packages/gateway-protocol/src/version.js";
88
import type { DeviceIdentity } from "../lib/nodes/index.ts";
9-
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../lib/nodes/index.ts";
9+
import {
10+
loadDeviceAuthToken as loadScopedDeviceAuthToken,
11+
storeDeviceAuthToken as storeScopedDeviceAuthToken,
12+
} from "../lib/nodes/index.ts";
1013
import { createStorageMock } from "../test-helpers/storage.ts";
1114

1215
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
16+
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
17+
const LEGACY_DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
18+
const DEFAULT_DEVICE_AUTH_STORAGE_KEY = `${LEGACY_DEVICE_AUTH_STORAGE_KEY}:${DEFAULT_GATEWAY_URL}`;
19+
const STORED_CRED = "stored-device-token";
20+
const ROSITA_CRED = "rosita-device-token";
21+
const WILFRED_CRED = "wilfred-device-token";
22+
const TENANT_A_CRED = "tenant-a-device-token";
23+
const TENANT_B_CRED = "tenant-b-device-token";
1324
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
1425
vi.fn(
1526
async (): Promise<DeviceIdentity> => ({
@@ -23,6 +34,19 @@ const signDevicePayloadMock = vi.hoisted(() =>
2334
vi.fn(async (_privateKeyBase64Url: string, _payload: string) => "signature"),
2435
);
2536

37+
function loadDeviceAuthToken(params: { deviceId: string; role: string }) {
38+
return loadScopedDeviceAuthToken({ ...params, gatewayUrl: DEFAULT_GATEWAY_URL });
39+
}
40+
41+
function storeDeviceAuthToken(params: {
42+
deviceId: string;
43+
role: string;
44+
token: string;
45+
scopes?: string[];
46+
}) {
47+
return storeScopedDeviceAuthToken({ ...params, gatewayUrl: DEFAULT_GATEWAY_URL });
48+
}
49+
2650
type HandlerMap = {
2751
close: MockWebSocketHandler[];
2852
error: MockWebSocketHandler[];
@@ -313,6 +337,13 @@ async function expectRetriedDeviceTokenConnect(params: {
313337
token: string;
314338
retryNonce?: string;
315339
}) {
340+
storeScopedDeviceAuthToken({
341+
deviceId: "device-1",
342+
gatewayUrl: params.url,
343+
role: "operator",
344+
token: STORED_CRED,
345+
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
346+
});
316347
const client = new GatewayBrowserClient({
317348
url: params.url,
318349
token: params.token,
@@ -333,7 +364,7 @@ async function expectRetriedDeviceTokenConnect(params: {
333364
params.retryNonce ?? "nonce-2",
334365
);
335366
expect(secondConnect.params?.auth?.token).toBe(params.token);
336-
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
367+
expect(secondConnect.params?.auth?.deviceToken).toBe(STORED_CRED);
337368

338369
return { client, firstWs, secondWs, firstConnect, secondConnect };
339370
}
@@ -930,6 +961,99 @@ describe("GatewayBrowserClient", () => {
930961
});
931962
});
932963

964+
it("uses a scoped device token when legacy cleanup fails", async () => {
965+
vi.spyOn(localStorage, "removeItem").mockImplementation(() => {
966+
throw new Error("storage cleanup blocked");
967+
});
968+
const client = new GatewayBrowserClient({
969+
url: DEFAULT_GATEWAY_URL,
970+
});
971+
972+
const { connectFrame } = await startConnect(client);
973+
974+
expect(connectFrame.params?.auth?.token).toBe(STORED_CRED);
975+
});
976+
977+
it("migrates the legacy device token store to the first gateway opened after upgrade", async () => {
978+
const legacyStore = localStorage.getItem(DEFAULT_DEVICE_AUTH_STORAGE_KEY);
979+
expect(legacyStore).not.toBeNull();
980+
localStorage.clear();
981+
localStorage.setItem(LEGACY_DEVICE_AUTH_STORAGE_KEY, legacyStore ?? "");
982+
983+
const client = new GatewayBrowserClient({
984+
url: DEFAULT_GATEWAY_URL,
985+
});
986+
const { connectFrame } = await startConnect(client);
987+
988+
expect(connectFrame.params?.auth?.token).toBe(STORED_CRED);
989+
expect(localStorage.getItem(LEGACY_DEVICE_AUTH_STORAGE_KEY)).toBeNull();
990+
expect(localStorage.getItem(DEFAULT_DEVICE_AUTH_STORAGE_KEY)).toBe(legacyStore);
991+
});
992+
993+
it("keeps cached device tokens separate for gateways on the same origin", async () => {
994+
localStorage.clear();
995+
storeScopedDeviceAuthToken({
996+
deviceId: "device-1",
997+
gatewayUrl: "wss://gateway.example/rosita/",
998+
role: "operator",
999+
token: ROSITA_CRED,
1000+
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
1001+
});
1002+
storeScopedDeviceAuthToken({
1003+
deviceId: "device-1",
1004+
gatewayUrl: "wss://gateway.example/wilfred",
1005+
role: "operator",
1006+
token: WILFRED_CRED,
1007+
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
1008+
});
1009+
1010+
const rositaClient = new GatewayBrowserClient({
1011+
url: "wss://gateway.example/rosita",
1012+
});
1013+
const { connectFrame: rositaConnect } = await startConnect(rositaClient);
1014+
expect(rositaConnect.params?.auth?.token).toBe(ROSITA_CRED);
1015+
rositaClient.stop();
1016+
1017+
const wilfredClient = new GatewayBrowserClient({
1018+
url: "wss://gateway.example/wilfred",
1019+
});
1020+
const { connectFrame: wilfredConnect } = await startConnect(wilfredClient, "nonce-2");
1021+
expect(wilfredConnect.params?.auth?.token).toBe(WILFRED_CRED);
1022+
wilfredClient.stop();
1023+
});
1024+
1025+
it("keeps cached device tokens separate for gateway query routes", async () => {
1026+
localStorage.clear();
1027+
storeScopedDeviceAuthToken({
1028+
deviceId: "device-1",
1029+
gatewayUrl: "wss://gateway.example/control?tenant=a",
1030+
role: "operator",
1031+
token: TENANT_A_CRED,
1032+
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
1033+
});
1034+
storeScopedDeviceAuthToken({
1035+
deviceId: "device-1",
1036+
gatewayUrl: "wss://gateway.example/control?tenant=b",
1037+
role: "operator",
1038+
token: TENANT_B_CRED,
1039+
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
1040+
});
1041+
1042+
const tenantAClient = new GatewayBrowserClient({
1043+
url: "wss://gateway.example/control?tenant=a",
1044+
});
1045+
const { connectFrame: tenantAConnect } = await startConnect(tenantAClient);
1046+
expect(tenantAConnect.params?.auth?.token).toBe(TENANT_A_CRED);
1047+
tenantAClient.stop();
1048+
1049+
const tenantBClient = new GatewayBrowserClient({
1050+
url: "wss://gateway.example/control?tenant=b",
1051+
});
1052+
const { connectFrame: tenantBConnect } = await startConnect(tenantBClient, "nonce-2");
1053+
expect(tenantBConnect.params?.auth?.token).toBe(TENANT_B_CRED);
1054+
tenantBClient.stop();
1055+
});
1056+
9331057
it("ignores cached operator device tokens that do not include read access", async () => {
9341058
localStorage.clear();
9351059
storeDeviceAuthToken({
@@ -974,9 +1098,12 @@ describe("GatewayBrowserClient", () => {
9741098
});
9751099
await expectSocketClosed(secondWs);
9761100
secondWs.emitClose(4008, "connect failed");
977-
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
978-
"stored-device-token",
979-
);
1101+
expect(
1102+
loadDeviceAuthToken({
1103+
deviceId: "device-1",
1104+
role: "operator",
1105+
})?.token,
1106+
).toBe("stored-device-token");
9801107
await vi.advanceTimersByTimeAsync(30_000);
9811108
expect(wsInstances).toHaveLength(2);
9821109

@@ -1362,7 +1489,12 @@ describe("GatewayBrowserClient", () => {
13621489
await expectSocketClosed(ws);
13631490
ws.emitClose(4008, "connect failed");
13641491

1365-
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull();
1492+
expect(
1493+
loadDeviceAuthToken({
1494+
deviceId: "device-1",
1495+
role: "operator",
1496+
}),
1497+
).toBeNull();
13661498
await vi.advanceTimersByTimeAsync(30_000);
13671499
expect(wsInstances).toHaveLength(1);
13681500

@@ -1392,9 +1524,12 @@ describe("GatewayBrowserClient", () => {
13921524
await expectSocketClosed(ws);
13931525
ws.emitClose(4008, "connect failed");
13941526

1395-
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
1396-
"stored-device-token",
1397-
);
1527+
expect(
1528+
loadDeviceAuthToken({
1529+
deviceId: "device-1",
1530+
role: "operator",
1531+
})?.token,
1532+
).toBe("stored-device-token");
13981533
await vi.advanceTimersByTimeAsync(30_000);
13991534
expect(wsInstances).toHaveLength(1);
14001535

ui/src/api/gateway.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ export class GatewayBrowserClient {
817817
this.deviceTokenRetryBudgetUsed = false;
818818
this.pendingStartupReconnectDelayMs = null;
819819
if (hello?.auth?.deviceToken && plan.deviceIdentity) {
820-
storeDeviceAuthToken({
820+
this.storeDeviceAuthToken({
821821
deviceId: plan.deviceIdentity.deviceId,
822822
role: hello.auth.role ?? plan.role,
823823
token: hello.auth.deviceToken,
@@ -882,7 +882,11 @@ export class GatewayBrowserClient {
882882
plan.deviceIdentity &&
883883
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
884884
) {
885-
clearDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId, role: plan.role });
885+
clearDeviceAuthToken({
886+
deviceId: plan.deviceIdentity.deviceId,
887+
gatewayUrl: this.opts.url,
888+
role: plan.role,
889+
});
886890
}
887891
const startupRetryAfterMs = resolveGatewayStartupRetryAfterMs(err);
888892
if (startupRetryAfterMs !== null) {
@@ -899,6 +903,18 @@ export class GatewayBrowserClient {
899903
return !this.closed && this.ws === ws && this.connectGeneration === generation;
900904
}
901905

906+
private storeDeviceAuthToken(params: {
907+
deviceId: string;
908+
role: string;
909+
token: string;
910+
scopes?: string[];
911+
}): void {
912+
storeDeviceAuthToken({
913+
...params,
914+
gatewayUrl: this.opts.url,
915+
});
916+
}
917+
902918
private async sendConnect(ws: WebSocket, generation: number) {
903919
if (!this.isActiveSocket(ws, generation) || ws.readyState !== WebSocket.OPEN) {
904920
return;
@@ -1029,6 +1045,7 @@ export class GatewayBrowserClient {
10291045
const authPassword = this.opts.password?.trim() || undefined;
10301046
const storedEntry = loadDeviceAuthToken({
10311047
deviceId: params.deviceId,
1048+
gatewayUrl: this.opts.url,
10321049
role: params.role,
10331050
});
10341051
const storedScopes = storedEntry?.scopes ?? [];

ui/src/app/gateway-scope.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Control UI module normalizes gateway URLs used to scope browser auth state.
2+
import { normalizeOptionalString } from "../lib/string-coerce.ts";
3+
4+
function normalizeGatewayScope(gatewayUrl: string, includeSearch: boolean): string {
5+
const trimmed = normalizeOptionalString(gatewayUrl) ?? "";
6+
if (!trimmed) {
7+
return "default";
8+
}
9+
try {
10+
const base =
11+
typeof location !== "undefined"
12+
? `${location.protocol}//${location.host}${location.pathname || "/"}`
13+
: undefined;
14+
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
15+
const pathname =
16+
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
17+
return `${parsed.protocol}//${parsed.host}${pathname}${includeSearch ? parsed.search : ""}`;
18+
} catch {
19+
return trimmed;
20+
}
21+
}
22+
23+
export function normalizeGatewayTokenScope(gatewayUrl: string): string {
24+
return normalizeGatewayScope(gatewayUrl, false);
25+
}
26+
27+
export function normalizeGatewayCredentialScope(gatewayUrl: string): string {
28+
return normalizeGatewayScope(gatewayUrl, true);
29+
}

ui/src/app/settings.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { normalizeOptionalString } from "../lib/string-coerce.ts";
4343
import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts";
4444
import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/split-layout.ts";
4545
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
46+
import { normalizeGatewayTokenScope } from "./gateway-scope.ts";
4647
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
4748
import {
4849
hasLocalUserIdentity,
@@ -351,25 +352,6 @@ function getSessionStorage(): Storage | null {
351352
return getSafeSessionStorage();
352353
}
353354

354-
function normalizeGatewayTokenScope(gatewayUrl: string): string {
355-
const trimmed = normalizeOptionalString(gatewayUrl) ?? "";
356-
if (!trimmed) {
357-
return "default";
358-
}
359-
try {
360-
const base =
361-
typeof location !== "undefined"
362-
? `${location.protocol}//${location.host}${location.pathname || "/"}`
363-
: undefined;
364-
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
365-
const pathname =
366-
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
367-
return `${parsed.protocol}//${parsed.host}${pathname}`;
368-
} catch {
369-
return trimmed;
370-
}
371-
}
372-
373355
type PersistedSettingsSource = {
374356
gatewayUrl: string;
375357
legacy: boolean;

0 commit comments

Comments
 (0)