Skip to content

feat: API key auth, .env config, admin relogin & usage endpoints - #15

Open
dobexx wants to merge 2 commits into
wende:mainfrom
dobexx:feature/auth-env
Open

feat: API key auth, .env config, admin relogin & usage endpoints#15
dobexx wants to merge 2 commits into
wende:mainfrom
dobexx:feature/auth-env

Conversation

@dobexx

@dobexx dobexx commented Aug 16, 2026

Copy link
Copy Markdown

What

Adds the security & operations layer needed to run the proxy on a routable address (server deployments). Everything here is running in production on my EasyPanel host.

API key authentication (src/server/auth.ts)

  • PROXY_API_KEY env → OpenAI-style Authorization: Bearer <key> on all endpoints except /health
  • Timing-safe comparison; generates a random key at startup when unset (never runs unprotected by accident); off disables explicitly for local dev

Configuration

  • Minimal .env loader (no new dependency; existing env vars take precedence), PORT/HOST env vars, .env.example documenting everything

Admin endpoints (src/server/admin.ts, separate PROXY_ADMIN_KEY, falls back to PROXY_API_KEY, 403 when neither set)

  • POST /admin/relogin/start|complete|status|cancel – runs claude auth login inside the deployment, returns the OAuth URL, feeds the authorization code back via stdin. Re-authentication without SSH/container access. Verified live against Claude CLI 2.1.233 (plain auth login is headless-friendly: prints the URL, waits for the code on stdin).
  • GET /admin/usage – structured subscription usage via claude --print /usage (no API cost, 60s cache)

Runtime auth-failure detection

  • CLI surfaces expired OAuth only as stderr text/exit code – matched against known signatures (bounded 4 KB stderr tail)
  • Expired sessions return an actionable in-chat guidance message (streaming + non-streaming) instead of a raw 500
  • Startup logs the real credential state instead of the previous always-OK stub

Note: touches routes.ts/manager.ts – the follow-up PR (vision/effort/cache metrics) is stacked on this one; merging in order avoids conflicts.

Security & operations layer for server deployments:

- API key auth (src/server/auth.ts): OpenAI-style Bearer via PROXY_API_KEY,
  timing-safe comparison, /health stays public. Generates a random key at
  startup when unset (never runs unprotected by accident); 'off' disables
  explicitly for local dev
- Minimal .env loader (no dependency, existing env vars win); PORT/HOST env
- Admin endpoints (src/server/admin.ts) protected by PROXY_ADMIN_KEY
  (falls back to PROXY_API_KEY; 403 when neither is set):
  - POST /admin/relogin/start + /complete + /status + /cancel: run
    'claude auth login' inside the deployment, return the OAuth URL, feed
    the code back via stdin. Re-authentication without SSH/container access.
    Verified live against Claude CLI 2.1.233 (plain 'auth login' is already
    headless-friendly: prints URL, waits for code on stdin).
  - GET /admin/usage: structured subscription usage via 'claude --print
    /usage' (no API cost, 60s cache)
- Runtime auth-failure detection: stderr signature matching (bounded 4 KB
  tail) + exit code; expired sessions return an actionable in-chat guidance
  message instead of a raw 500 (streaming + non-streaming)
- Startup logs credential state honestly instead of always-OK
- .env.example documents all variables

@sourcery-ai sourcery-ai 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.

Hey - I've found 3 issues, and left some high level feedback:

  • In admin.ts the usageCache is read but never updated on successful /admin/usage responses, so the 60s cache is effectively disabled; consider populating usageCache with the parsed data and timestamp after a successful CLI call.
  • The authExpiredResponse helper hardcodes the model as claude-sonnet-4 in non-streaming responses; if the request used a different model, this will be inconsistent with the client’s expectations, so consider passing through the actual model used for the request.
  • The relogin session in admin.ts is stored as a single global session, which makes concurrent admin flows impossible and can lead to races if multiple admins use the endpoints; consider keying sessions per admin or adding explicit safeguards to prevent overlapping flows.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `admin.ts` the `usageCache` is read but never updated on successful `/admin/usage` responses, so the 60s cache is effectively disabled; consider populating `usageCache` with the parsed data and timestamp after a successful CLI call.
- The `authExpiredResponse` helper hardcodes the model as `claude-sonnet-4` in non-streaming responses; if the request used a different model, this will be inconsistent with the client’s expectations, so consider passing through the actual model used for the request.
- The relogin session in `admin.ts` is stored as a single global `session`, which makes concurrent admin flows impossible and can lead to races if multiple admins use the endpoints; consider keying sessions per admin or adding explicit safeguards to prevent overlapping flows.

## Individual Comments

