gateway: optional bearer-token auth for /v1/* routes (off by default)#246
Conversation
Add Cfg.Gateway.Token. Empty (the default) preserves the existing
unauthenticated shape every pre-token deployment relies on. When
set, OnCommandGet's middleware requires `Authorization: Bearer
<token>` (or `?token=<token>` for browser EventSource which can't
set headers) on every non-exempt route and returns 401 otherwise.
Exempt routes (rationale in PasClaw.Gateway.Auth's unit comment):
/ web UI HTML -- browser can't attach a Bearer
header on the initial GET; JS inside the
page is responsible for the subsequent
/v1/* fetches.
/v1/health k8s readiness / liveness probes.
/v1/version build metadata; frequently scraped.
/webhooks/* per-channel signature secret (LINE
x-line-signature, Meta x-hub-signature-256,
etc.) gates these -- upstream channels can't
supply the gateway bearer.
Everything else is gated, including /v1/logs (per-tool argument
bytes), /v1/config (masked API keys + bot tokens), /v1/fs/read
(up to 256 KB of file contents), /mcp, /v1/mcp/rpc. The whole
point.
PasClaw.Gateway.Auth is a leaf unit (SysUtils only) so the
contract can be unit-tested in isolation -- no Indy server, no
provider stack, no config file. CheckGatewayAuth is the
decision function the middleware calls; ConstantTimeStringEqual
defeats simple timing oracles that measure first-byte-mismatch
to enumerate the secret.
Env var $PASCLAW_GATEWAY_TOKEN overrides config at LoadConfig
time (standard ops-sets-env-at-deploy contract; mirrors the
OTEL_EXPORTER_OTLP_ENDPOINT precedence shape).
Identity stamping: LoopCfg.Identity is now `gateway:authed`
when Cfg.Gateway.Token is non-empty, `gateway:anon` otherwise.
The pre-PR comment at PasClaw.Gateway.Server.pas:1498-1500
explicitly flagged "tightening this is a follow-up alongside
gateway auth"; this is that follow-up. Operators can now set
`allow_senders: ["gateway:authed", ...]` and have a meaningful
allowlist entry, so an accidentally-removed gateway.token field
on a 0.0.0.0-bound deployment trips the channel boundary
instead of silently dropping back to anon.
Pin the contract with src/tests/gateway_token_tests.pas (10
cases, all green):
- unauthenticated mode (empty token) passes every route
- configured token refuses missing / wrong / wrong-scheme
(Basic) headers
- matching Bearer header accepts; case-insensitive scheme;
surrounding whitespace tolerated
- ?token= query param accepted as fallback
- header beats query param when both present (avoids
log-leaked-token gotchas)
- /, /v1/health, /v1/version, /webhooks/* exempt; everything
else still gated
- constant-time compare: length-mismatch short-circuits;
same-length compares touch every byte (first-byte and last-
byte mismatch both return False, no early return path)
- ExtractBearerToken parses standard + lowercase + whitespace
variants
- IsExemptRoute names exactly the four families documented
- similar-looking paths don't silently match (/v1/healthbeat
is NOT exempt; only the exact /v1/health is)
Docs updated:
docs/gateway.md ## Authentication section (full contract)
docs/configuration.md gateway.token table row + env-var row
docs/security.md ## Gateway bearer token section
Known limitations (documented in docs/gateway.md):
- Embedded web UI doesn't yet pass the token through. Browser
users either bind loopback + ssh-tunnel, or leave the token
empty. Native web-UI auth is a follow-up.
- Token rotation requires a restart.
- No per-tenant tokens / no JWT -- single shared secret. Higher-
assurance auth: terminate TLS + mTLS at a reverse proxy and
run PasClaw loopback-bound.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09c9fbe6f1
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| env-at-deploy contract, mirrors the OTel env var precedence. | ||
| Empty env is a no-op (the config value, if any, wins). } | ||
| if GetEnvironmentVariable('PASCLAW_GATEWAY_TOKEN') <> '' then | ||
| Result.Gateway.Token := GetEnvironmentVariable('PASCLAW_GATEWAY_TOKEN'); |
There was a problem hiding this comment.
Keep env-only gateway tokens out of saved config
When PASCLAW_GATEWAY_TOKEN is set, LoadConfig mutates Result.Gateway.Token with the environment value. Any config-changing command that does LoadConfig then SaveConfig (for example pasclaw auth login, pasclaw model set, or MCP/cron/skill edits) will then serialize that deployment secret into config.json because ToJSON emits non-empty gateway.token. Keep the env override separate from the persisted config value, or suppress serialization for env-sourced tokens.
Useful? React with 👍 / 👎.
| Gw.PutStr ('log_level', Gateway.LogLevel); | ||
| Gw.PutStr ('bind_addr', Gateway.BindAddr); | ||
| Gw.PutInt ('port', Gateway.Port); | ||
| if Gateway.Token <> '' then Gw.PutStr('token', Gateway.Token); |
There was a problem hiding this comment.
Mask the new gateway token in /v1/config
This adds gateway.token to TConfig.ToJSON, and HandleConfig builds the /v1/config response from FCfg.ToJSON while masking only providers[].api_key and mcp_servers[].env. With token auth enabled, any authenticated caller of GET /v1/config receives the raw shared bearer token; this endpoint is already designed to show secret set/unset state without leaking values, so the new gateway.token field should be masked there too.
Useful? React with 👍 / 👎.
…openclaw env alias Two Codex P2 findings + one openclaw-compat ask, fixed together since they touch the same code path. P2 #1 -- env-only secret leaking into the persisted config: LoadConfig used to do Result.Gateway.Token := <env value>, so any later SaveConfig (auth login, model set, skills install, ...) round-tripped the env-only secret into config.json. A user who only wanted the token in $PASCLAW_GATEWAY_TOKEN ended up with it baked into ~/.pasclaw/config.json on the next CLI command and probably committed to git on the next dotfile sync. Fix: keep the env value in a SEPARATE module-level GEnvGatewayToken in PasClaw.Config and expose a public GetEffectiveGatewayToken(C) that returns env-or-config. ToJSON still serialises only C.Gateway.Token (the in-file value); the env override never persists. The middleware uses the effective getter at the auth check and at all three identity-stamp sites. P2 #2 -- /v1/config returning the raw bearer token: HandleConfig already masks providers[].api_key and mcp_servers[].env to "•••" but not the new gateway.token field. An authenticated /v1/config caller (or anyone hitting the endpoint when gateway.token is empty) would see the shared secret in cleartext. Fix: mirror the existing mask block on the gateway sub-object. openclaw-compat: Operators porting an existing openclaw .env file shouldn't have to rename the env var. LoadConfig now also honours $OPENCLAW_GATEWAY_TOKEN; PASCLAW_GATEWAY_TOKEN wins when both are set (we're not openclaw, after all). Adds 2 lines to the env-pickup block. Test additions (now 13 cases across 3 binary runs): TestEffectiveTokenContract (first pass): - no token anywhere: getter returns "" - config-only: getter returns Cfg.Gateway.Token - ToJSON / FromJSON round-trip of a config-set token - ToJSON does NOT emit "token" field when empty (guards against a future SaveConfig dropping a "":"" row) TestEnvModePrecedence (--env-mode pass): - LoadConfig leaves Cfg.Gateway.Token empty when only env is set - GetEffectiveGatewayToken returns the env value - ToJSON of an env-only-token config does NOT include the "token" field -- THIS is the literal assertion that catches the original Codex P2 bug - FromJSON round-trip produces empty Cfg.Gateway.Token Makefile runs the binary three times: 1. clean env (10 base cases + TestEffectiveTokenContract) 2. PASCLAW_GATEWAY_TOKEN set (TestEnvModePrecedence) 3. OPENCLAW_GATEWAY_TOKEN set (TestEnvModePrecedence again -- same assertion, different env var path) `make test` aggregate: 23 PASS markers (was 21). Docs updated: docs/gateway.md adds OPENCLAW_GATEWAY_TOKEN alias note, persistence-isolation contract, and the /v1/config masking note. docs/configuration.md adds the alias row + persistence note to the env-var table.
Embedded web UI now passes the gateway bearer token through to every
/v1/* fetch as `Authorization: Bearer <token>` when one is stored.
Same single-shared-secret model PASCLAW_GATEWAY_TOKEN already uses
at the protocol level; this just teaches the JS to participate.
UX (the operator path the user asked for):
1. Load https://gateway/ -> exempt route, web UI HTML loads.
2. JS fires the first /v1/status fetch on page load -> 401.
3. gwFetch() catches the 401, pops a window.prompt() asking for
the bearer token, saves the answer to
localStorage["pasclaw.gw_token.v1"], retries the fetch.
4. Every subsequent fetch (status, memory, files, mcp, cron,
skills, config, stats, chat completion stream) picks the
token up automatically.
5. The new 🔑 button in the header lets the operator re-set or
clear the token without reloading the page -- handy when
rotating, or after an env-var swap on the gateway.
SSE path (EventSource can't set headers):
gwSseUrl(path) appends ?token=<stored> to the URL. PasClaw.
Gateway.Auth.CheckGatewayAuth already accepts the query-param
fallback (added in PR #246 specifically for browser SSE), so
/v1/logs streams reach the JS as soon as a token is stored.
Implementation:
- 4 new helpers in webui.html's <script> block (gwToken /
gwSetToken / gwClearToken / promptForToken).
- gwFetch(url, opts): wraps fetch(), adds the Authorization
header, prompts + retries once on 401. The single-retry cap
matters -- a second 401 after the user supplied a token
surfaces normally (rendered as "offline" / "error" in each
tab's loader) instead of looping the prompt.
- gwSseUrl(path): appends ?token=<value> when stored.
- One sed pass swaps every fetch("/v1/...") -> gwFetch(...) and
the single EventSource("/v1/logs") -> EventSource(gwSseUrl(...)).
- Tiny 🔑 button in the header with title= explaining what it
does.
Security: localStorage is accessible to any script running on the
page. Since the embedded UI ships verbatim from the gateway binary
and loads no third-party scripts (no CDN, no analytics, no
external fonts -- check webui.html), that's an acceptable trade
for the single-shared-secret model. Documented in docs/gateway.md
+ digitalocean/README.md alongside the higher-assurance
reverse-proxy + mTLS path the existing security notes mention.
webui.res regenerated via `fpcres -of res -o webui.res webui.rc`
(the Makefile rule does this automatically next time webui.html
gets touched; bumping the .res here lets the next consumer skip
the regen step).
Docs updated:
docs/gateway.md "Known limitations" entry flipped from
"doesn't pass the token yet" to the new
contract.
digitalocean/README.md Web UI section flipped likewise --
operators using `PASCLAW_GATEWAY_TOKEN`
with the DO deployment will see the
prompt on first browser load.
PR #246 wired CheckGatewayAuth into OnCommandGet but missed Indy's TIdCustomHTTPServer hard-coding "Basic" as the only natively-known auth scheme. When a request arrives with `Authorization: Bearer <tok>`, IdCustomHTTPServer.pas line 1454 calls DoParseAuthentication which recognises only Basic; the next line raises EIdHTTPUnsupportedAuthorisationScheme, caught at line 1476 and converted to a 401 with `WWW-Authenticate: Basic realm=""` -- BEFORE OnCommandGet runs. So PasClaw's CheckGatewayAuth never sees the bearer at all and rejects every authenticated request. Symptom: web UI prompts for the token, operator pastes the correct value, every /v1/* call returns 401 forever. `Server: PasClaw/dev` in the response is misleading -- Indy uses the configured ServerSoftware header even when its own exception handler builds the response, hence why this looked like a PasClaw-issued 401 the whole time. Fix: wire OnParseAuthentication to a handler that just sets VHandled := True for every scheme. Indy's built-in Basic path still fires first; everything else (Bearer, and future schemes if anyone adds them) falls through to OnCommandGet where CheckGatewayAuth parses the raw header directly via ExtractBearerToken. Verified against a live gateway: - no auth header -> 401 (unchanged) - wrong bearer -> 401 with WWW-Authenticate: Bearer realm="pasclaw" - right bearer -> 200 (was 401 before this commit) - exempt /v1/health, no auth -> 200 (unchanged) The reason this hid in PR #246: web UI's gwFetch suppresses the Authorization header when no token is stored in localStorage, and the default token-less config doesn't gate /v1/* at all -- so no deployment exercised the bearer path until eli@peacekeeper.com turned auth on for his DO App Platform instance.
gateway: fix PR #246 — accept Bearer scheme via Indy's OnParseAuthentication
Summary
Adds optional bearer-token authentication for the HTTP gateway. Off by default — every pre-token deployment keeps its existing unauthenticated shape. When
gateway.tokenis set (or$PASCLAW_GATEWAY_TOKENenv var), every non-exempt/v1/*route requiresAuthorization: Bearer <token>(or?token=<token>for browser EventSource) and returns 401 otherwise.Mirrors openclaw's
gateway_tokenshape so configs port cleanly.Wiring
Config:
Cfg.Gateway.Token(string, default""). Round-trips throughTConfig.FromJSON/ToJSON.$PASCLAW_GATEWAY_TOKENoverrides atLoadConfigtime, same precedence asOTEL_EXPORTER_OTLP_ENDPOINT.Middleware: new leaf unit
PasClaw.Gateway.Auth(no Indy, no provider stack — testable in isolation). ExportsCheckGatewayAuth(token, method, doc, authHeader, queryToken): Boolean, plus helpersExtractBearerToken,IsExemptRoute,ConstantTimeStringEqual.Hook point:
TGatewayServer.OnCommandGetcallsCheckGatewayAuthat the top — before theFMCPOnlyearly-exit, so a--mcp-portisolation listener honours the same token. On failure: 401 withWWW-Authenticate: Bearer realm="pasclaw"header and an explanatory JSON body.Identity stamping:
LoopCfg.Identitybecomesgateway:authedwhenCfg.Gateway.Tokenis non-empty (the request already passed the bearer check by the timeLoopCfgis built),gateway:anonotherwise. The pre-PR comment atPasClaw.Gateway.Server.pas:1498-1500explicitly flagged "tightening this is a follow-up alongside gateway auth" — this is that follow-up. Operators can now setallow_senders: ["gateway:authed", ...]and have a meaningful allowlist entry.Exempt routes
GET /GET /v1/healthGET /v1/version/webhooks/<channel>x-line-signature, Metax-hub-signature-256) gates these — upstream channels can't supply the gateway bearer.Everything else is gated, including
/v1/logs,/v1/config,/v1/fs/read,/mcp,/v1/mcp/rpc. The whole point.Test plan
make smoke— greenmake test— full aggregate green (21 PASS markers, was 20)make test-gateway-token— 10 cases insrc/tests/gateway_token_tests.pas:Basic) headersBearerheader accepts; case-insensitive scheme; surrounding whitespace tolerated?token=query param accepted as fallbackExtractBearerTokenparses standard / lowercase / whitespace variantsIsExemptRoutenames exactly the four families documented/v1/healthbeatis NOT exempt; only the exact/v1/healthis)pasclaw config showconfirms the token round-trips through ToJSON/FromJSON.Docs updated
docs/gateway.md— new## Authenticationsection with the full contract: how to set the token (config + env), how to call (curl / OpenAI SDK / EventSource), exempt routes, identity stamping, comparison shape (constant-time), known limitations (web UI doesn't yet pass the token, no JWT, no per-tenant tokens, rotation requires restart).docs/configuration.md—gateway.tokenrow in the config table;PASCLAW_GATEWAY_TOKENrow in the env-var table.docs/security.md— new## Gateway bearer tokensection with cross-link to the gateway page.Known limitations
gateway.tokenset will see the chat / stats / logs panels return 401 from JS. Workaround: bind to127.0.0.1+ ssh-tunnel for browser access, or leavegateway.tokenempty and rely on loopback. Native web-UI auth is a follow-up.pasclaw gatewaystartup, not per-request).Scope
~620 LOC total. ~175 LOC for the leaf
Authunit (heavily commented), ~60 LOC ofOnCommandGet+ identity-stamp wiring, ~265 LOC of tests, ~120 LOC of docs.Generated by Claude Code