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
This commit is contained in:
Jason (Json)
2026-07-07 00:51:19 -06:00
committed by GitHub
parent 9cf5079e56
commit e024eacf17
8 changed files with 579 additions and 50 deletions
+144 -9
View File
@@ -6,10 +6,21 @@ import {
PROTOCOL_VERSION,
} from "../../../packages/gateway-protocol/src/version.js";
import type { DeviceIdentity } from "../lib/nodes/index.ts";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../lib/nodes/index.ts";
import {
loadDeviceAuthToken as loadScopedDeviceAuthToken,
storeDeviceAuthToken as storeScopedDeviceAuthToken,
} from "../lib/nodes/index.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
const LEGACY_DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
const DEFAULT_DEVICE_AUTH_STORAGE_KEY = `${LEGACY_DEVICE_AUTH_STORAGE_KEY}:${DEFAULT_GATEWAY_URL}`;
const STORED_CRED = "stored-device-token";
const ROSITA_CRED = "rosita-device-token";
const WILFRED_CRED = "wilfred-device-token";
const TENANT_A_CRED = "tenant-a-device-token";
const TENANT_B_CRED = "tenant-b-device-token";
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
vi.fn(
async (): Promise<DeviceIdentity> => ({
@@ -23,6 +34,19 @@ const signDevicePayloadMock = vi.hoisted(() =>
vi.fn(async (_privateKeyBase64Url: string, _payload: string) => "signature"),
);
function loadDeviceAuthToken(params: { deviceId: string; role: string }) {
return loadScopedDeviceAuthToken({ ...params, gatewayUrl: DEFAULT_GATEWAY_URL });
}
function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
token: string;
scopes?: string[];
}) {
return storeScopedDeviceAuthToken({ ...params, gatewayUrl: DEFAULT_GATEWAY_URL });
}
type HandlerMap = {
close: MockWebSocketHandler[];
error: MockWebSocketHandler[];
@@ -313,6 +337,13 @@ async function expectRetriedDeviceTokenConnect(params: {
token: string;
retryNonce?: string;
}) {
storeScopedDeviceAuthToken({
deviceId: "device-1",
gatewayUrl: params.url,
role: "operator",
token: STORED_CRED,
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
});
const client = new GatewayBrowserClient({
url: params.url,
token: params.token,
@@ -333,7 +364,7 @@ async function expectRetriedDeviceTokenConnect(params: {
params.retryNonce ?? "nonce-2",
);
expect(secondConnect.params?.auth?.token).toBe(params.token);
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
expect(secondConnect.params?.auth?.deviceToken).toBe(STORED_CRED);
return { client, firstWs, secondWs, firstConnect, secondConnect };
}
@@ -930,6 +961,99 @@ describe("GatewayBrowserClient", () => {
});
});
it("uses a scoped device token when legacy cleanup fails", async () => {
vi.spyOn(localStorage, "removeItem").mockImplementation(() => {
throw new Error("storage cleanup blocked");
});
const client = new GatewayBrowserClient({
url: DEFAULT_GATEWAY_URL,
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.params?.auth?.token).toBe(STORED_CRED);
});
it("migrates the legacy device token store to the first gateway opened after upgrade", async () => {
const legacyStore = localStorage.getItem(DEFAULT_DEVICE_AUTH_STORAGE_KEY);
expect(legacyStore).not.toBeNull();
localStorage.clear();
localStorage.setItem(LEGACY_DEVICE_AUTH_STORAGE_KEY, legacyStore ?? "");
const client = new GatewayBrowserClient({
url: DEFAULT_GATEWAY_URL,
});
const { connectFrame } = await startConnect(client);
expect(connectFrame.params?.auth?.token).toBe(STORED_CRED);
expect(localStorage.getItem(LEGACY_DEVICE_AUTH_STORAGE_KEY)).toBeNull();
expect(localStorage.getItem(DEFAULT_DEVICE_AUTH_STORAGE_KEY)).toBe(legacyStore);
});
it("keeps cached device tokens separate for gateways on the same origin", async () => {
localStorage.clear();
storeScopedDeviceAuthToken({
deviceId: "device-1",
gatewayUrl: "wss://gateway.example/rosita/",
role: "operator",
token: ROSITA_CRED,
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
});
storeScopedDeviceAuthToken({
deviceId: "device-1",
gatewayUrl: "wss://gateway.example/wilfred",
role: "operator",
token: WILFRED_CRED,
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
});
const rositaClient = new GatewayBrowserClient({
url: "wss://gateway.example/rosita",
});
const { connectFrame: rositaConnect } = await startConnect(rositaClient);
expect(rositaConnect.params?.auth?.token).toBe(ROSITA_CRED);
rositaClient.stop();
const wilfredClient = new GatewayBrowserClient({
url: "wss://gateway.example/wilfred",
});
const { connectFrame: wilfredConnect } = await startConnect(wilfredClient, "nonce-2");
expect(wilfredConnect.params?.auth?.token).toBe(WILFRED_CRED);
wilfredClient.stop();
});
it("keeps cached device tokens separate for gateway query routes", async () => {
localStorage.clear();
storeScopedDeviceAuthToken({
deviceId: "device-1",
gatewayUrl: "wss://gateway.example/control?tenant=a",
role: "operator",
token: TENANT_A_CRED,
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
});
storeScopedDeviceAuthToken({
deviceId: "device-1",
gatewayUrl: "wss://gateway.example/control?tenant=b",
role: "operator",
token: TENANT_B_CRED,
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
});
const tenantAClient = new GatewayBrowserClient({
url: "wss://gateway.example/control?tenant=a",
});
const { connectFrame: tenantAConnect } = await startConnect(tenantAClient);
expect(tenantAConnect.params?.auth?.token).toBe(TENANT_A_CRED);
tenantAClient.stop();
const tenantBClient = new GatewayBrowserClient({
url: "wss://gateway.example/control?tenant=b",
});
const { connectFrame: tenantBConnect } = await startConnect(tenantBClient, "nonce-2");
expect(tenantBConnect.params?.auth?.token).toBe(TENANT_B_CRED);
tenantBClient.stop();
});
it("ignores cached operator device tokens that do not include read access", async () => {
localStorage.clear();
storeDeviceAuthToken({
@@ -974,9 +1098,12 @@ describe("GatewayBrowserClient", () => {
});
await expectSocketClosed(secondWs);
secondWs.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
"stored-device-token",
);
expect(
loadDeviceAuthToken({
deviceId: "device-1",
role: "operator",
})?.token,
).toBe("stored-device-token");
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(2);
@@ -1362,7 +1489,12 @@ describe("GatewayBrowserClient", () => {
await expectSocketClosed(ws);
ws.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull();
expect(
loadDeviceAuthToken({
deviceId: "device-1",
role: "operator",
}),
).toBeNull();
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
@@ -1392,9 +1524,12 @@ describe("GatewayBrowserClient", () => {
await expectSocketClosed(ws);
ws.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
"stored-device-token",
);
expect(
loadDeviceAuthToken({
deviceId: "device-1",
role: "operator",
})?.token,
).toBe("stored-device-token");
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
+19 -2
View File
@@ -817,7 +817,7 @@ export class GatewayBrowserClient {
this.deviceTokenRetryBudgetUsed = false;
this.pendingStartupReconnectDelayMs = null;
if (hello?.auth?.deviceToken && plan.deviceIdentity) {
storeDeviceAuthToken({
this.storeDeviceAuthToken({
deviceId: plan.deviceIdentity.deviceId,
role: hello.auth.role ?? plan.role,
token: hello.auth.deviceToken,
@@ -882,7 +882,11 @@ export class GatewayBrowserClient {
plan.deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
clearDeviceAuthToken({ deviceId: plan.deviceIdentity.deviceId, role: plan.role });
clearDeviceAuthToken({
deviceId: plan.deviceIdentity.deviceId,
gatewayUrl: this.opts.url,
role: plan.role,
});
}
const startupRetryAfterMs = resolveGatewayStartupRetryAfterMs(err);
if (startupRetryAfterMs !== null) {
@@ -899,6 +903,18 @@ export class GatewayBrowserClient {
return !this.closed && this.ws === ws && this.connectGeneration === generation;
}
private storeDeviceAuthToken(params: {
deviceId: string;
role: string;
token: string;
scopes?: string[];
}): void {
storeDeviceAuthToken({
...params,
gatewayUrl: this.opts.url,
});
}
private async sendConnect(ws: WebSocket, generation: number) {
if (!this.isActiveSocket(ws, generation) || ws.readyState !== WebSocket.OPEN) {
return;
@@ -1029,6 +1045,7 @@ export class GatewayBrowserClient {
const authPassword = this.opts.password?.trim() || undefined;
const storedEntry = loadDeviceAuthToken({
deviceId: params.deviceId,
gatewayUrl: this.opts.url,
role: params.role,
});
const storedScopes = storedEntry?.scopes ?? [];
+29
View File
@@ -0,0 +1,29 @@
// Control UI module normalizes gateway URLs used to scope browser auth state.
import { normalizeOptionalString } from "../lib/string-coerce.ts";
function normalizeGatewayScope(gatewayUrl: string, includeSearch: boolean): string {
const trimmed = normalizeOptionalString(gatewayUrl) ?? "";
if (!trimmed) {
return "default";
}
try {
const base =
typeof location !== "undefined"
? `${location.protocol}//${location.host}${location.pathname || "/"}`
: undefined;
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
const pathname =
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
return `${parsed.protocol}//${parsed.host}${pathname}${includeSearch ? parsed.search : ""}`;
} catch {
return trimmed;
}
}
export function normalizeGatewayTokenScope(gatewayUrl: string): string {
return normalizeGatewayScope(gatewayUrl, false);
}
export function normalizeGatewayCredentialScope(gatewayUrl: string): string {
return normalizeGatewayScope(gatewayUrl, true);
}
+1 -19
View File
@@ -43,6 +43,7 @@ import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts";
import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/split-layout.ts";
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
import { normalizeGatewayTokenScope } from "./gateway-scope.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
import {
hasLocalUserIdentity,
@@ -351,25 +352,6 @@ function getSessionStorage(): Storage | null {
return getSafeSessionStorage();
}
function normalizeGatewayTokenScope(gatewayUrl: string): string {
const trimmed = normalizeOptionalString(gatewayUrl) ?? "";
if (!trimmed) {
return "default";
}
try {
const base =
typeof location !== "undefined"
? `${location.protocol}//${location.host}${location.pathname || "/"}`
: undefined;
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
const pathname =
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
return `${parsed.protocol}//${parsed.host}${pathname}`;
} catch {
return trimmed;
}
}
type PersistedSettingsSource = {
gatewayUrl: string;
legacy: boolean;
@@ -0,0 +1,286 @@
// Control UI tests cover browser-native device-token isolation and reuse.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
normalizeGatewayCredentialScope,
normalizeGatewayTokenScope,
} from "../app/gateway-scope.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
let browser: Browser;
let server: ControlUiE2eServer;
const openContexts = new Set<BrowserContext>();
const OPERATOR_SCOPES = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
];
const ROSITA_GATEWAY_URL = "wss://gateway.example/rosita";
const WILFRED_GATEWAY_URL = "wss://gateway.example/wilfred";
const ROSITA_DEVICE_TOKEN = "rosita-device-token";
const WILFRED_DEVICE_TOKEN = "wilfred-device-token";
function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected object value");
}
return value as Record<string, unknown>;
}
function readConnectAuth(request: { params?: unknown }): Record<string, unknown> | undefined {
const auth = requireRecord(request.params).auth;
return auth == null ? undefined : requireRecord(auth);
}
function requireConnectAuth(request: { params?: unknown }): Record<string, unknown> {
return requireRecord(readConnectAuth(request));
}
function browserPageGatewayUrl(appBaseUrl: string): string {
const parsed = new URL(appBaseUrl);
const protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${parsed.host}`;
}
async function selectGatewayOnNextLoad(
page: Page,
appBaseUrl: string,
gatewayUrl: string,
): Promise<void> {
const settingsKey = `openclaw.control.settings.v1:${normalizeGatewayTokenScope(gatewayUrl)}`;
const selectionKey =
`openclaw.control.currentGateway.v1:` +
normalizeGatewayTokenScope(browserPageGatewayUrl(appBaseUrl));
await page.addInitScript(
({ nextGatewayUrl, nextSelectionKey, nextSettingsKey }) => {
localStorage.setItem(nextSettingsKey, JSON.stringify({ gatewayUrl: nextGatewayUrl }));
localStorage.setItem(nextSelectionKey, nextGatewayUrl);
},
{
nextGatewayUrl: gatewayUrl,
nextSelectionKey: selectionKey,
nextSettingsKey: settingsKey,
},
);
}
async function openGatewayPage(params: {
appBaseUrl: string;
context: BrowserContext;
deviceToken: string;
gatewayUrl: string;
methodResponses?: Record<string, unknown>;
route?: string;
sharedToken?: string;
}) {
const page = await params.context.newPage();
await selectGatewayOnNextLoad(page, params.appBaseUrl, params.gatewayUrl);
const gateway = await installMockGateway(page, {
deviceToken: params.deviceToken,
methodResponses: params.methodResponses,
});
const tokenFragment = params.sharedToken
? `#token=${encodeURIComponent(params.sharedToken)}`
: "";
const response = await page.goto(`${params.appBaseUrl}${params.route ?? "chat"}${tokenFragment}`);
expect(response?.status()).toBe(200);
const connect = await gateway.waitForRequest("connect");
await page.locator("openclaw-app-shell").waitFor();
return { connect, gateway, page };
}
async function createContext(): Promise<BrowserContext> {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
openContexts.add(context);
return context;
}
async function captureProof(page: Page, name: string): Promise<void> {
if (!proofDir) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(proofDir, name) });
}
describeControlUiE2e("Control UI device-token reconnect E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`,
);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
await browser?.close();
await server?.close();
});
afterEach(async () => {
await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
openContexts.clear();
});
it("isolates device tokens across gateways, origins, and revocation", async () => {
const context = await createContext();
const rositaSource = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: ROSITA_DEVICE_TOKEN,
gatewayUrl: ROSITA_GATEWAY_URL,
sharedToken: "shared-rosita",
});
expect(requireConnectAuth(rositaSource.connect).token).toBe("shared-rosita");
const wilfredSource = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: WILFRED_DEVICE_TOKEN,
gatewayUrl: WILFRED_GATEWAY_URL,
sharedToken: "shared-wilfred",
});
expect(requireConnectAuth(wilfredSource.connect).token).toBe("shared-wilfred");
const rositaReconnect = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: ROSITA_DEVICE_TOKEN,
gatewayUrl: ROSITA_GATEWAY_URL,
route: "overview",
});
expect(requireConnectAuth(rositaReconnect.connect)).toMatchObject({
deviceToken: ROSITA_DEVICE_TOKEN,
token: ROSITA_DEVICE_TOKEN,
});
expect(await rositaReconnect.page.locator("openclaw-login-gate").count()).toBe(0);
await captureProof(rositaReconnect.page, "rosita-reconnected.png");
const wilfredReconnect = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: WILFRED_DEVICE_TOKEN,
gatewayUrl: WILFRED_GATEWAY_URL,
});
expect(requireConnectAuth(wilfredReconnect.connect)).toMatchObject({
deviceToken: WILFRED_DEVICE_TOKEN,
token: WILFRED_DEVICE_TOKEN,
});
expect(await wilfredReconnect.page.locator("openclaw-login-gate").count()).toBe(0);
const identity = await wilfredSource.page.evaluate(() => {
const raw = localStorage.getItem("openclaw-device-identity-v1");
return raw ? JSON.parse(raw) : null;
});
const deviceId = requireRecord(identity).deviceId;
if (typeof deviceId !== "string") {
throw new Error("Expected the browser device identity to contain a deviceId");
}
const otherOriginBaseUrl = server.baseUrl.replace("127.0.0.1", "localhost");
const otherOrigin = await openGatewayPage({
appBaseUrl: otherOriginBaseUrl,
context,
deviceToken: "other-origin-device-token",
gatewayUrl: ROSITA_GATEWAY_URL,
});
expect(readConnectAuth(otherOrigin.connect)?.token).toBeUndefined();
expect(readConnectAuth(otherOrigin.connect)?.deviceToken).toBeUndefined();
const wilfredNodes = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: WILFRED_DEVICE_TOKEN,
gatewayUrl: WILFRED_GATEWAY_URL,
methodResponses: {
"device.pair.list": {
paired: [
{
deviceId,
displayName: "This browser",
roles: ["operator"],
scopes: OPERATOR_SCOPES,
tokens: [
{
createdAtMs: Date.now(),
role: "operator",
scopes: OPERATOR_SCOPES,
},
],
},
],
pending: [],
},
"device.token.revoke": {},
"node.list": { nodes: [] },
},
route: "nodes",
});
expect(requireConnectAuth(wilfredNodes.connect).token).toBe(WILFRED_DEVICE_TOKEN);
const revokeButton = wilfredNodes.page.getByRole("button", { name: "Revoke" });
await revokeButton.waitFor();
await revokeButton.scrollIntoViewIfNeeded();
await captureProof(wilfredNodes.page, "wilfred-before-revoke.png");
wilfredNodes.page.once("dialog", (dialog) => void dialog.accept());
await revokeButton.click();
const revoke = await wilfredNodes.gateway.waitForRequest("device.token.revoke");
expect(revoke.params).toEqual({ deviceId, role: "operator" });
const wilfredStoreKey =
`openclaw.device.auth.v1:` + normalizeGatewayCredentialScope(WILFRED_GATEWAY_URL);
await expect
.poll(() =>
wilfredNodes.page.evaluate((key) => {
const raw = localStorage.getItem(key);
if (!raw) {
return undefined;
}
const store = JSON.parse(raw) as { tokens?: Record<string, unknown> };
return store.tokens?.operator;
}, wilfredStoreKey),
)
.toBeUndefined();
const rositaAfterRevoke = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: ROSITA_DEVICE_TOKEN,
gatewayUrl: ROSITA_GATEWAY_URL,
});
expect(requireConnectAuth(rositaAfterRevoke.connect)).toMatchObject({
deviceToken: ROSITA_DEVICE_TOKEN,
token: ROSITA_DEVICE_TOKEN,
});
const wilfredAfterRevoke = await openGatewayPage({
appBaseUrl: server.baseUrl,
context,
deviceToken: WILFRED_DEVICE_TOKEN,
gatewayUrl: WILFRED_GATEWAY_URL,
});
expect(readConnectAuth(wilfredAfterRevoke.connect)?.token).toBeUndefined();
expect(readConnectAuth(wilfredAfterRevoke.connect)?.deviceToken).toBeUndefined();
});
});
+85 -17
View File
@@ -7,6 +7,7 @@ import {
storeDeviceAuthTokenInStore,
} from "../../../../src/shared/device-auth-store.js";
import type { DeviceAuthStore } from "../../../../src/shared/device-auth.js";
import { normalizeGatewayCredentialScope } from "../../app/gateway-scope.ts";
import { getSafeLocalStorage } from "../../local-storage.ts";
import { cloneConfigObject, removePathValue, setPathValue } from "../config-form-utils.ts";
@@ -142,7 +143,8 @@ export type DeviceIdentity = {
privateKey: string;
};
const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
const LEGACY_DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
const DEVICE_AUTH_STORAGE_KEY_PREFIX = `${LEGACY_DEVICE_AUTH_STORAGE_KEY}:`;
const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1";
export function createInitialNodesState(
@@ -253,24 +255,26 @@ export async function rejectDevicePairing(state: DevicesState, requestId: string
export async function rotateDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string; scopes?: string[] },
params: { deviceId: string; gatewayUrl: string; role: string; scopes?: string[] },
) {
if (!state.client || !state.connected) {
return;
}
try {
const { gatewayUrl, ...requestParams } = params;
const res = await state.client.request<{
token?: string;
role?: string;
deviceId?: string;
scopes?: Array<string>;
}>("device.token.rotate", params);
}>("device.token.rotate", requestParams);
if (res?.token) {
const identity = await loadOrCreateDeviceIdentity();
const role = res.role ?? params.role;
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
storeDeviceAuthToken({
deviceId: identity.deviceId,
gatewayUrl,
role,
token: res.token,
scopes: res.scopes ?? params.scopes ?? [],
@@ -286,7 +290,7 @@ export async function rotateDeviceToken(
export async function revokeDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string },
params: { deviceId: string; gatewayUrl: string; role: string },
) {
if (!state.client || !state.connected) {
return;
@@ -296,10 +300,15 @@ export async function revokeDeviceToken(
return;
}
try {
await state.client.request("device.token.revoke", params);
const { gatewayUrl, ...requestParams } = params;
await state.client.request("device.token.revoke", requestParams);
const identity = await loadOrCreateDeviceIdentity();
if (params.deviceId === identity.deviceId) {
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
clearDeviceAuthToken({
deviceId: identity.deviceId,
gatewayUrl,
role: params.role,
});
}
await loadDevices(state);
} catch (err) {
@@ -433,12 +442,23 @@ export function removeExecApprovalsFormValue(
state.execApprovalsDirty = true;
}
function readStore(): DeviceAuthStore | null {
function deviceAuthStorageKey(gatewayUrl: string): string {
return `${DEVICE_AUTH_STORAGE_KEY_PREFIX}${normalizeGatewayCredentialScope(gatewayUrl)}`;
}
function removeLegacyDeviceAuthStore(storage: Storage | null) {
try {
storage?.removeItem(LEGACY_DEVICE_AUTH_STORAGE_KEY);
} catch {
// Legacy cleanup must not make an otherwise usable device token unreadable.
}
}
function parseDeviceAuthStore(raw: string | null): DeviceAuthStore | null {
if (!raw) {
return null;
}
try {
const raw = getSafeLocalStorage()?.getItem(DEVICE_AUTH_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) {
return null;
@@ -455,9 +475,42 @@ function readStore(): DeviceAuthStore | null {
}
}
function writeStore(store: DeviceAuthStore) {
function readStore(gatewayUrl: string): DeviceAuthStore | null {
try {
getSafeLocalStorage()?.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store));
const storage = getSafeLocalStorage();
const scopedKey = deviceAuthStorageKey(gatewayUrl);
const scopedStore = parseDeviceAuthStore(storage?.getItem(scopedKey) ?? null);
if (scopedStore) {
removeLegacyDeviceAuthStore(storage);
return scopedStore;
}
const legacyStore = parseDeviceAuthStore(
storage?.getItem(LEGACY_DEVICE_AUTH_STORAGE_KEY) ?? null,
);
if (!legacyStore) {
return null;
}
// Older releases stored one origin-wide token. Claim it for the first gateway
// opened after upgrade, then remove the ambiguous key before sibling routes use it.
try {
storage?.setItem(scopedKey, JSON.stringify(legacyStore));
removeLegacyDeviceAuthStore(storage);
} catch {
// Keep the usable in-memory result when browser storage rejects the migration.
}
return legacyStore;
} catch {
return null;
}
}
function writeStore(gatewayUrl: string, store: DeviceAuthStore) {
try {
const storage = getSafeLocalStorage();
storage?.setItem(deviceAuthStorageKey(gatewayUrl), JSON.stringify(store));
removeLegacyDeviceAuthStore(storage);
} catch {
// localStorage can be unavailable in private or embedded contexts.
}
@@ -465,10 +518,14 @@ function writeStore(store: DeviceAuthStore) {
export function loadDeviceAuthToken(params: {
deviceId: string;
gatewayUrl: string;
role: string;
}): DeviceAuthEntry | null {
return loadDeviceAuthTokenFromStore({
adapter: { readStore, writeStore },
adapter: {
readStore: () => readStore(params.gatewayUrl),
writeStore: (store) => writeStore(params.gatewayUrl, store),
},
deviceId: params.deviceId,
role: params.role,
});
@@ -476,12 +533,16 @@ export function loadDeviceAuthToken(params: {
export function storeDeviceAuthToken(params: {
deviceId: string;
gatewayUrl: string;
role: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
return storeDeviceAuthTokenInStore({
adapter: { readStore, writeStore },
adapter: {
readStore: () => readStore(params.gatewayUrl),
writeStore: (store) => writeStore(params.gatewayUrl, store),
},
deviceId: params.deviceId,
role: params.role,
token: params.token,
@@ -489,9 +550,16 @@ export function storeDeviceAuthToken(params: {
});
}
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
export function clearDeviceAuthToken(params: {
deviceId: string;
gatewayUrl: string;
role: string;
}) {
clearDeviceAuthTokenFromStore({
adapter: { readStore, writeStore },
adapter: {
readStore: () => readStore(params.gatewayUrl),
writeStore: (store) => writeStore(params.gatewayUrl, store),
},
deviceId: params.deviceId,
role: params.role,
});
+12 -2
View File
@@ -246,8 +246,18 @@ class NodesPage extends LitElement implements NodesPageDataState {
onDeviceApprove: (requestId) => void approveDevicePairing(this, requestId),
onDeviceReject: (requestId) => void rejectDevicePairing(this, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
void rotateDeviceToken(this, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => void revokeDeviceToken(this, { deviceId, role }),
void rotateDeviceToken(this, {
deviceId,
gatewayUrl: this.context.gateway.connection.gatewayUrl,
role,
scopes,
}),
onDeviceRevoke: (deviceId, role) =>
void revokeDeviceToken(this, {
deviceId,
gatewayUrl: this.context.gateway.connection.gatewayUrl,
role,
}),
onLoadConfig: () =>
void this.context.runtimeConfig.refresh({ discardPendingChanges: true }),
onLoadExecApprovals: () =>
+3 -1
View File
@@ -54,6 +54,7 @@ export type ControlUiMockGatewayScenario = {
}>;
defaultAgentId?: string;
deferredMethods?: string[];
deviceToken?: string;
featureMethods?: string[];
historyMessages?: unknown[];
methodResponses?: Record<string, unknown>;
@@ -218,6 +219,7 @@ function normalizeScenario(
controlUiTabs: scenario.controlUiTabs ?? [],
defaultAgentId,
deferredMethods: scenario.deferredMethods ?? [],
deviceToken: scenario.deviceToken?.trim() || "e2e-device-token",
featureMethods: scenario.featureMethods ?? ["chat.metadata", "chat.startup"],
historyMessages: scenario.historyMessages ?? [],
methodResponses: scenario.methodResponses ?? {},
@@ -429,7 +431,7 @@ function installControlUiMockGateway(input: {
case "connect":
return {
auth: {
deviceToken: "e2e-device-token",
deviceToken: scenario.deviceToken,
role: "operator",
scopes: [
"operator.admin",