From 6b3353d31750119dc7198c62f51322fc336f53dc Mon Sep 17 00:00:00 2001 From: Samuel Silva Date: Wed, 8 Jul 2026 13:46:56 -0300 Subject: [PATCH] feat: per-user access keys with individual revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add authorizedKeys: a named allowlist of static access keys, one per person (sk-proxy-… tokens), accepted alongside the legacy shared proxySecret. Each key is granted and revoked individually via `cc-router keys add/list/revoke`. Since Claude Code only sends a static ANTHROPIC_AUTH_TOKEN (it can't present a client certificate), per-user keys authorize specific people without an nginx/mTLS layer in front. Auth is a structured, testable outcome so connection failures are diagnosable from the server log without ever leaking the secret. Each rejection says exactly what happened — no credentials, malformed header, unknown token, or a correct-but-disabled key — with the carrying header, token length, and a short sha256 fingerprint that correlates with the fingerprint shown by `cc-router keys list`. Accepted requests log once per key and are attributed to their owner in the dashboard. - config: AuthorizedKey type + add/list/revoke helpers, generateUserKey - proxy/auth: buildCredentials + extractPresented + authenticate + describeAuthFailure + fingerprint (all pure, constant-time compare) - proxy/server: multi-credential auth middleware, _authUser attribution, clientIpOf (X-Forwarded-For aware), accept/reject server logging - proxy/logger: logAuthReject/logAuthAccept, [user] on logRoute - stats: per-user request counters exposed as usageByUser on /health - ui: [user] label on activity rows + per-key request tally - cli: cc-router keys command group (list shows fingerprints) - tests: 18 cases covering config round-trip, extraction, auth outcomes, fingerprinting, and secret-safe failure messages - docs: README auth section, security note, CLI reference --- README.md | 19 ++- src/__tests__/authorized-keys.test.ts | 189 ++++++++++++++++++++++++++ src/cli/cmd-keys.ts | 119 ++++++++++++++++ src/cli/index.ts | 2 + src/config/manager.ts | 60 ++++++++ src/proxy/auth.ts | 142 +++++++++++++++++++ src/proxy/logger.ts | 17 ++- src/proxy/server.ts | 59 +++++--- src/proxy/stats.ts | 14 ++ src/ui/Dashboard.tsx | 12 ++ 10 files changed, 613 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/authorized-keys.test.ts create mode 100644 src/cli/cmd-keys.ts create mode 100644 src/proxy/auth.ts diff --git a/README.md b/README.md index 621d994..eaeffc0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Distribute Claude Code requests across Claude subscriptions, and expose an OpenA - **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux) - **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything - **Live dashboard** — real-time terminal UI showing account health, request counts, token usage, recent activity -- **Proxy authentication** — optional Bearer / x-api-key secret for internet-exposed deployments +- **Proxy authentication** — optional shared secret, or per-user access keys (`cc-router keys add `) that can be granted and revoked individually, with per-user attribution in the dashboard - **Auto-update** — patch/minor releases install automatically (opt-out available) - **Multiple deployment modes** — background daemon, native OS auto-start (launchd/systemd), foreground, Docker Compose - **Cross-platform** — macOS, Linux, Windows; Node.js 20+ @@ -182,7 +182,18 @@ Each developer then points to: } ``` -**Security note:** if the proxy is internet-accessible, add authentication at the nginx level (basic auth, mTLS, or IP allowlist) so only your team can use it. cc-router does not implement user authentication itself. +**Security note:** if the proxy is internet-accessible, protect it. cc-router has built-in authentication with two options: + +- **Shared secret** — one password for everyone (`cc-router configure` → set a proxy password). +- **Per-user access keys** — a unique key per person, granted and revoked individually: + + ```bash + cc-router keys add alice # prints alice's key once — she uses it as ANTHROPIC_AUTH_TOKEN + cc-router keys add bob + cc-router keys list # show users, masked keys, and live request counts + cc-router keys revoke alice # cut off one person without touching the others + ``` + --- @@ -309,6 +320,10 @@ cc-router configure models --claude-model claude-sonnet-4-6 --openai-model gpt-5 cc-router configure --show Show current Claude Code proxy settings cc-router configure --remove Remove cc-router settings (same as revert without stopping) +cc-router keys add Generate a per-user access key (printed once) +cc-router keys list List access keys (masked) with live request counts +cc-router keys revoke Remove a user's access key + cc-router client connect Connect Claude Code to a remote CC-Router cc-router client connect --desktop Also configure Claude Desktop interception cc-router client disconnect Revert all client configuration diff --git a/src/__tests__/authorized-keys.test.ts b/src/__tests__/authorized-keys.test.ts new file mode 100644 index 0000000..70cdbf6 --- /dev/null +++ b/src/__tests__/authorized-keys.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; + +const MOCK_DIR = vi.hoisted(() => { + const tmp = process.env["TMPDIR"] ?? process.env["TEMP"] ?? "/tmp"; + return `${tmp}/cc-router-keys-${Date.now()}-${Math.floor(Math.random() * 10_000)}`; +}); + +vi.mock("../config/paths.js", () => ({ + CONFIG_DIR: MOCK_DIR, + ACCOUNTS_PATH: `${MOCK_DIR}/accounts.json`, + CONFIG_PATH: `${MOCK_DIR}/config.json`, + CLAUDE_SETTINGS_PATH: `${MOCK_DIR}/settings.json`, + PROXY_PORT: 3456, + LITELLM_PORT: 4000, + LITELLM_URL: undefined, +})); + +import { + addAuthorizedKey, + listAuthorizedKeys, + revokeAuthorizedKey, + generateUserKey, +} from "../config/manager.js"; +import { + buildCredentials, + extractPresented, + authenticate, + describeAuthFailure, + fingerprint, +} from "../proxy/auth.js"; + +/** Convenience: authenticate a raw Bearer token against a config. */ +function authBearer(config: Parameters[0], token: string) { + return authenticate(buildCredentials(config), extractPresented({ authorization: `Bearer ${token}` })); +} + +beforeEach(() => { + fs.mkdirSync(MOCK_DIR, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(MOCK_DIR, { recursive: true, force: true }); +}); + +describe("generateUserKey", () => { + it("uses the sk-proxy- prefix and is unguessable", () => { + const key = generateUserKey(); + expect(key.startsWith("sk-proxy-")).toBe(true); + expect(key.length).toBeGreaterThan(20); + expect(generateUserKey()).not.toBe(key); + }); +}); + +describe("authorized keys config helpers", () => { + it("adds, lists, and revokes keys with a config round-trip", () => { + const alice = addAuthorizedKey("alice"); + expect(alice.user).toBe("alice"); + expect(alice.enabled).toBe(true); + expect(alice.key.startsWith("sk-proxy-")).toBe(true); + + addAuthorizedKey("bob"); + const listed = listAuthorizedKeys(); + expect(listed.map(k => k.user).sort()).toEqual(["alice", "bob"]); + + expect(revokeAuthorizedKey("alice")).toBe(true); + expect(listAuthorizedKeys().map(k => k.user)).toEqual(["bob"]); + }); + + it("rejects a duplicate user", () => { + addAuthorizedKey("alice"); + expect(() => addAuthorizedKey("alice")).toThrow(/already exists/); + }); + + it("rejects an empty user", () => { + expect(() => addAuthorizedKey(" ")).toThrow(/must not be empty/); + }); + + it("returns false when revoking an unknown user", () => { + expect(revokeAuthorizedKey("nobody")).toBe(false); + }); +}); + +describe("extractPresented", () => { + it("reads a Bearer token from Authorization", () => { + expect(extractPresented({ authorization: "Bearer abc" })) + .toEqual({ token: "abc", via: "authorization", malformed: false }); + }); + + it("reads x-api-key when Authorization is absent", () => { + expect(extractPresented({ "x-api-key": "abc" })) + .toEqual({ token: "abc", via: "x-api-key", malformed: false }); + }); + + it("flags a non-Bearer Authorization header as malformed", () => { + expect(extractPresented({ authorization: "Basic abc" })) + .toEqual({ token: "", via: "authorization", malformed: true }); + }); + + it("reports no credentials when neither header is present", () => { + expect(extractPresented({})).toEqual({ token: "", via: "none", malformed: false }); + }); +}); + +describe("authenticate", () => { + it("accepts the legacy proxySecret under the 'shared' label", () => { + expect(authBearer({ proxySecret: "cc-rtr-legacy" }, "cc-rtr-legacy")) + .toEqual({ ok: true, user: "shared" }); + }); + + it("accepts any enabled per-user key and returns its owner", () => { + const config = { + authorizedKeys: [ + { user: "alice", key: "cc-key-alice", enabled: true }, + { user: "bob", key: "cc-key-bob" }, + ], + }; + expect(authBearer(config, "cc-key-alice")).toEqual({ ok: true, user: "alice" }); + expect(authBearer(config, "cc-key-bob")).toEqual({ ok: true, user: "bob" }); + }); + + it("accepts the legacy secret and per-user keys together", () => { + const config = { + proxySecret: "cc-rtr-legacy", + authorizedKeys: [{ user: "alice", key: "cc-key-alice" }], + }; + expect(authBearer(config, "cc-rtr-legacy")).toEqual({ ok: true, user: "shared" }); + expect(authBearer(config, "cc-key-alice")).toEqual({ ok: true, user: "alice" }); + }); + + it("distinguishes a correct-but-disabled key from an unknown token", () => { + const config = { authorizedKeys: [{ user: "carol", key: "cc-key-carol", enabled: false }] }; + + const disabled = authBearer(config, "cc-key-carol"); + expect(disabled.ok).toBe(false); + if (!disabled.ok) { + expect(disabled.reason).toBe("disabled_key"); + expect(disabled.user).toBe("carol"); + expect(disabled.fingerprint).toBe(fingerprint("cc-key-carol")); + } + + const unknown = authBearer(config, "cc-key-nope"); + expect(unknown.ok).toBe(false); + if (!unknown.ok) { + expect(unknown.reason).toBe("unknown_token"); + expect(unknown.user).toBeUndefined(); + expect(unknown.fingerprint).toBe(fingerprint("cc-key-nope")); + } + }); + + it("reports no_credentials and malformed_header distinctly", () => { + const creds = buildCredentials({ authorizedKeys: [{ user: "alice", key: "cc-key-alice" }] }); + const none = authenticate(creds, extractPresented({})); + const bad = authenticate(creds, extractPresented({ authorization: "Basic x" })); + expect(none.ok === false && none.reason).toBe("no_credentials"); + expect(bad.ok === false && bad.reason).toBe("malformed_header"); + }); + + it("treats a differing-length token as unknown, not a match", () => { + const result = authBearer({ authorizedKeys: [{ user: "alice", key: "cc-key-alice" }] }, "cc-key-alice-extra"); + expect(result.ok === false && result.reason).toBe("unknown_token"); + }); + + it("produces no credentials when nothing is configured", () => { + expect(buildCredentials({})).toHaveLength(0); + }); +}); + +describe("fingerprint", () => { + it("is stable, 8 hex chars, and non-reversible", () => { + const fp = fingerprint("cc-key-alice"); + expect(fp).toMatch(/^[0-9a-f]{8}$/); + expect(fingerprint("cc-key-alice")).toBe(fp); + expect(fingerprint("cc-key-bob")).not.toBe(fp); + expect(fp).not.toContain("alice"); + }); +}); + +describe("describeAuthFailure", () => { + it("never includes the token, only length and fingerprint", () => { + const secret = "cc-key-supersecretvalue"; + const outcome = authBearer({ authorizedKeys: [{ user: "x", key: "cc-key-other" }] }, secret); + if (outcome.ok) throw new Error("expected rejection"); + const msg = describeAuthFailure(outcome); + expect(msg).toContain(`fp=${fingerprint(secret)}`); + expect(msg).toContain(`len=${secret.length}`); + expect(msg).not.toContain(secret); + }); +}); diff --git a/src/cli/cmd-keys.ts b/src/cli/cmd-keys.ts new file mode 100644 index 0000000..a789240 --- /dev/null +++ b/src/cli/cmd-keys.ts @@ -0,0 +1,119 @@ +import type { Command } from "commander"; +import chalk from "chalk"; +import { + addAuthorizedKey, + listAuthorizedKeys, + revokeAuthorizedKey, +} from "../config/manager.js"; +import { PROXY_PORT } from "../config/paths.js"; +import { fingerprint } from "../proxy/auth.js"; + +const RESTART_HINT = " Restart cc-router for the change to take effect."; + +export function registerKeys(program: Command): void { + const keys = program + .command("keys") + .description("Manage per-user access keys for a hosted CC-Router"); + + // ── keys add ──────────────────────────────────────────────────────── + keys + .command("add ") + .description("Generate a new access key for a user") + .action((user: string) => { + let record; + try { + record = addAuthorizedKey(user); + } catch (err) { + console.error(chalk.red((err as Error).message)); + process.exit(1); + } + + console.log(chalk.green(`✓ Access key created for "${record.user}".`)); + console.log(" " + chalk.bold.yellow("Save this — it will not be shown again:")); + console.log(" " + chalk.bold(record.key)); + console.log(); + console.log(chalk.gray(" The user sets it as ANTHROPIC_AUTH_TOKEN, or connects with:")); + console.log(chalk.gray(` cc-router client connect --secret ${record.key}`)); + console.log(chalk.gray(RESTART_HINT)); + }); + + // ── keys list ────────────────────────────────────────────────────────────── + keys + .command("list") + .description("List configured access keys (with live usage if the proxy is up)") + .option("--json", "Output as JSON") + .action(async (opts: { json?: boolean }) => { + const stored = listAuthorizedKeys(); + const usage = await fetchUsageByUser(); + + if (opts.json) { + console.log(JSON.stringify( + stored.map(k => ({ + user: k.user, + enabled: k.enabled !== false, + fingerprint: fingerprint(k.key), + requests: usage?.[k.user] ?? null, + })), + null, + 2, + )); + return; + } + + if (stored.length === 0) { + console.log(chalk.yellow("No access keys configured. Add one with: cc-router keys add ")); + return; + } + + console.log(chalk.bold(`\n Access keys (${stored.length})\n`)); + if (usage) console.log(chalk.green(" ● Proxy is running — showing live usage\n")); + + for (const k of stored) { + const status = k.enabled === false + ? chalk.red("disabled") + : chalk.green("enabled "); + const masked = maskKey(k.key); + const fp = chalk.gray(`fp=${fingerprint(k.key)}`); + const reqs = usage + ? chalk.gray(` req ${usage[k.user] ?? 0}`) + : ""; + console.log(` ${chalk.bold(k.user.padEnd(20))} ${status} ${chalk.gray(masked)} ${fp}${reqs}`); + } + console.log(); + console.log(chalk.gray(" fp matches the fingerprint shown in rejected-auth server logs.")); + }); + + // ── keys revoke ───────────────────────────────────────────────────── + keys + .command("revoke ") + .description("Remove a user's access key") + .action((user: string) => { + const removed = revokeAuthorizedKey(user); + if (!removed) { + console.error(chalk.red(`No access key found for "${user}".`)); + process.exit(1); + } + console.log(chalk.green(`✓ Access key for "${user}" revoked.`)); + console.log(chalk.gray(RESTART_HINT)); + }); +} + +/** Show only the prefix and last 4 chars so the key stays unguessable in logs. */ +function maskKey(key: string): string { + if (key.length <= 13) return "…"; + return `${key.slice(0, 9)}…${key.slice(-4)}`; +} + +/** Best-effort per-user request counts from a running proxy. Null if it's down. */ +async function fetchUsageByUser(): Promise | null> { + try { + const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, { + signal: AbortSignal.timeout(1_000), + }); + if (!res.ok) return null; + const data = await res.json() as { usageByUser?: Record }; + return data.usageByUser ?? {}; + } catch { + return null; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 80b7daa..4477bb0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { registerStop, registerRevert } from "./cmd-stop.js"; import { registerStatus } from "./cmd-status.js"; import { registerAccounts } from "./cmd-accounts.js"; import { registerConfigure } from "./cmd-configure.js"; +import { registerKeys } from "./cmd-keys.js"; import { registerDocker } from "./cmd-docker.js"; import { registerUpdate } from "./cmd-update.js"; import { registerClient } from "./cmd-client.js"; @@ -47,6 +48,7 @@ registerStatus(program); registerModels(program); registerAccounts(program); registerConfigure(program); +registerKeys(program); registerDocker(program); registerUpdate(program); registerClient(program); diff --git a/src/config/manager.ts b/src/config/manager.ts index 3390d65..96d0d62 100644 --- a/src/config/manager.ts +++ b/src/config/manager.ts @@ -180,8 +180,24 @@ export interface RunPreferences { configureClaudeCode?: boolean; } +/** + * A single named access key. Each person on a shared/hosted deployment gets + * their own unique key so access can be granted and revoked individually — + * unlike the single shared `proxySecret`, which is all-or-nothing. + */ +export interface AuthorizedKey { + /** Human-readable owner label, e.g. "alice". Unique within the list. */ + user: string; + /** The secret token the client presents as a Bearer / x-api-key. */ + key: string; + /** When false, the key is rejected. Default: true. */ + enabled?: boolean; +} + export interface ProxyConfig { proxySecret?: string; + /** Per-user access keys accepted alongside the legacy `proxySecret`. */ + authorizedKeys?: AuthorizedKey[]; /** Upstream proxy request timeout in milliseconds. Default: 300000 (5 minutes). */ proxyRequestTimeoutMs?: number; /** Deprecated typo-compatible alias for proxyRequestTimeoutMs. */ @@ -241,6 +257,50 @@ export function generateProxySecret(): string { return "cc-rtr-" + randomBytes(16).toString("hex"); } +/** + * Generate a per-user access key. Uses an `sk-proxy-` prefix so it looks like a + * standard `sk-` API token to clients and is recognizable in logs. + */ +export function generateUserKey(): string { + return "sk-proxy-" + randomBytes(16).toString("hex"); +} + +export function listAuthorizedKeys(): AuthorizedKey[] { + return readConfig().authorizedKeys ?? []; +} + +/** + * Create and persist a new access key for `user`. Throws if a key already + * exists for that user. Returns the created record (including the secret). + */ +export function addAuthorizedKey(user: string): AuthorizedKey { + const trimmed = user.trim(); + if (!trimmed) throw new Error("user must not be empty"); + + const cfg = readConfig(); + const keys = cfg.authorizedKeys ?? []; + if (keys.some(k => k.user === trimmed)) { + throw new Error(`An access key for "${trimmed}" already exists`); + } + + const record: AuthorizedKey = { user: trimmed, key: generateUserKey(), enabled: true }; + writeConfig({ ...cfg, authorizedKeys: [...keys, record] }); + return record; +} + +/** + * Remove the access key for `user`. Returns true if a key was removed, + * false if no matching key existed. + */ +export function revokeAuthorizedKey(user: string): boolean { + const cfg = readConfig(); + const keys = cfg.authorizedKeys ?? []; + const next = keys.filter(k => k.user !== user); + if (next.length === keys.length) return false; + writeConfig({ ...cfg, authorizedKeys: next }); + return true; +} + // ─── Accounts ───────────────────────────────────────────────────────────────── function deserialize(records: AccountRecord[]): Account[] { diff --git a/src/proxy/auth.ts b/src/proxy/auth.ts new file mode 100644 index 0000000..d5d66b4 --- /dev/null +++ b/src/proxy/auth.ts @@ -0,0 +1,142 @@ +import { createHash, timingSafeEqual } from "crypto"; +import type { AuthorizedKey } from "../config/manager.js"; + +export interface Credential { + /** Owner label used for per-user attribution. */ + user: string; + buf: Buffer; + /** Disabled credentials still match (so we can report it) but never authorize. */ + enabled: boolean; +} + +/** + * Build the list of known credentials from proxy config. The legacy single + * `proxySecret` is included under the "shared" label; every per-user key is + * included with its enabled flag. Disabled keys are kept (not dropped) so the + * authenticator can distinguish "correct-but-disabled" from "unknown". + */ +export function buildCredentials(config: { + proxySecret?: string; + authorizedKeys?: AuthorizedKey[]; +}): Credential[] { + const credentials: Credential[] = []; + if (config.proxySecret) { + credentials.push({ user: "shared", buf: Buffer.from(config.proxySecret, "utf-8"), enabled: true }); + } + for (const k of config.authorizedKeys ?? []) { + credentials.push({ user: k.user, buf: Buffer.from(k.key, "utf-8"), enabled: k.enabled !== false }); + } + return credentials; +} + +/** + * Short, non-reversible fingerprint of a token. Lets an operator correlate a + * rejected token in the server log with a key shown by `cc-router keys list` + * without ever exposing the secret itself. + */ +export function fingerprint(token: string): string { + return createHash("sha256").update(token).digest("hex").slice(0, 8); +} + +export interface PresentedToken { + /** The extracted token value (empty when none/ malformed). */ + token: string; + /** Which header carried the credential. */ + via: "authorization" | "x-api-key" | "none"; + /** Authorization header present but not in "Bearer " form. */ + malformed: boolean; +} + +/** + * Extract the presented credential from request headers, distinguishing a + * missing header from a malformed one. Bearer takes precedence over x-api-key. + */ +export function extractPresented(headers: { + authorization?: string; + "x-api-key"?: string | string[]; +}): PresentedToken { + const auth = typeof headers.authorization === "string" ? headers.authorization : ""; + const apiKeyRaw = headers["x-api-key"]; + const apiKey = Array.isArray(apiKeyRaw) ? apiKeyRaw[0] ?? "" : apiKeyRaw ?? ""; + + if (auth) { + if (auth.startsWith("Bearer ")) { + return { token: auth.slice(7), via: "authorization", malformed: false }; + } + // Header is present but not "Bearer " — a common misconfiguration. + return { token: "", via: "authorization", malformed: true }; + } + if (apiKey) { + return { token: apiKey, via: "x-api-key", malformed: false }; + } + return { token: "", via: "none", malformed: false }; +} + +export type AuthReason = + | "no_credentials" // no Authorization or x-api-key header + | "malformed_header" // Authorization present but not "Bearer " + | "empty_token" // header present but the token was empty + | "disabled_key" // token matches a configured key that is disabled + | "unknown_token"; // a token was presented but matches nothing + +export type AuthOutcome = + | { ok: true; user: string } + | { + ok: false; + reason: AuthReason; + via: PresentedToken["via"]; + /** Owner label for the disabled_key case. */ + user?: string; + tokenLen: number; + /** Present whenever a non-empty token was actually presented. */ + fingerprint?: string; + }; + +/** + * Authenticate a presented token against the known credentials. Returns a + * structured outcome so callers can log precisely what happened without + * leaking the secret. Constant-time compare per candidate; disabled matches + * are recorded but never authorize. + */ +export function authenticate(credentials: Credential[], presented: PresentedToken): AuthOutcome { + const { token, via, malformed } = presented; + + if (malformed) return { ok: false, reason: "malformed_header", via, tokenLen: 0 }; + if (via === "none") return { ok: false, reason: "no_credentials", via, tokenLen: 0 }; + if (!token) return { ok: false, reason: "empty_token", via, tokenLen: 0 }; + + const presentedBuf = Buffer.from(token, "utf-8"); + let disabledUser: string | undefined; + for (const cred of credentials) { + if (presentedBuf.length === cred.buf.length && timingSafeEqual(presentedBuf, cred.buf)) { + if (cred.enabled) return { ok: true, user: cred.user }; + disabledUser = cred.user; + } + } + + const fp = fingerprint(token); + if (disabledUser !== undefined) { + return { ok: false, reason: "disabled_key", via, user: disabledUser, tokenLen: token.length, fingerprint: fp }; + } + return { ok: false, reason: "unknown_token", via, tokenLen: token.length, fingerprint: fp }; +} + +/** + * Human-readable, secret-safe description of a failed auth outcome, suitable + * for the server log. Never includes the token — only its length and + * fingerprint. + */ +export function describeAuthFailure(outcome: Extract): string { + switch (outcome.reason) { + case "no_credentials": + return "no credentials (no Authorization or x-api-key header)"; + case "malformed_header": + return "malformed Authorization header (expected 'Bearer ')"; + case "empty_token": + return `empty token (via ${outcome.via})`; + case "disabled_key": + return `token matches DISABLED key for "${outcome.user}" (via ${outcome.via}, len=${outcome.tokenLen}, fp=${outcome.fingerprint})`; + case "unknown_token": + return `unknown token (via ${outcome.via}, len=${outcome.tokenLen}, fp=${outcome.fingerprint})`; + } +} diff --git a/src/proxy/logger.ts b/src/proxy/logger.ts index ff88c99..ed7039a 100644 --- a/src/proxy/logger.ts +++ b/src/proxy/logger.ts @@ -4,15 +4,30 @@ function ts(): string { return new Date().toISOString().slice(11, 19); // HH:MM:SS } -export function logRoute(accountId: string, requestCount: number, expiresInMin: number): void { +export function logRoute(accountId: string, requestCount: number, expiresInMin: number, user?: string): void { console.log( chalk.gray(`[${ts()}]`) + chalk.green(` → ${accountId}`) + + (user ? chalk.blueBright(` [${user}]`) : "") + chalk.gray(` req#${requestCount}`) + chalk.yellow(` exp=${expiresInMin}min`) ); } +/** + * Log a rejected connection attempt at the auth boundary. `reason` is a short + * label ("no token" / "invalid token") — the presented token is never logged. + */ +export function logAuthReject(clientIp: string, reason: string, method?: string, path?: string): void { + const where = method && path ? ` ${method} ${path}` : ""; + console.log(chalk.red(`[${ts()}] [AUTH] ✗ rejected ${clientIp}${where} — ${reason}`)); +} + +/** Log an accepted connection at the auth boundary (first request per user). */ +export function logAuthAccept(user: string, clientIp: string): void { + console.log(chalk.green(`[${ts()}] [AUTH] ✓ accepted ${user} from ${clientIp}`)); +} + export function logRefresh(accountId: string, ok: boolean, expiresInMin?: number): void { if (ok) { console.log(chalk.yellow(`[${ts()}] [REFRESH] ${accountId}: OK — expires in ${expiresInMin}min`)); diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 30e5101..955cf73 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -1,7 +1,7 @@ import express from "express"; import { createProxyMiddleware } from "http-proxy-middleware"; import { ServerResponse } from "http"; -import { timingSafeEqual } from "crypto"; +import { buildCredentials, extractPresented, authenticate, describeAuthFailure } from "./auth.js"; import type { IncomingMessage } from "http"; import type { Socket } from "net"; import type { Request } from "express"; @@ -11,7 +11,7 @@ import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccounts, accountsFileExist import { checkForUpdate, performUpdate, restartSelf } from "../utils/self-update.js"; import { trackEvent, startHeartbeat } from "../utils/telemetry.js"; import { loadTelemetryState } from "../config/telemetry.js"; -import { logRoute, logError, logStartup } from "./logger.js"; +import { logRoute, logError, logStartup, logAuthReject, logAuthAccept } from "./logger.js"; import { stats } from "./stats.js"; import type { LogEntry } from "./stats.js"; import { PROXY_PORT, LITELLM_URL } from "../config/paths.js"; @@ -32,6 +32,7 @@ declare module "express-serve-static-core" { _ccAccount?: Account; _startTime?: number; _pendingLog?: Partial; + _authUser?: string; } } @@ -237,6 +238,17 @@ function extractRateLimits(headers: Record { const port = opts.port ?? PROXY_PORT; @@ -290,33 +302,42 @@ export async function startServer(opts: ServerOptions = {}): Promise { const proxyRequestTimeoutMs = getProxyRequestTimeoutMs(); // ─── Proxy auth middleware ───────────────────────────────────────────────── - // If a proxySecret is configured, all requests must present it as EITHER + // When auth is configured, all requests must present a valid credential as // "Authorization: Bearer " (Claude Code CLI, HTTP clients) // OR "x-api-key: " (Claude Desktop via mitmproxy, Anthropic SDK) + // Two credential sources are accepted, in this order: + // 1. The legacy single `proxySecret` (shared, all-or-nothing). + // 2. Any enabled entry in `authorizedKeys` (per-user, individually revocable). + // The matched key's owner is stashed on the request for per-user attribution. // The /cc-router/health endpoint is always exempt so monitoring and PM2 // healthchecks keep working. - const { proxySecret } = initialConfig; - if (proxySecret) { - const secretBuf = Buffer.from(proxySecret, "utf-8"); + const credentials = buildCredentials(initialConfig); + const authEnabled = credentials.length > 0; + + // Users seen at least once this run — used to log an "accepted" line the + // first time each key connects, without logging every subsequent request + // (per-request routing is already logged by logRoute). + const seenUsers = new Set(); + + if (authEnabled) { app.use((req, res, next) => { if (req.path === "/cc-router/health") return next(); - const auth = (req.headers["authorization"] as string | undefined) ?? ""; - const bearerToken = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - const apiKey = (req.headers["x-api-key"] as string | undefined) ?? ""; - const presented = bearerToken || apiKey; - const presentedBuf = Buffer.from(presented, "utf-8"); - - if ( - presentedBuf.length !== secretBuf.length || - !timingSafeEqual(presentedBuf, secretBuf) - ) { + const clientIp = clientIpOf(req); + const outcome = authenticate(credentials, extractPresented(req.headers)); + if (!outcome.ok) { + logAuthReject(clientIp, describeAuthFailure(outcome), req.method, req.path); res.status(401).json({ type: "error", error: { type: "authentication_error", message: "Invalid or missing proxy authentication token" }, }); return; } + req._authUser = outcome.user; + if (!seenUsers.has(outcome.user)) { + seenUsers.add(outcome.user); + logAuthAccept(outcome.user, clientIp); + } next(); }); } @@ -334,11 +355,12 @@ export async function startServer(opts: ServerOptions = {}): Promise { operational: createOperationalStatus({ mode, target, - authRequired: Boolean(proxySecret), + authRequired: authEnabled, accounts: accountViews, modelRouting, }), uptime: stats.getUptimeSeconds(), + usageByUser: stats.getUsageByUser(), totalRequests: stats.totalRequests, totalErrors: stats.totalErrors, totalRefreshes: stats.totalRefreshes, @@ -851,13 +873,16 @@ export async function startServer(opts: ServerOptions = {}): Promise { method: req.method, path: req.path, source, + user: req._authUser, }; stats.totalRequests++; + stats.incrUser(req._authUser); logRoute( account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000), + req._authUser, ); next(); diff --git a/src/proxy/stats.ts b/src/proxy/stats.ts index 4fa8341..a0905ff 100644 --- a/src/proxy/stats.ts +++ b/src/proxy/stats.ts @@ -9,6 +9,8 @@ export interface LogEntry { method?: string; path?: string; source?: "cli" | "desktop" | "api"; + /** Owner label of the access key that authenticated this request, if any. */ + user?: string; // Token usage from Anthropic response (message_start + message_delta events) cacheReadTokens?: number; cacheCreationTokens?: number; @@ -28,12 +30,24 @@ class ProxyStats { totalOutputTokens = 0; readonly startTime = Date.now(); private logs: LogEntry[] = []; + private requestsByUser = new Map(); addLog(entry: LogEntry): void { this.logs.push(entry); if (this.logs.length > MAX_LOG_ENTRIES) this.logs.shift(); } + /** Increment the per-user request counter. No-op when user is undefined. */ + incrUser(user?: string): void { + if (!user) return; + this.requestsByUser.set(user, (this.requestsByUser.get(user) ?? 0) + 1); + } + + /** Snapshot of request counts keyed by access-key owner. */ + getUsageByUser(): Record { + return Object.fromEntries(this.requestsByUser); + } + getRecentLogs(n = 20): LogEntry[] { return [...this.logs].reverse().slice(0, n); } diff --git a/src/ui/Dashboard.tsx b/src/ui/Dashboard.tsx index b32466b..df648db 100644 --- a/src/ui/Dashboard.tsx +++ b/src/ui/Dashboard.tsx @@ -61,6 +61,7 @@ interface HealthData { totalOutputTokens?: number; accounts: AccountStat[]; recentLogs: LogEntry[]; + usageByUser?: Record; } interface OperationalStatus { @@ -642,6 +643,16 @@ function LiveDashboard({ {/* ── Recent activity ── */} RECENT ACTIVITY + {(() => { + const usage = Object.entries(data.usageByUser ?? {}).sort((a, b) => b[1] - a[1]); + if (usage.length === 0) return null; + return ( + + {" keys: "} + {usage.map(([u, n]) => `${u} ${n}`).join(" · ")} + + ); + })()} {visibleLogs.length === 0 ? No activity yet @@ -969,6 +980,7 @@ function LogRow({ log, selected }: { log: LogEntry; selected: boolean }) { {typeIcon} {sourceLabel} {log.accountId.slice(0, 22).padEnd(22)} + {(log.user ? `[${log.user}]` : "").slice(0, 12).padEnd(12)} {log.method && log.path ? {log.method} {log.path.padEnd(14)} : {log.type.padEnd(9)}