Skip to content

fix(gateway): always resolve device token for fallback auth during SPA navigation#45070

Closed
openperf wants to merge 2 commits intoopenclaw:mainfrom
openperf:fix/device-token-fallback-auth
Closed

fix(gateway): always resolve device token for fallback auth during SPA navigation#45070
openperf wants to merge 2 commits intoopenclaw:mainfrom
openperf:fix/device-token-fallback-auth

Conversation

@openperf
Copy link
Copy Markdown
Contributor

Summary

The gateway connect-auth logic suppresses the stored device token whenever an explicit shared token or password is present. This means the auth.deviceToken field 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.deviceToken is always populated when a device identity exists. The gateway server already supports deviceToken fallback 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)

File Change
src/gateway/client.ts Remove the !(explicitGatewayToken || authPassword) guard from resolvedDeviceToken computation so the stored token is always resolved
ui/src/ui/gateway.ts Same fix for the browser client; also include deviceToken in the auth object construction guard so the payload is built even when only a device token is available

Test code (2 files)

File Change
src/gateway/client.test.ts Update two tests to verify deviceToken is sent alongside shared token/password; add deviceIdentity to test fixtures (fixes the missing config gap flagged during review of #39639)
ui/src/ui/gateway.node.test.ts Update browser-client test to verify deviceToken is sent alongside explicit shared auth

Root cause analysis

The issue was first reported in #39611 and traced to three interacting bugs by @NetZlash:

  1. Bug 1resolvedDeviceToken guard in selectConnectAuth() discards the stored device token when any shared credential is present
  2. Bug 2 — The browser client's auth object omits deviceToken when only a device token (no shared token/password) is available
  3. Bug 3 — SPA navigation drops the URL hash token on same-tab reload

PR #40892 addressed Bug 3 via sessionStorage persistence, but Bugs 1 and 2 remain unfixed on main. This is confirmed by:

This PR fixes Bugs 1 and 2 at the gateway layer, complementing the UI-layer fix in #40892.

How to test

  1. Start gateway with token auth: openclaw dashboard --no-open
  2. Open the token URL in browser — Control UI connects
  3. Navigate to Sessions → click a session → should stay connected (previously disconnected with 1008)
  4. Refresh the page → should reconnect using the device token fallback
  5. Open a new tab with the base URL (no token hash) → should still connect if device was previously paired

Related issues

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-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 13, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Cross-origin exfiltration of cached device auth token via WebSocket connect auth payload
2 🟠 High Stored device auth token included in connect payload for untrusted Gateway URLs (Node GatewayClient)

1. 🟠 Cross-origin exfiltration of cached device auth token via WebSocket connect auth payload

Property Value
Severity High
CWE CWE-359
Location ui/src/ui/gateway.ts:441-270

Description

In GatewayBrowserClient, a cached device auth token (persisted in window.localStorage) is now always resolved and included in the initial WebSocket connect request, regardless of whether the WebSocket URL is a trusted/same-origin endpoint.

This introduces a credential leak risk:

  • Source (secret): storedToken is loaded from window.localStorage via loadDeviceAuthToken() (openclaw.device.auth.v1). This token is used for gateway authentication (bearer-like credential).
  • Sink (exfil): the token is placed into the auth object and transmitted over the WebSocket to this.opts.url.
  • Trust bypass: the prior isTrustedRetryEndpoint(this.opts.url) gating was removed for resolvedDeviceToken, so the browser client can send the stored device token to any URL passed in (including attacker-controlled wss://...).

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).

Recommendation

Re-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 auth payload only includes deviceToken when the endpoint is trusted:

const deviceToken = trusted ? (selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken) : undefined;

More robust fix:

  • Bind device tokens to the gateway origin when storing/loading (include gateway host/origin in the storage key), so a token issued by one gateway is never sent to another.
  • Consider enforcing wss:// for non-loopback connections (defense-in-depth) and/or require explicit user consent before sending cached credentials to a new origin.

2. 🟠 Stored device auth token included in connect payload for untrusted Gateway URLs (Node GatewayClient)

Property Value
Severity High
CWE CWE-201
Location src/gateway/client.ts:519-523

Description

The Node GatewayClient now resolves and transmits the stored device auth token (storedToken) even when shared credentials (token/password) are present, and without requiring the endpoint to pass isTrustedDeviceRetryEndpoint().

Impact:

  • storedToken is loaded from the local device-auth store and is a long-lived secret.
  • With this change, resolvedDeviceToken becomes storedToken whenever a stored token exists.
  • resolvedDeviceToken is placed into the websocket connect frame (auth.deviceToken), so it is transmitted to whatever opts.url points to.
  • The existing trust gate (isTrustedDeviceRetryEndpoint()), which requires loopback or wss:// plus a configured tlsFingerprint, no longer controls whether the stored device token is sent (it only gates authDeviceToken for the special retry path).

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 wss:// endpoint), this causes credential disclosure of the cached device token to that endpoint.

Recommendation

Re-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 storedToken when the endpoint is trusted (loopback, or wss:// with tlsFingerprint), and otherwise only use explicitly provided device tokens:

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 82cba0c

Last updated on: 2026-03-13T13:12:52Z

@openclaw-barnacle openclaw-barnacle bot added app: web-ui App: web-ui gateway Gateway runtime size: S labels Mar 13, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This 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:

  • src/gateway/client.ts: Removes the shared-credential exclusion from the resolvedDeviceToken computation; the device credential is now resolved independently of whether a shared credential is present
  • ui/src/ui/gateway.ts: Simplifies resolvedDeviceToken to always derive from storedToken, and extends the auth object guard to include the device credential as a sufficient condition
  • Tests in client.test.ts and gateway.node.test.ts updated to assert the new behavior; deviceIdentity fixtures added to test cases that previously lacked them

Issues found:

  • In src/gateway/client.ts, because resolvedDeviceToken is now always populated when a stored credential exists, the derived authToken variable (explicitGatewayToken ?? resolvedDeviceToken) will be set to the stored device credential when authPassword is the primary auth method and no explicit shared credential is present. This sets the token field in the connect frame to the device credential alongside the password field — a new behavioral change (the previous assertion in the test expected undefined here, not a device credential value). If the gateway server evaluates the token field before the password field, password-based connections from clients that also hold a stored device identity may begin failing.
  • The shouldRetryWithStoredDeviceToken guard in client.ts short-circuits immediately when resolvedDeviceToken is set. Since resolvedDeviceToken is now always set for clients with a stored credential, the device-retry path (pendingDeviceTokenRetry / shouldUseDeviceRetryToken) is now effectively unreachable dead code for such clients.

Confidence Score: 2/5

  • The fix correctly addresses token-auth reconnect regressions, but introduces an unintended side effect where the token field in the connect frame is populated with the stored device credential during password-auth connects, which may break password-based flows.
  • The fix for token-auth and device-credential fallback cases is correct and well-tested. However, the authToken derivation chain causes the connect frame token field to be set to the device credential during password-auth flows — new behavior contradicting the prior test expectation. This untested server interaction and the now-unreachable retry path lower confidence significantly.
  • Pay close attention to src/gateway/client.ts — specifically the authToken assignment on line 526 and how it interacts with password-auth flows, and the shouldRetryWithStoredDeviceToken guard on line 455 which is now permanently bypassed for clients that have a stored device credential.

Comments Outside Diff (2)

  1. src/gateway/client.ts, line 452-456 (link)

    shouldRetryWithStoredDeviceToken retry path permanently disabled for users with stored tokens

    The guard on line 455 (if (params.resolvedDeviceToken) { return false; }) was designed to skip the retry when the device token was already included in the initial connect frame. Before this PR, resolvedDeviceToken was undefined whenever a shared credential was present, so the retry path could still trigger for the "shared token failed → retry with device token" scenario.

    After this PR, resolvedDeviceToken is now always set when storedToken exists (the core fix). This means shouldRetryWithStoredDeviceToken will always return false for any client that has a stored device token, regardless of whether authentication actually failed and a retry with a different device-token-only frame would help.

    The shouldUseDeviceRetryToken / pendingDeviceTokenRetry / authDeviceToken code path is now effectively dead code for users with stored device tokens.

    If the intent is that the initial frame always carrying the device token makes retries unnecessary, this should be documented explicitly and the now-dead retry path should be cleaned up to avoid confusion. If the retry is still intended to be reachable in some edge case (e.g. storedToken is present but resolvedDeviceToken is intentionally omitted), the guard logic needs revisiting.

  2. src/gateway/client.ts, line 519-526 (link)

    auth.token unintentionally populated with device token during password auth

    Removing the !(explicitGatewayToken || authPassword) guard means resolvedDeviceToken is now always set when storedToken exists. Because authToken is assigned as explicitGatewayToken ?? resolvedDeviceToken on line 526, when no explicit shared token is present but a password credential is provided, authToken falls through to the stored device token.

    This is a net-new behavioral change: the updated test on line 406 now asserts that auth.token is set to the stored device token during password-auth connects, whereas the previous expectation was toBeUndefined(). The inline test comment calling this "legacy compatibility" is inaccurate — this is new behavior introduced here.

    The practical risk: the gateway server may evaluate auth.token before auth.password. If it validates the token's format or origin before falling back to password checking, connections that supply a password credential alongside a stored device identity will start receiving auth rejections. Additionally, signatureToken (line 534) is derived from authToken, so the device-signature payload will embed the stored device token in its signed field, which may not satisfy server-side signature validation for a password-auth connect.

    A safer approach is to conditionally skip the resolvedDeviceToken fallback for authToken when authPassword is present, so that auth.deviceToken is still populated for the fallback path without clobbering auth.token in password flows. This would preserve the existing behavior for password-auth while achieving the fix for token-auth and token-less reconnects.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/client.ts
Line: 452-456

Comment:
**`shouldRetryWithStoredDeviceToken` retry path permanently disabled for users with stored tokens**

The guard on line 455 (`if (params.resolvedDeviceToken) { return false; }`) was designed to skip the retry when the device token was already included in the initial connect frame. Before this PR, `resolvedDeviceToken` was `undefined` whenever a shared credential was present, so the retry path could still trigger for the "shared token failed → retry with device token" scenario.

After this PR, `resolvedDeviceToken` is now always set when `storedToken` exists (the core fix). This means `shouldRetryWithStoredDeviceToken` will **always** return `false` for any client that has a stored device token, regardless of whether authentication actually failed and a retry with a different device-token-only frame would help.

The `shouldUseDeviceRetryToken` / `pendingDeviceTokenRetry` / `authDeviceToken` code path is now effectively dead code for users with stored device tokens.

If the intent is that the initial frame always carrying the device token makes retries unnecessary, this should be documented explicitly and the now-dead retry path should be cleaned up to avoid confusion. If the retry is still intended to be reachable in some edge case (e.g. `storedToken` is present but `resolvedDeviceToken` is intentionally omitted), the guard logic needs revisiting.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/client.ts
Line: 519-526

Comment:
**`auth.token` unintentionally populated with device token during password auth**

Removing the `!(explicitGatewayToken || authPassword)` guard means `resolvedDeviceToken` is now always set when `storedToken` exists. Because `authToken` is assigned as `explicitGatewayToken ?? resolvedDeviceToken` on line 526, when no explicit shared token is present but a password credential is provided, `authToken` falls through to the stored device token.

This is a net-new behavioral change: the updated test on line 406 now asserts that `auth.token` is set to the stored device token during password-auth connects, whereas the previous expectation was `toBeUndefined()`. The inline test comment calling this "legacy compatibility" is inaccurate — this is new behavior introduced here.

The practical risk: the gateway server may evaluate `auth.token` before `auth.password`. If it validates the token's format or origin before falling back to password checking, connections that supply a password credential alongside a stored device identity will start receiving auth rejections. Additionally, `signatureToken` (line 534) is derived from `authToken`, so the device-signature payload will embed the stored device token in its signed field, which may not satisfy server-side signature validation for a password-auth connect.

A safer approach is to conditionally skip the `resolvedDeviceToken` fallback for `authToken` when `authPassword` is present, so that `auth.deviceToken` is still populated for the fallback path without clobbering `auth.token` in password flows. This would preserve the existing behavior for password-auth while achieving the fix for token-auth and token-less reconnects.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 82cba0c

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@velvet-shark
Copy link
Copy Markdown
Member

Thanks for digging into the auth/reconnect path here.

I re-checked the linked issues, all the comments, and the merged #40892 fix. The evidence points to two separate problems rather than one remaining root cause:

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.

@velvet-shark velvet-shark added the invalid This doesn't seem right label Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui gateway Gateway runtime invalid This doesn't seem right size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Control UI "device signature invalid" — token field mismatch between client signing and server

2 participants