### Comment 1
<location path="src/server/routes.ts" line_range="219-228" />
<code_context>
     subprocess.on("content_delta", (event: ClaudeCliStreamEvent) => {
       const delta = event.event.delta;
-      const text = (delta?.type === "text_delta" && delta.text) || "";
+      let text = (delta?.type === "text_delta" && delta.text) || "";
+      // CLI surfaces auth failures as plain text on stdout in some failure
+      // modes - replace with actionable guidance
</code_context>
<issue_to_address>
**issue (bug_risk):** Auth-expired handling in streaming can send duplicated guidance and inconsistent finish_reason.

Auth errors are now handled both in `content_delta` (overwriting `text` and setting `isComplete = true`) and in `close`, which emits another guidance chunk plus `[DONE]`. This can produce duplicate guidance and mismatched `finish_reason` values. Please handle auth expiry in a single place (preferably the close/error path) and ensure the final chunk uses a consistent `finish_reason` (e.g. `"stop"`).
</issue_to_address>

### Comment 2
<location path="src/server/admin.ts" line_range="247-256" />
<code_context>
+      return;
+    }
+
+    execFile(
+      "claude",
+      ["--print", "/usage"],
+      { timeout: 20_000, maxBuffer: 64 * 1024 },
+      (err, stdout, stderr) => {
+        if (err) {
+          res.status(502).json({
+            error: {
+              message: `Usage query failed: ${stderr?.toString().trim() || err.message}`,
+              type: "server_error",
+              code: null,
+            },
+          });
+          return;
+        }
+        res.json({ ...parseUsage(stdout.toString()), cached: false });
+      }
+    );
</code_context>
<issue_to_address>
**issue (performance):** Usage endpoint declares a cache but never populates it, so caching is effectively disabled.

In `/admin/usage`, you read from `usageCache` but never write to it. After a successful CLI call, assign `usageCache = { at: now, data: parseUsage(stdout.toString()) }` and then respond using that cached data so the 60s cache is actually used instead of calling the CLI on every request.
</issue_to_address>

### Comment 3
<location path="src/server/standalone.ts" line_range="101-105" />
<code_context>
-    console.error(`Error: ${authCheck.error}`);
-    console.error("Please run: claude auth login");
-    process.exit(1);
+  const configDir = process.env.CLAUDE_CONFIG_DIR || `${process.env.HOME}/.claude`;
+  let hasCredentials = false;
+  try {
+    hasCredentials =
+      existsSync(configDir) && readdirSync(configDir).some((f) => f.endsWith(".json"));
+  } catch {
+    hasCredentials = false;
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using HOME to derive the config dir can produce an invalid path when HOME is unset.

This fallback assumes `process.env.HOME` is always set; if it’s missing (e.g., some Windows or container environments), you’ll end up with a path like `"undefined/.claude"` and misleading auth state. Please guard against an undefined HOME (only use it when present, or choose a safer default/behavior).

Suggested implementation:

```typescript
  console.log("Checking authentication...");
  const configDirEnv = process.env.CLAUDE_CONFIG_DIR;
  const homeDir = process.env.HOME;
  const configDir = configDirEnv ?? (homeDir ? `${homeDir}/.claude` : undefined);

  let hasCredentials = false;
  try {
    if (configDir) {
      hasCredentials =
        existsSync(configDir) && readdirSync(configDir).some((f) => f.endsWith(".json"));
    } else {
      hasCredentials = false;
    }
  } catch {
    hasCredentials = false;
  }

```

If you want to be more explicit when HOME is missing and no CLAUDE_CONFIG_DIR is set, you could also add a log line in the `else` branch (where `configDir` is undefined) to explain that credential detection is limited because no config directory could be resolved.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/server/routes.ts
Comment on lines +219 to 228
let text = (delta?.type === "text_delta" && delta.text) || "";
// CLI surfaces auth failures as plain text on stdout in some failure
// modes - replace with actionable guidance
if (text && subprocess.hasAuthError()) {
text = AUTH_EXPIRED_MESSAGE;
isComplete = true;
}
if (text && !res.writableEnded) {
const chunk = {
id: `chatcmpl-${requestId}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Auth-expired handling in streaming can send duplicated guidance and inconsistent finish_reason.

Auth errors are now handled both in content_delta (overwriting text and setting isComplete = true) and in close, which emits another guidance chunk plus [DONE]. This can produce duplicate guidance and mismatched finish_reason values. Please handle auth expiry in a single place (preferably the close/error path) and ensure the final chunk uses a consistent finish_reason (e.g. "stop").

Comment thread src/server/admin.ts
Comment thread src/server/standalone.ts Outdated
Addresses Sourcery review: usageCache was never written; HOME fallback
could produce 'undefined/.claude'; streaming auth guidance could be
emitted twice (now owned solely by error/close handlers).
@dobexx

dobexx commented Aug 16, 2026

Copy link
Copy Markdown
Author

Thanks for the review – all three findings addressed in the latest commits:

  1. Usage cache: now populated after each successful CLI call (usageCache = { at, data }), so the 60s cache branch is actually reachable.
  2. Duplicate auth guidance (streaming): centralized – content_delta now only swallows the raw CLI error text, while the error/close handlers emit the guidance exactly once with a consistent finish_reason.
  3. HOME fallback: guarded – no more undefined/.claude when HOME is unset; an empty config dir is treated as 'no credentials found'.

@sourcery-ai review

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