Skip to content

feat(auth): Phase 1 — warn-only two-domain auth scheme#36

Merged
lis186 merged 8 commits into
mainfrom
feat/auth-phase-1
May 26, 2026
Merged

feat(auth): Phase 1 — warn-only two-domain auth scheme#36
lis186 merged 8 commits into
mainfrom
feat/auth-phase-1

Conversation

@lis186
Copy link
Copy Markdown
Owner

@lis186 lis186 commented May 26, 2026

Summary

Adds the foundation of ccxray's two-domain authentication scheme (upstream vs dashboard), without breaking any existing behavior. All new gates are warn-only — Phase 2 will flip them to enforce.

  • 1.1 HKDF + HMAC cookie verifier module (pure crypto, zero integration)
  • 1.2 Two-domain dispatcher with deprecation headers on legacy credential forms
  • 1.3 /_auth/redeem endpoint + ccxray open browser bootstrap flow
  • 1.4 Launcher X-Ccxray-Auth header injection (Claude via ANTHROPIC_CUSTOM_HEADERS, Codex API-key via model_providers.ccxray), WS upgrade warn-only gate, internal header stripping before upstream

Design docs: reason/260525-0055-ccxray-auth-design/ (candidate-AB.md is the historical record; errata.md is what shipped).

Key properties

  • Zero breakage: every legacy auth path (Authorization: Bearer, ?token=) continues to work with deprecation response headers
  • ChatGPT-OAuth Codex carve-out: skips model_provider override to avoid breaking OAuth login; WS gate classifies as chatgpt-oauth without warning
  • No secrets leak upstream: X-Ccxray-Auth and X-Ccxray-Bootstrap stripped from both HTTP forward and WS upgrade headers before reaching Anthropic/OpenAI
  • Graceful degradation: if K_upstream derivation fails (unreadable CCXRAY_HOME), launcher warns but does not abort

Test plan

  • 589 tests passing (572 baseline + 17 new auth tests)
  • Unit: HKDF derivation, cookie sign/verify, dispatcher classification, launcher header injection, WS auth gate classification
  • E2E: HTTP forward strips internal headers, disk logs contain no auth values, WS upgrade warns without auth, ChatGPT-OAuth carve-out does not warn, WS strips headers before upstream
  • Smoke: isolated CCXRAY_HOME + independent port, verified K_upstream consistency across Claude/Codex launch paths
  • Browser-harness verification of bootstrap flow (GREEN 5/5)
  • All pre-existing tests unchanged and passing

🤖 Generated with Claude Code

lis186 and others added 8 commits May 25, 2026 13:26
Phase 1.1 of the auth migration. Pure-function additions to server/auth.js
that are not yet wired into the request path — the existing authMiddleware
remains in place verbatim and is still the only thing server/index.js
calls. Phase 1.2 will replace that call site with the new verifiers.

New exports:
- deriveSecrets(rootKey): HKDF-SHA256 with empty salt and labels
  ccxray/v1/upstream, ccxray/v1/session-hmac, ccxray/v1/bootstrap
  returning three pairwise-distinct 32-byte keys.
- getRootSecret(): sha256(AUTH_TOKEN) when set, otherwise reads or
  creates ~/.ccxray/local-secret (32 random bytes, mode 0600 in a
  mode-0700 parent dir). CCXRAY_HOME overrides the location for tests.
- signCookie(payload, K_session) / verifyCookie(raw, K_session):
  stateless HMAC-signed cookie. Verifier order is base64url decode →
  HMAC compare (constant-time) → JSON.parse → version + exp check.
  Tampered HMAC, tampered payload, expired exp, wrong key, malformed
  base64url, malformed JSON, and unsupported version all return null.
- compareSecret(a, b): hashes both inputs to fixed-width digests
  before crypto.timingSafeEqual to avoid the throw-on-length-mismatch
  path; final length check guards against hash-prefix collisions.

TDD: 27 new assertions across test/auth-hkdf.test.js (HKDF determinism,
label separation, ephemeral-mode file creation with correct modes,
AUTH_TOKEN-vs-ephemeral switching) and test/auth-cookie.test.js
(roundtrip, tamper detection, expiry, key mismatch, malformed inputs,
constant-time compare). Tests run with CCXRAY_HOME pointing at a
per-run temp dir so they never touch the user's real ~/.ccxray.

Full suite: 533/533 (506 prior + 27 new).

Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md
Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 1.2. Replaces the single authMiddleware() call in server/index.js
with dispatch(req).verify(req, res). Behavior is byte-identical to the
prior authMiddleware for every legacy credential combination — the new
verifiers internally delegate to authMiddleware for the success/failure
decision. The only added behavior is a response header on requests that
used credential forms slated for removal in Phase 2.

