feat(auth): Phase 1 — warn-only two-domain auth scheme#36
Merged
Conversation
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>
Owner
Author
Codex Review Gate — Response to FindingsReviewer: Codex v0.133.0-alpha.1 (gpt-5.5), full-auto mode. Finding 1 (P1):
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
/_auth/redeemendpoint +ccxray openbrowser bootstrap flowX-Ccxray-Authheader injection (Claude viaANTHROPIC_CUSTOM_HEADERS, Codex API-key viamodel_providers.ccxray), WS upgrade warn-only gate, internal header stripping before upstreamDesign docs:
reason/260525-0055-ccxray-auth-design/(candidate-AB.md is the historical record; errata.md is what shipped).Key properties
Authorization: Bearer,?token=) continues to work with deprecation response headersmodel_provideroverride to avoid breaking OAuth login; WS gate classifies aschatgpt-oauthwithout warningX-Ccxray-AuthandX-Ccxray-Bootstrapstripped from both HTTP forward and WS upgrade headers before reaching Anthropic/OpenAIK_upstreamderivation fails (unreadableCCXRAY_HOME), launcher warns but does not abortTest plan
CCXRAY_HOME+ independent port, verified K_upstream consistency across Claude/Codex launch paths🤖 Generated with Claude Code