mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 10:51:43 +00:00
docs(control-ui): clarify loopback gateway auth (#112746)
* docs(control-ui): clarify loopback gateway auth * fix(gateway): preserve control ui settings auth hint
This commit is contained in:
committed by
GitHub
parent
62c148d7d0
commit
30c651d5bf
@@ -39,11 +39,13 @@ Auth is supplied during the WebSocket handshake via:
|
||||
- Tailscale Serve identity headers when `gateway.auth.allowTailscale: true`
|
||||
- trusted-proxy identity headers when `gateway.auth.mode: "trusted-proxy"`
|
||||
|
||||
The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. Onboarding usually generates a gateway token for shared-secret auth on first connect, but password auth works too when `gateway.auth.mode` is `"password"`.
|
||||
Gateway auth runs before device pairing. A direct loopback connection does not bypass token or password auth. The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. After pairing, the browser can use its stored per-device token on later connections.
|
||||
|
||||
Onboarding usually configures a gateway token for shared-secret auth. If the Gateway starts in token mode without a configured token, it generates an ephemeral runtime token for that process instead. The runtime token is not written to config, so `openclaw config get gateway.auth.token` cannot retrieve it and a loopback browser without that token is rejected. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then paste the configured token in Control UI settings. Password auth works instead when `gateway.auth.mode` is `"password"`.
|
||||
|
||||
## Device pairing (first connection)
|
||||
|
||||
Connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`.
|
||||
After gateway auth succeeds, connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`.
|
||||
|
||||
<Steps>
|
||||
<Step title="List pending requests">
|
||||
@@ -60,14 +62,15 @@ Connecting from a new browser or device usually requires a **one-time pairing ap
|
||||
|
||||
If the browser retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new `requestId` is created; re-run `openclaw devices list` before approving.
|
||||
|
||||
Switching an already-paired browser from read access to write/admin access is treated as an approval upgrade, not a silent reconnect: OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly.
|
||||
Switching an already-paired remote browser from read access to write/admin access is treated as an approval upgrade, not a silent reconnect: OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly. A qualifying direct-loopback Control UI connection can silently approve the upgrade after it authenticates.
|
||||
|
||||
Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow.
|
||||
|
||||
<Note>
|
||||
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are auto-approved.
|
||||
- Direct local Control UI connections from a loopback TCP peer (`127.0.0.1` or `::1`, typically reached as `localhost`) with no forwarded/proxy headers can auto-approve device pairing only after gateway auth succeeds and the browser presents device identity. In token/password mode, the first connection still needs the configured shared secret; this auto-approval is not a token bypass.
|
||||
- Direct loopback needs no shared secret only when `gateway.auth.mode: "none"` is explicitly configured. That disables gateway auth and is not the recommended Control UI setup. Tailscale Serve and trusted-proxy modes can avoid a pasted shared secret only when their respective identity checks succeed.
|
||||
- Tailscale Serve can skip the pairing round trip for Control UI operator sessions when `gateway.auth.allowTailscale: true`, Tailscale identity verifies, and the browser presents its device identity. Device-less browsers and node-role connections still follow the normal device checks.
|
||||
- Direct Tailnet binds, LAN browser connects, and browser profiles without device identity still require explicit approval.
|
||||
- Direct Tailnet binds and LAN browser connects still require explicit approval. Browser profiles without device identity cannot use loopback auto-approval.
|
||||
- Each browser profile generates a unique device ID, so switching browsers or clearing browser data requires re-pairing.
|
||||
|
||||
</Note>
|
||||
|
||||
@@ -43,6 +43,7 @@ The Control UI is an **admin surface** (chat, config, exec approvals). Do not ex
|
||||
- **Localhost**: open `http://127.0.0.1:18789/`.
|
||||
- **Gateway TLS**: when `gateway.tls.enabled: true`, dashboard/status links use `https://` and Control UI WebSocket links use `wss://`.
|
||||
- **Shared-secret token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). `openclaw dashboard` can pass it via URL fragment for one-time bootstrap; the Control UI keeps it in sessionStorage for the current tab and selected gateway URL, not localStorage.
|
||||
- **Missing-config runtime token**: if startup says it generated a runtime token, that token is ephemeral and is not available through `openclaw config get gateway.auth.token`. Loopback still requires auth. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then paste the configured token in Control UI settings.
|
||||
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design, to avoid exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. If the ref is unresolved in your current shell, it still prints the non-tokenized URL plus actionable auth setup guidance.
|
||||
- **Shared-secret password**: use the configured `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). The dashboard does not persist passwords across reloads.
|
||||
- **Identity-bearing modes**: Tailscale Serve satisfies Control UI/WebSocket auth via identity headers when `gateway.auth.allowTailscale: true`; a non-loopback identity-aware reverse proxy satisfies `gateway.auth.mode: "trusted-proxy"`. Neither needs a pasted shared secret for the WebSocket.
|
||||
@@ -80,7 +81,7 @@ Non-goals for v1:
|
||||
- Token: `openclaw config get gateway.auth.token`
|
||||
- Password: resolve the configured `gateway.auth.password` or `OPENCLAW_GATEWAY_PASSWORD`
|
||||
- SecretRef-managed token: resolve the external secret provider, or export `OPENCLAW_GATEWAY_TOKEN` in this shell and rerun `openclaw dashboard`
|
||||
- No shared secret configured: `openclaw doctor --generate-gateway-token`
|
||||
- Runtime token generated because no shared secret was configured: run `openclaw doctor --generate-gateway-token`, restart the Gateway, then use the configured token
|
||||
- In the dashboard settings, paste the token or password into the auth field, then connect.
|
||||
- The UI language picker lives in **Settings -> General -> Language**, not under Appearance.
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
* WebSocket authentication message regression tests.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
import { formatGatewayAuthFailureMessage } from "./auth-messages.js";
|
||||
|
||||
describe("formatGatewayAuthFailureMessage", () => {
|
||||
@@ -14,4 +19,21 @@ describe("formatGatewayAuthFailureMessage", () => {
|
||||
}),
|
||||
).toBe("unauthorized: device token scope mismatch (re-pair or approve scope upgrade)");
|
||||
});
|
||||
|
||||
it("makes a missing Control UI token actionable within the WebSocket close limit", () => {
|
||||
const message = formatGatewayAuthFailureMessage({
|
||||
authMode: "token",
|
||||
authProvided: "none",
|
||||
reason: "token_missing",
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_IDS.CONTROL_UI,
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
|
||||
expect(message).toBe(
|
||||
"unauthorized: gateway token missing (paste in Control UI settings or openclaw doctor --generate-gateway-token; restart)",
|
||||
);
|
||||
expect(truncateCloseReason(message)).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ export function formatGatewayAuthFailureMessage(params: {
|
||||
const isControlUi = isOperatorUiClient(client);
|
||||
const isWebchat = isWebchatClient(client);
|
||||
const uiHint = "open the dashboard URL and paste the token in Control UI settings";
|
||||
const missingUiTokenHint =
|
||||
"paste in Control UI settings or openclaw doctor --generate-gateway-token; restart";
|
||||
const tokenHint = isCli
|
||||
? "set gateway.remote.token to match gateway.auth.token"
|
||||
: isControlUi || isWebchat
|
||||
@@ -35,7 +37,7 @@ export function formatGatewayAuthFailureMessage(params: {
|
||||
: "provide gateway auth password";
|
||||
switch (reason) {
|
||||
case "token_missing":
|
||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
||||
return `unauthorized: gateway token missing (${isControlUi || isWebchat ? missingUiTokenHint : tokenHint})`;
|
||||
case "token_mismatch":
|
||||
return `unauthorized: gateway token mismatch (${tokenHint})`;
|
||||
case "token_missing_config":
|
||||
|
||||
@@ -166,6 +166,12 @@ export async function authenticateGatewayConnect(
|
||||
scopeCount: scopes.length,
|
||||
hasDeviceIdentity: Boolean(device),
|
||||
});
|
||||
const authMessage = formatGatewayAuthFailureMessage({
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
reason: failedAuth.reason,
|
||||
client: connectParams.client,
|
||||
});
|
||||
const authLogDecision = shouldLimitMissingCredentialAuthLog({
|
||||
reason: failedAuth.reason,
|
||||
authProvided,
|
||||
@@ -186,15 +192,9 @@ export async function authenticateGatewayConnect(
|
||||
? ` suppressed=${authLogDecision.suppressedSinceLastLog}`
|
||||
: "";
|
||||
logWsControl.warn(
|
||||
`unauthorized conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} role=${role} scopes=${scopes.length} auth=${authProvided} device=${device ? "yes" : "no"} platform=${formatForLog(connectParams.client.platform)} instance=${formatForLog(connectParams.client.instanceId ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")} reason=${failedAuth.reason ?? "unknown"}${suppressedText}`,
|
||||
`unauthorized conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} role=${role} scopes=${scopes.length} auth=${authProvided} device=${device ? "yes" : "no"} platform=${formatForLog(connectParams.client.platform)} instance=${formatForLog(connectParams.client.instanceId ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")} reason=${failedAuth.reason ?? "unknown"} guidance=${formatForLog(authMessage)}${suppressedText}`,
|
||||
);
|
||||
}
|
||||
const authMessage = formatGatewayAuthFailureMessage({
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
reason: failedAuth.reason,
|
||||
client: connectParams.client,
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
|
||||
...(failedAuth.rateLimited === true
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user