New in server/auth.js:
- dispatch(req): pure path classifier. Returns { domain, verify } where
  domain is 'upstream' for any /v1/* path and 'dashboard' for everything
  else (including / and /_events). Splits the query string before
  comparing so /_api/entries?token= still classifies as dashboard.
- verifyDashboard(req, res): delegates to authMiddleware. On success,
  attaches X-Ccxray-Deprecation: token-query when ?token= was used.
  Bearer remains the permanent CLI form on the dashboard (errata §1.1)
  and is NOT deprecated.
- verifyUpstream(req, res): delegates to authMiddleware. On success,
  attaches X-Ccxray-Deprecation: bearer-on-upstream for Bearer auth
  on /v1/* and X-Ccxray-Deprecation: token-query for ?token=.
- whichLegacyMechanism(req): re-derives which credential form
  succeeded so the header reflects what the caller actually sent.

The deprecation header is set via setHeader (not writeHead), so the
downstream handler's writeHead call cannot accidentally drop it and,
critically, the deprecation code cannot influence status code or body —
making this commit incapable of breaking a request that authMiddleware
would have allowed. authMiddleware itself is unchanged.

server/index.js: one-line swap from authMiddleware to dispatch().verify.

Tests:
- 23 new assertions in test/auth-dispatcher.test.js covering path
  classification, byte-identical behavior to authMiddleware, and
  deprecation-header presence by (domain, mechanism) combo.
- The existing 5 cases in test/auth.test.js stay green untouched.
- Full suite: 556/556 (was 533 pre-commit).

Smoke test on port 5589 (separate from the user's running hub):
- no auth → 401
- correct Bearer on /_api/entries → 200, no deprecation header
- wrong Bearer → 401
- correct ?token= on /_api/entries → 200 + token-query header
- correct Bearer on /v1/ping → forwarded (404 from Anthropic) +
  bearer-on-upstream header

Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md §2.1, §5.2
Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 1.3 of the auth migration. Introduces the user-visible bootstrap
path: `ccxray open` mints a one-time URL like http://localhost:5577/#k=…
that the browser redeems via POST /_auth/redeem to receive an HttpOnly
session cookie. Cookie is HMAC-signed with K_session derived in 1.1.

Per errata §1.1: the inline browser script CANNOT probe an HttpOnly
cookie via document.cookie, so it uses GET /_auth/status as a server-
side probe instead.

Endpoints (server/routes/auth.js):
- POST /_auth/redeem  — consumes X-Ccxray-Bootstrap token, mints cookie.
  Single-use, 60s TTL on tokens. Gated by Sec-Fetch-Site: same-origin
  with Origin/Host fallback for older browsers and curl tests.
- GET  /_auth/status  — returns 200 if the request would authenticate
  via cookie / Bearer / ?token= / ephemeral mode, 401 otherwise. Used
  by the inline browser bootstrap to decide whether to show a banner.

Both endpoints run BEFORE dispatch().verify so they're reachable
without prior authentication.

server/hub.js: POST /_api/hub/bootstrap-token mints a token via
auth.mintBootstrapToken(). Restricted to loopback peers (real peer-UID
gate comes in Phase 2.3 with the Unix socket migration).

server/auth.js:
- mintBootstrapToken(): module-level Map<hmacHash, expireMs>, capped at
  8 entries, 60s TTL, HMAC-K_bootstrap stored (never the raw token).
- redeemBootstrap(req, res): verifies CSRF gate + token presence +
  replay protection, mints cookie with HttpOnly; SameSite=Strict;
  Path=/; Max-Age=86400 (24h per errata "最小開發" decision).
- authStatus(req, res): light verify, 200/401 only.
- verifyDashboard: now also accepts a valid cookie. Falls through to
  authMiddleware for legacy Bearer/?token= when cookie is absent or
  invalid — byte-identical Phase 1.2 behavior preserved on the
  non-cookie path.
- Cached HKDF secrets via getSecrets() so we don't re-derive per
  request.

server/index.js:
- New `ccxray open` subcommand: looks up the hub via hub.readHubLock(),
  POSTs to /_api/hub/bootstrap-token, prints the one-time URL, and
  attempts to open it in the system browser (unless BROWSER=none/CI/
  SSH_TTY).
- /_auth/* routes mounted before the auth dispatcher.

public/index.html: 30-line inline bootstrap script in <head>, ahead of
all other scripts:
- If location.hash matches #k=…, scrub via history.replaceState, POST
  to /_auth/redeem, reload on 204.
- Else fetch /_auth/status; on 401, surface a small red banner
  pointing to `ccxray open`.
- With no AUTH_TOKEN set (default), /_auth/status returns 200 → script
  is a no-op. Zero user-visible change for users not in AUTH_TOKEN
  mode.

Tests:
- 16 new assertions in test/auth-bootstrap.test.js: mint+redeem happy
  path, replay rejected, missing token → 401, foreign Origin → 403,
  CSRF-gate fallback (Sec-Fetch absent + Origin match → allow),
  Set-Cookie attributes correct, /_auth/status branches, cookie path
  in verifyDashboard, fall-through to authMiddleware when cookie
  absent/invalid.
- Full suite: 572/572 (was 556 pre-commit).

Smoke test on port 5600 (separate from any running hub):
- GET / returns inline bootstrap script in HTML body
- /_auth/status no AUTH_TOKEN → 200
- /_auth/redeem no token → 401, foreign Origin → 403
- Mint via hub endpoint + redeem succeeds with 204 + Set-Cookie
  containing HttpOnly, SameSite=Strict, Max-Age=86400
- Replay same token → 401 (single-use enforced)
- /_api/entries still 200 in no-AUTH_TOKEN mode
- `ccxray open` prints http://localhost:5601/#k=<token> as designed

Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md §2.1, §3.2, §3.3
Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md §1.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude: injects via ANTHROPIC_CUSTOM_HEADERS env (SDK-documented extension).
Codex API-key mode: injects via model_providers.ccxray={…http_headers…} +
model_provider="ccxray" (spike-verified against Codex v0.133.0-alpha.1).
Codex ChatGPT-OAuth: skips model_provider override to avoid breaking OAuth
login — falls through to legacy openai_base_url path.

If K_upstream derivation fails (unreadable CCXRAY_HOME), warns but does not
abort — Phase 2.1 enforces. No existing behavior changes for any launch path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
classifyUpstreamAuth() classifies each WS upgrade as authed / chatgpt-oauth /
warn. Phase 1 emits console.warn for unauthenticated upgrades but does NOT
block — Phase 2.1 will enforce.

ChatGPT-OAuth carve-out: presence of chatgpt-account-id + JWT-shaped
Authorization skips the warning (these requests cannot carry X-Ccxray-Auth
without breaking Codex OAuth login).

buildWebSocketHeaders now strips X-Ccxray-Auth and X-Ccxray-Bootstrap before
forwarding to upstream — ccxray-internal headers must not leak to OpenAI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
buildForwardHeaders now removes X-Ccxray-Auth and X-Ccxray-Bootstrap before
proxying to Anthropic/OpenAI. These are ccxray-internal auth headers and must
not leak to upstream APIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers:
- HTTP forward strips X-Ccxray-Auth + X-Ccxray-Bootstrap before upstream
- No auth header values leak to disk logs
- WS upgrade without X-Ccxray-Auth warns but still succeeds
- ChatGPT-OAuth carve-out (chatgpt-account-id + JWT) does not warn
- WS upgrade strips ccxray-internal headers before upstream

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
depandabot audit found that the proposed dual-loopback check
(localAddress + remoteAddress) replicates a known-broken pattern
(OpenClaw GHSA-xc7w-v5x6-cc87). Reorder Phase 2 so Unix socket
hub IPC lands first, closing the multi-UID attack surface before
enforcement flips on.

See docs/depandabot/2026-05-26-phase-2-auth-adjustments.md for
the full audit artifact (terminal state: REFRAME).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lis186
Copy link
Copy Markdown
Owner Author

lis186 commented May 26, 2026

Codex Review Gate — Response to Findings

Reviewer: Codex v0.133.0-alpha.1 (gpt-5.5), full-auto mode.

Finding 1 (P1): verifyUpstream doesn't accept X-Ccxray-Auth

Finding 2 (P1): isAuthorized doesn't accept X-Ccxray-Auth

Assessment: not a regression (false positive).

Both findings correctly observe that X-Ccxray-Auth is injected by the launcher but not recognized by the auth gates. However, this is by design for Phase 1:

  • When AUTH_TOKEN is unset (default): authMiddleware/isAuthorized return true unconditionally — all requests pass. The X-Ccxray-Auth header is inert.
  • When AUTH_TOKEN is set: launched agents were rejected before Phase 1 too (the launcher never injected Bearer <AUTH_TOKEN> or ?token=). Phase 1 doesn't change this.

The phased rollout is:

  • Phase 1 (this PR): inject the header → establish the wire format
  • Phase 2: recognize the header → verifyUpstream accepts X-Ccxray-Auth, isAuthorized accepts it for WS upgrades

No existing behavior is broken. The header is being "planted" for Phase 2 enforcement.

Verdict: APPROVE (findings acknowledged as Phase 2 scope, not Phase 1 regression).

@lis186 lis186 merged commit 5931bfa into main May 26, 2026
2 checks passed
@lis186 lis186 deleted the feat/auth-phase-1 branch May 26, 2026 04:12
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.

1 participant