Skip to content

feat(chat): server-side BYO API keys with cookie auth #516

@jonathaneoliver

Description

@jonathaneoliver

Why

Today the dashboard chat panel keeps API keys in browser localStorage and POSTs them to the forwarder on every chat request. After the per-profile localStorage fix (PR for issue #497-ish followup), the operator no longer has to re-paste a key when switching providers — but the keys still live plaintext on disk in browser storage, scoped only to the dashboard's origin. Long-term, taking the keys out of the browser entirely is the more defensible posture:

  • XSS on the dashboard's origin = key exfil
  • Anyone with local file access can read ~/Library/Application Support/<browser>/.../Local Storage/...
  • Keys can leak to browser sync extensions / sync engines
  • The dashboard chat is the only "BYO key in browser" surface in our stack; everywhere else (Claude Code, harness CLI, scripts) reads from $ANTHROPIC_API_KEY / $HF_TOKEN / etc., which can be backed by macOS Keychain. This issue brings the dashboard chat in line.

What

Move API keys out of localStorage and into a server-side store keyed by an HTTP session cookie. Browser sends Cookie: chat_session=<id> on every chat request; forwarder looks up the appropriate per-profile key for that session.

Architecture

Browser                          Forwarder (Docker on test-dev)
─────────                        ───────────────────────────────
ChatSettings UI                  POST /api/v2/chat/keys
  paste key once    ───────►     body: {profile, api_key}
                                 stores in server-side memory keyed
                                 by (cookie_session_id, profile);
                                 returns Set-Cookie if first time.

ChatPanel (every turn)           POST /api/v2/chat
  Cookie: session=xyz  ──►       looks up api_key for
  body: {profile, msgs}          (cookie_session_id, profile);
                                 forwards to upstream as before.
                                 No api_key field in browser →
                                 forwarder request bodies.

Storage choice (the biggest trade-off)

Storage Persistence Security When to use
a. In-memory only (sync.Map keyed by session_id) Lost on forwarder restart — re-paste once per restart Best: keys never touch disk The "I trust the network, just don't want it in the browser" case
b. Plain file on bind-mounted dir (/claude/keys/<session>.json) Survives restarts Equivalent to keys in ~/.aws/credentials If forwarder restarts annoy more than disk plaintext does
c. Encrypted file with operator master password Survives restarts; user types master password after forwarder restart Better than plain file; worse UX If actual untrusted local access matters
d. macOS Keychain via SSH bridge Survives anything Best Over-engineered for one user on one box

Recommended: Option (a) in-memory only for v1. If RAM-restart-friction becomes annoying, upgrade to (b) — same code, swap the backing store.

Auth (the second-biggest trade-off)

Today /api/v2/chat is open to anyone on the LAN. Server-side keys make that worse — anyone reaching the forwarder can USE your Anthropic credit.

Auth Effort Security
i. None (single-user trust) 0 Anyone on the LAN can spend your keys. Adequate for localhost-only dev; not for shared LAN.
ii. Simple HMAC-signed cookie issued on key-paste 2-3 hours A leaked cookie = key access. Cookie scoped to the session; new cookie per key-paste. Reasonable for single-operator dev.
iii. Extend the existing INFINITE_STREAM_AUTH_HTPASSWD to cover /api/v2/chat 1 hour HTTP Basic auth gate. Operator's Basic password becomes the key to the keys. Already used for /dashboard/, /analytics/api/, /grafana/.

Recommended: Option (iii) because it reuses an auth mechanism already in place for dashboard / Grafana / analytics paths. Today, chat endpoints are explicitly excluded from the htpasswd gate (per CLAUDE.md: "player-app endpoints stay public"). We'd add /api/v2/chat/keys (and /api/v2/chat itself, when server-side keys are configured) to the gated set.

Server-side endpoints

  • POST /api/v2/chat/keys — body { profile, api_key }. Stores in server-side memory keyed by (session_id, profile). Issues a session cookie on first request.
  • GET /api/v2/chat/keys — returns { profiles_with_keys: ["anthropic-claude", "huggingface"] }. Never returns key values.
  • DELETE /api/v2/chat/keys/{profile} — revoke one profile's stored key for this session.
  • handleChat lookup: if request body has api_key, use it (backward compat); else look up (session_id, profile) from cookie + body; else 400 "no key configured for this profile".

Browser-side changes

  • useChatSettings: drop the apiKey field from the public ref. keys map goes away entirely (server holds them now).
  • ChatSettings.vue: pasting a key calls POST /api/v2/chat/keys immediately (not just localStorage write); shows ✓/× per profile via GET /api/v2/chat/keys.
  • useChat: stop sending api_key in the request body — forwarder looks it up from the cookie.
  • A new "Configured providers" widget in chat settings shows which profiles have stored keys + a "Revoke" button per profile.

What this issue does NOT ship

  • macOS Keychain integration for the forwarder. The forwarder runs in Docker on a different machine; Keychain isn't reachable. If we ever want Keychain-backed storage, it'd be a separate "Mac-side key helper" daemon that the forwarder calls (option d above — significant work).
  • Multi-user identity — sessions are anonymous (cookie ≠ user); HTTP Basic gives a single shared password gate.
  • Rotation / expiry policy — keys live until explicit revocation or forwarder restart. Worth revisiting if usage shows mistakes.

Acceptance

  • POST /api/v2/chat/keys accepts and stores a key in server memory; subsequent POST /api/v2/chat requests look it up via cookie.
  • localStorage for the chat panel no longer contains an api_key field after migration; the keys map is removed.
  • Forwarder restart → all stored keys are gone; user re-pastes once per profile they want to use.
  • HTTP Basic gate on /api/v2/chat/keys (and optionally on /api/v2/chat itself) when INFINITE_STREAM_AUTH_HTPASSWD is configured.
  • Backwards-compat: requests that still include api_key in the body continue to work for one release cycle (so old browser tabs don't break mid-session).

Out-of-scope follow-ups (if anyone asks)

  • True multi-user auth (OAuth / SSO) — different design, different problem
  • Encrypted-at-rest storage option (encrypted file with master password) — straightforward upgrade from in-memory if needed
  • Keychain-via-helper daemon for storing keys on the Mac and serving to remote forwarders — only worth it if "forwarder restart loses all my keys" becomes annoying enough

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions