Skip to content

gateway: optional bearer-token auth for /v1/* routes (off by default)#246

Merged
FMXExpress merged 2 commits into
mainfrom
claude/gateway-token
Jun 13, 2026
Merged

gateway: optional bearer-token auth for /v1/* routes (off by default)#246
FMXExpress merged 2 commits into
mainfrom
claude/gateway-token

Conversation

@FMXExpress

Copy link
Copy Markdown
Owner

Summary

Adds optional bearer-token authentication for the HTTP gateway. Off by default — every pre-token deployment keeps its existing unauthenticated shape. When gateway.token is set (or $PASCLAW_GATEWAY_TOKEN env var), every non-exempt /v1/* route requires Authorization: Bearer <token> (or ?token=<token> for browser EventSource) and returns 401 otherwise.

Mirrors openclaw's gateway_token shape so configs port cleanly.

Wiring

  • Config: Cfg.Gateway.Token (string, default ""). Round-trips through TConfig.FromJSON / ToJSON. $PASCLAW_GATEWAY_TOKEN overrides at LoadConfig time, same precedence as OTEL_EXPORTER_OTLP_ENDPOINT.

  • Middleware: new leaf unit PasClaw.Gateway.Auth (no Indy, no provider stack — testable in isolation). Exports CheckGatewayAuth(token, method, doc, authHeader, queryToken): Boolean, plus helpers ExtractBearerToken, IsExemptRoute, ConstantTimeStringEqual.

  • Hook point: TGatewayServer.OnCommandGet calls CheckGatewayAuth at the top — before the FMCPOnly early-exit, so a --mcp-port isolation listener honours the same token. On failure: 401 with WWW-Authenticate: Bearer realm="pasclaw" header and an explanatory JSON body.

  • Identity stamping: LoopCfg.Identity becomes gateway:authed when Cfg.Gateway.Token is non-empty (the request already passed the bearer check by the time LoopCfg is built), 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.

Exempt routes

Route Why exempt
GET / Web UI HTML. Browsers can't attach a Bearer header on the initial GET; JS inside the page attaches the token to subsequent fetches.
GET /v1/health k8s readiness / liveness probes. A 401 would route the platform's probe into "instance unhealthy" even when the gateway is up.
GET /v1/version Build metadata; frequently scraped.
/webhooks/<channel> Per-channel signature secret (LINE x-line-signature, Meta x-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 — green
  • make test — full aggregate green (21 PASS markers, was 20)
  • New: make test-gateway-token — 10 cases in src/tests/gateway_token_tests.pas:
    • 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)
    • exempt routes pass through; non-exempt 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)
  • Manual: pasclaw config show confirms the token round-trips through ToJSON/FromJSON.

Docs updated

  • docs/gateway.md — new ## Authentication section 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.mdgateway.token row in the config table; PASCLAW_GATEWAY_TOKEN row in the env-var table.
  • docs/security.md — new ## Gateway bearer token section with cross-link to the gateway page.

Known limitations

  • The embedded web UI doesn't yet pass the token through. Operators using the web UI with gateway.token set will see the chat / stats / logs panels return 401 from JS. Workaround: bind to 127.0.0.1 + ssh-tunnel for browser access, or leave gateway.token empty and rely on loopback. Native web-UI auth is a follow-up.
  • Token rotation requires a restart (config is read at pasclaw gateway startup, not per-request).
  • No per-tenant tokens / no JWT — single shared secret. The use case is "PasClaw is the team's shared HTTP agent, every team member has the same secret". For higher-assurance auth: terminate TLS + mTLS at a reverse proxy and run PasClaw loopback-bound.

Scope

~620 LOC total. ~175 LOC for the leaf Auth unit (heavily commented), ~60 LOC of OnCommandGet + identity-stamp wiring, ~265 LOC of tests, ~120 LOC of docs.


Generated by Claude Code

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.

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

Copy link
Copy Markdown

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: 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".

Comment thread src/pkg/config/PasClaw.Config.pas Outdated
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.
@FMXExpress FMXExpress merged commit a08e32e into main Jun 13, 2026
FMXExpress pushed a commit that referenced this pull request Jun 13, 2026
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.
FMXExpress pushed a commit that referenced this pull request Jun 14, 2026
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.
FMXExpress added a commit that referenced this pull request Jun 14, 2026
gateway: fix PR #246 — accept Bearer scheme via Indy's OnParseAuthentication
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants