feat(gateway): server-side token injection for reverse proxy deployments#10382
feat(gateway): server-side token injection for reverse proxy deployments#10382nkuhn-vmw wants to merge 3 commits intoopenclaw:mainfrom
Conversation
| // Extract token from reverse proxy header when configured. | ||
| const injectionConfig = opts?.config?.gateway?.auth?.injectTokenFromHeader; | ||
| let injectedToken: string | undefined; | ||
| if (injectionConfig?.enabled) { | ||
| const headerName = (injectionConfig.headerName ?? "x-openclaw-token").toLowerCase(); | ||
| const headerValue = req.headers[headerName]; | ||
| if (typeof headerValue === "string" && headerValue.trim()) { | ||
| injectedToken = headerValue.trim(); | ||
| } |
There was a problem hiding this comment.
Multiple header values ignored
req.headers[headerName] can be a string[] when the proxy sends the header multiple times; in that case injectedToken stays undefined and token injection silently won’t happen even though the header is present. The codebase already has getHeader() which handles string | string[] and would make this robust. This affects reverse proxies that emit duplicate headers (or combine) during redirects / auth flows.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/control-ui.ts
Line: 272:280
Comment:
**Multiple header values ignored**
`req.headers[headerName]` can be a `string[]` when the proxy sends the header multiple times; in that case `injectedToken` stays `undefined` and token injection silently won’t happen even though the header is present. The codebase already has `getHeader()` which handles `string | string[]` and would make this robust. This affects reverse proxies that emit duplicate headers (or combine) during redirects / auth flows.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Good catch. Fixed in 673869d — now uses the existing getHeader() from src/gateway/http-utils.ts which handles both string and string[] header values (takes the first element for arrays). This covers proxies that emit duplicate headers during auth flows.
| const tokenSnippet = token | ||
| ? `(function(){try{` + | ||
| `var k="openclaw.control.settings.v1",s={};` + | ||
| `try{s=JSON.parse(localStorage.getItem(k)||"{}")}catch(e){}` + | ||
| `s.token=${JSON.stringify(token)};` + | ||
| `localStorage.setItem(k,JSON.stringify(s));` + | ||
| `}catch(e){}})();` | ||
| : ""; |
There was a problem hiding this comment.
Ambiguous token precedence
The server-side injection always writes the token into localStorage when the header is present. If the same page load also uses the existing URL-based token import (query params), the two mechanisms can conflict and the header-provided token will overwrite the other value. It would be better to define a deterministic precedence rule (e.g., skip injection when a token is already set, or skip when a token query param is present) to avoid surprising behavior during migration/troubleshooting.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/control-ui.ts
Line: 176:183
Comment:
**Ambiguous token precedence**
The server-side injection always writes the token into localStorage when the header is present. If the same page load also uses the existing URL-based token import (query params), the two mechanisms can conflict and the header-provided token will overwrite the other value. It would be better to define a deterministic precedence rule (e.g., skip injection when a token is already set, or skip when a token query param is present) to avoid surprising behavior during migration/troubleshooting.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Good point. Fixed in 673869d — the injection snippet now only sets the token when localStorage doesn't already have one (if(!s.token){...}). This gives a deterministic precedence:
?token=query param (client-side, viaapplySettingsFromUrl()) — wins, because it runs after page load and overwrites localStorage- Header injection (server-side, this PR) — only seeds localStorage if empty, acts as a default
- Existing localStorage — preserved if already set by either mechanism
So during migration or troubleshooting, explicitly passing ?token=newvalue in the URL will always take effect regardless of header injection.
bfc1ccb to
f92900f
Compare
|
This pull request has been automatically marked as stale due to inactivity. |
When running OpenClaw behind a reverse proxy (e.g. oauth2-proxy for SSO),
the browser needs the gateway token for WebSocket auth. Currently this
requires a custom HTML injection proxy between the SSO proxy and OpenClaw.
This adds a `gateway.auth.injectTokenFromHeader` config option that reads
the gateway token from an HTTP request header (set by the reverse proxy)
and injects it into the Control UI HTML via a <script> tag that populates
localStorage. This eliminates the need for a custom token injection proxy.
Config example:
```json
{
"gateway": {
"auth": {
"mode": "token",
"token": "my-secret",
"injectTokenFromHeader": {
"enabled": true,
"headerName": "x-openclaw-token"
}
}
}
}
```
The reverse proxy sets the header (e.g. oauth2-proxy --pass-header), and
the gateway auto-populates the browser's localStorage with the token on
every HTML page load. Existing ?token= query param flow is unchanged.
Changes:
- types.gateway.ts: Add GatewayTokenInjectionConfig type
- control-ui.ts: Extract token from header, inject into HTML via localStorage
- schema.ts: Add UI hints and help text for new config fields
Address review feedback: 1. Use getHeader() from http-utils.ts instead of raw req.headers[] to correctly handle string[] when proxies send duplicate headers. 2. Only inject token into localStorage when no token is already set, so ?token= query param (client-side) takes precedence over header injection (server-side). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
673869d to
40d372f
Compare
…inline scripts Adapt to main's CSP-safe architecture that disallows inline scripts. Instead of injecting a <script> tag into index.html, pass the token through the existing bootstrap JSON config endpoint. The client awaits the config before connecting when no token is present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Closing this PR — the {
"gateway": {
"auth": {
"mode": "trusted-proxy",
"trustedProxy": {
"userHeader": "x-auth-request-email"
}
},
"trustedProxies": ["10.0.0.1"]
}
}
|
Summary
When running OpenClaw behind a reverse proxy (e.g. oauth2-proxy for SSO), the browser needs the gateway token for WebSocket authentication. Currently this requires deploying a custom HTML injection middleware between the SSO proxy and OpenClaw — adding operational complexity and an extra process to manage.
This PR adds a native
gateway.auth.injectTokenFromHeaderconfig option that:/__openclaw/control-ui-config.json)localStorageand uses it for WebSocket authenticationThis eliminates the need for custom token injection proxies in SSO deployments.
Motivation
In Cloud Foundry (and similar PaaS) deployments, oauth2-proxy handles SSO authentication as a sidecar process. The gateway token must reach the browser for WebSocket auth, but:
?token=query param approach doesn't work with SSO redirects (proxy can't inject query params)<script>tag — this is fragile and adds ~50 lines of deployment glueWith this change, the reverse proxy just sets an HTTP header (e.g.
--pass-header=X-OpenClaw-Tokenin oauth2-proxy), and the gateway handles the rest natively.Configuration
{ "gateway": { "auth": { "mode": "token", "token": "my-secret", "injectTokenFromHeader": { "enabled": true, "headerName": "x-openclaw-token" } } } }Default header name is
x-openclaw-token. The feature is off by default — no behavior change for existing deployments.Changes
src/config/types.gateway.ts— AddGatewayTokenInjectionConfigtype toGatewayAuthConfigsrc/config/schema.labels.ts/src/config/schema.help.ts— Add UI labels and help text for the new config fieldssrc/gateway/control-ui-contract.ts— Add optionaltokenfield toControlUiBootstrapConfigsrc/gateway/control-ui.ts— Extract token from request header inhandleControlUiHttpRequest(), include it in the bootstrap JSON config responseui/src/ui/controllers/control-ui-bootstrap.ts— Return token from bootstrap config fetchui/src/ui/app-lifecycle.ts— When no token is present, await bootstrap config (which may provide a reverse-proxy token) before connecting the WebSocketArchitecture
Token delivery uses the existing bootstrap JSON config endpoint rather than inline script injection, which is compatible with the Control UI's CSP policy (
script-src 'self'without'unsafe-inline').Token precedence (highest to lowest):
?token=query param (client-side, viaapplySettingsFromUrl()) — always winslocalStoragetoken — preserved from previous sessionsStartup flow:
?token=), the WS connects immediately; bootstrap config fetches in the backgroundSecurity Considerations
enabled: true)gateway.trustedProxies, only requests from trusted IPs are acceptedgetHeader()which handles bothstringandstring[]header values (for proxies that emit duplicate headers)Test plan
?token=query param flow still works unchangedinjectTokenFromHeader.enabled: false(default) has no effectenabled: trueand header is present🤖 Generated with Claude Code