mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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
@@ -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 ?? [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user