fix(gateway): always resolve device token for fallback auth during SPA navigation#45070
fix(gateway): always resolve device token for fallback auth during SPA navigation#45070openperf wants to merge 2 commits intoopenclaw:mainfrom
Conversation
The gateway connect-auth logic previously suppressed the stored device token whenever an explicit shared token or password was present. This meant the auth payload's `deviceToken` field was empty when the user connected via a dashboard token URL. After SPA navigation or a page refresh strips the shared token from the URL hash, the next WebSocket reconnect attempt has neither a shared token nor a device token, causing the gateway to reject the connection with "device identity required" (close code 1008). Fix: resolve the stored device token independently of shared credentials so that `auth.deviceToken` is always populated when a device identity exists. The gateway server already supports deviceToken fallback when the shared token is absent or invalid. Also ensure the browser client builds the `auth` object when only a deviceToken is available (no shared token or password). Fixes openclaw#39667 Refs openclaw#39611, openclaw#44485
Update connect-auth tests to assert that the stored device token is included in the auth payload even when an explicit shared token or password is provided. Previously the tests asserted deviceToken was undefined in these cases, matching the old suppression behavior. Add `deviceIdentity` to the Node.js client test fixtures so the stored-token lookup path is exercised (addresses the missing `deviceIdentity` config gap flagged during review of openclaw#39639).
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟠 Cross-origin exfiltration of cached device auth token via WebSocket connect auth payload
DescriptionIn This introduces a credential leak risk:
Vulnerable flow: const storedToken = loadDeviceAuthToken({ deviceId, role })?.token;
const resolvedDeviceToken = storedToken ?? undefined; // no trust check
...
const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken;
const auth = authToken || selectedAuth.authPassword || deviceToken ? { deviceToken, ... } : undefined;If a user can be induced to connect to an attacker-controlled WebSocket URL (e.g., by changing the gateway URL in settings / confirming a URL change prompt / any URL injection), the attacker receives the cached device token and may be able to reuse it to authenticate to the real gateway (depending on server-side auth policy). RecommendationRe-scope and gate cached device-token usage so it is not sent to untrusted endpoints. Minimal fix (restore trust check when using stored tokens): const storedToken = loadDeviceAuthToken({ deviceId: params.deviceId, role: params.role })?.token;
const trusted = isTrustedRetryEndpoint(this.opts.url);
const resolvedDeviceToken = trusted ? (storedToken ?? undefined) : undefined;Also ensure the const deviceToken = trusted ? (selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken) : undefined;More robust fix:
2. 🟠 Stored device auth token included in connect payload for untrusted Gateway URLs (Node GatewayClient)
DescriptionThe Node Impact:
Vulnerable behavior (new logic): const resolvedDeviceToken = explicitDeviceToken ??
(shouldUseDeviceRetryToken || !explicitBootstrapToken || Boolean(storedToken)
? (storedToken ?? undefined)
: undefined);
...
const auth = authToken || authBootstrapToken || authPassword || resolvedDeviceToken
? { deviceToken: authDeviceToken ?? resolvedDeviceToken, ... }
: undefined;If an attacker can influence the gateway URL (e.g., via configuration/CLI argument injection, or tricking an operator into connecting to an attacker-controlled RecommendationRe-introduce a trust boundary for sending cached/stored device tokens, or require an explicit opt-in to send them to non-loopback, non-pinned endpoints. For example, only include const endpointTrusted = this.isTrustedDeviceRetryEndpoint();
const resolvedDeviceToken =
explicitDeviceToken ??
(endpointTrusted && storedToken ? storedToken : undefined);
// Or, if you only want fallback during retry:
const resolvedDeviceToken = explicitDeviceToken ??
(shouldUseDeviceRetryToken ? storedToken ?? undefined : undefined);This preserves the original security property: cached device credentials are not disclosed to arbitrary remote endpoints. Analyzed PR: #45070 at commit Last updated on: 2026-03-13T13:12:52Z |
Greptile SummaryThis PR fixes the root cause of the "device identity required" close-1008 error during SPA navigation by removing the guard that suppressed the stored device credential whenever a shared credential was present, ensuring the device fallback field is always populated in the WebSocket connect frame. The browser client receives a complementary fix so the connect payload is constructed even when only a device identity is available. Key changes:
Issues found:
Confidence Score: 2/5
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 82cba0c4e5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| explicitDeviceToken ?? | ||
| (shouldUseDeviceRetryToken || | ||
| (!(explicitGatewayToken || authPassword) && (!explicitBootstrapToken || Boolean(storedToken))) | ||
| (shouldUseDeviceRetryToken || !explicitBootstrapToken || Boolean(storedToken) |
There was a problem hiding this comment.
Keep backend device token fallback behind trust gating
This branch now resolves storedToken whenever one exists, regardless of isTrustedDeviceRetryEndpoint(). In sendConnect() the client always emits deviceToken: authDeviceToken ?? resolvedDeviceToken, so a connection that uses an explicit shared credential to an untrusted endpoint (for example wss://... without a pinned tlsFingerprint) will now send the cached device token on the first connect frame. That bypasses the existing trusted-endpoint/one-shot retry boundary and exposes the device token in cases where the previous code intentionally withheld it.
Useful? React with 👍 / 👎.
| // caused reconnects to fail with "device identity required" after SPA | ||
| // navigation dropped the shared token from the URL hash (#39611, | ||
| // #39667, #44485). | ||
| const resolvedDeviceToken = storedToken ?? undefined; |
There was a problem hiding this comment.
Keep browser device token fallback behind trust gating
Setting resolvedDeviceToken directly from storedToken here makes the cached device token available even when isTrustedRetryEndpoint() is false. Since sendConnect() now includes deviceToken whenever present, Control UI clients with an explicit shared credential will transmit the cached device token on the initial connect instead of only during the bounded trusted-retry path. This removes the trust gate that previously limited where device-token fallback could be sent.
Useful? React with 👍 / 👎.
|
Thanks for digging into the auth/reconnect path here. I re-checked the linked issues, all the comments, and the merged
Because of that, this PR ends up solving the wrong path, and it also broadens cached device-token fallback beyond the current trusted retry boundary. I opened a narrower replacement in #45088 that restores explicit shared token/password auth for insecure Control UI connects without changing the trusted device-token retry behavior. Closing this one in favor of #45088. |
Summary
The gateway connect-auth logic suppresses the stored device token whenever an explicit shared token or password is present. This means the
auth.deviceTokenfield in the WebSocket connect frame is empty when the user connects via a dashboard token URL.After SPA navigation or a page refresh strips the shared token from the URL hash, the next reconnect attempt has neither a shared token nor a device token, causing the gateway to reject with
device identity required(close code 1008).This PR fixes the root cause by resolving the stored device token independently of shared credentials, so
auth.deviceTokenis always populated when a device identity exists. The gateway server already supportsdeviceTokenfallback when the shared token is absent or invalid (see the server auth matrix test "uses explicit auth.deviceToken fallback when shared token is wrong").What changed
Production code (2 files)
src/gateway/client.ts!(explicitGatewayToken || authPassword)guard fromresolvedDeviceTokencomputation so the stored token is always resolvedui/src/ui/gateway.tsdeviceTokenin theauthobject construction guard so the payload is built even when only a device token is availableTest code (2 files)
src/gateway/client.test.tsdeviceTokenis sent alongside shared token/password; adddeviceIdentityto test fixtures (fixes the missing config gap flagged during review of #39639)ui/src/ui/gateway.node.test.tsdeviceTokenis sent alongside explicit shared authRoot cause analysis
The issue was first reported in #39611 and traced to three interacting bugs by @NetZlash:
resolvedDeviceTokenguard inselectConnectAuth()discards the stored device token when any shared credential is presentauthobject omitsdeviceTokenwhen only a device token (no shared token/password) is availablePR #40892 addressed Bug 3 via
sessionStoragepersistence, but Bugs 1 and 2 remain unfixed onmain. This is confirmed by:dangerouslyDisableDeviceAuth: trueignored in 2026.3.11 — Control UI rejects HTTP connections with "device identity required" #44485 (dangerouslyDisableDeviceAuthignored in v2026.3.11) opened todayThis PR fixes Bugs 1 and 2 at the gateway layer, complementing the UI-layer fix in #40892.
How to test
openclaw dashboard --no-openRelated issues
dangerouslyDisableDeviceAuth: trueignored in 2026.3.11 — Control UI rejects HTTP connections with "device identity required" #44485, [Bug]: Control UI still rejects LAN token-only access with 'device identity required' on v2026.3.12 #44967