Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user>`) 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+
Expand Down Expand Up @@ -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
```


---

Expand Down Expand Up @@ -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 <user> Generate a per-user access key (printed once)
cc-router keys list List access keys (masked) with live request counts
cc-router keys revoke <user> Remove a user's access key

cc-router client connect <url> 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
Expand Down
189 changes: 189 additions & 0 deletions src/__tests__/authorized-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildCredentials>[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);
});
});
119 changes: 119 additions & 0 deletions src/cli/cmd-keys.ts
Original file line number Diff line number Diff line change
@@ -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 <user> ────────────────────────────────────────────────────────
keys
.command("add <user>")
.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 <url> --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 <user>"));
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 <user> ─────────────────────────────────────────────────────
keys
.command("revoke <user>")
.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<Record<string, number> | 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<string, number> };
return data.usageByUser ?? {};
} catch {
return null;
}
}
2 changes: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,6 +48,7 @@ registerStatus(program);
registerModels(program);
registerAccounts(program);
registerConfigure(program);
registerKeys(program);
registerDocker(program);
registerUpdate(program);
registerClient(program);
Expand Down
Loading