mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 支持自托管的 remote-control-server (#214)
* feat: 支持自托管的 remote-control-server (#214) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
12
packages/remote-control-server/src/auth/api-key.ts
Normal file
12
packages/remote-control-server/src/auth/api-key.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { config } from "../config";
|
||||
|
||||
/** Validate a raw API key token string */
|
||||
export function validateApiKey(token: string | undefined): boolean {
|
||||
if (!token) return false;
|
||||
return config.apiKeys.includes(token);
|
||||
}
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
92
packages/remote-control-server/src/auth/jwt.ts
Normal file
92
packages/remote-control-server/src/auth/jwt.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Lightweight JWT implementation using HMAC-SHA256.
|
||||
* No external dependencies — uses Node.js crypto.
|
||||
*
|
||||
* Token format: base64url(header).base64url(payload).base64url(signature)
|
||||
* Used for V2 worker authentication (session ingress / SSE / CCR).
|
||||
*/
|
||||
|
||||
interface JwtPayload {
|
||||
session_id: string;
|
||||
role: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function base64url(data: string | Buffer): string {
|
||||
return Buffer.from(data as unknown as ArrayLike<number>)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Buffer.from(padded, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
function getSigningKey(): string {
|
||||
const key = process.env.RCS_API_KEYS?.split(",").filter(Boolean)[0];
|
||||
if (!key) throw new Error("No API key configured for JWT signing");
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Generate a JWT for worker authentication. */
|
||||
export function generateWorkerJwt(
|
||||
sessionId: string,
|
||||
expiresInSeconds: number,
|
||||
): string {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const payload: JwtPayload = {
|
||||
session_id: sessionId,
|
||||
role: "worker",
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
|
||||
};
|
||||
|
||||
const headerB64 = base64url(JSON.stringify(header));
|
||||
const payloadB64 = base64url(JSON.stringify(payload));
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const signature = createHmac("sha256", getSigningKey())
|
||||
.update(signingInput)
|
||||
.digest();
|
||||
|
||||
return `${signingInput}.${base64url(signature)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT and return its payload, or null if invalid/expired.
|
||||
* Uses timing-safe comparison to prevent timing attacks.
|
||||
*/
|
||||
export function verifyWorkerJwt(token: string): JwtPayload | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSig = createHmac("sha256", getSigningKey())
|
||||
.update(signingInput)
|
||||
.digest();
|
||||
const actualSig = Buffer.from(
|
||||
signatureB64.replace(/-/g, "+").replace(/_/g, "/"),
|
||||
"base64",
|
||||
);
|
||||
|
||||
if (expectedSig.length !== actualSig.length) return null;
|
||||
if (!timingSafeEqual(expectedSig, actualSig)) return null;
|
||||
|
||||
// Decode payload
|
||||
try {
|
||||
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64));
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
102
packages/remote-control-server/src/auth/middleware.ts
Normal file
102
packages/remote-control-server/src/auth/middleware.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { validateApiKey } from "./api-key";
|
||||
import { verifyWorkerJwt } from "./jwt";
|
||||
import { resolveToken } from "./token";
|
||||
|
||||
/** Extract Bearer token from Authorization header or ?token= query param */
|
||||
function extractBearerToken(c: Context): string | undefined {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
const queryToken = c.req.query("token");
|
||||
return authHeader?.replace("Bearer ", "") || queryToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified authentication middleware — supports two modes:
|
||||
*
|
||||
* 1. **Token mode** (Web UI): Bearer token resolved via server-side lookup → username injected
|
||||
* 2. **API Key mode** (CLI bridge): Valid API key + X-Username header → username injected
|
||||
*/
|
||||
export async function apiKeyAuth(c: Context, next: Next) {
|
||||
const token = extractBearerToken(c);
|
||||
|
||||
// Try token authentication (Web UI)
|
||||
const tokenUsername = resolveToken(token);
|
||||
if (tokenUsername) {
|
||||
c.set("username", tokenUsername);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try API Key authentication (CLI bridge)
|
||||
if (validateApiKey(token)) {
|
||||
// Extract username from X-Username header or ?username= query param
|
||||
const username = c.req.header("X-Username") || c.req.query("username");
|
||||
if (username) {
|
||||
c.set("username", username);
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid or missing auth token" } }, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session ingress authentication — accepts both API key and worker JWT.
|
||||
*
|
||||
* Used for SSE stream, CCR worker events, and WebSocket ingress endpoints.
|
||||
* On JWT validation, stores the decoded payload in c.set("jwtPayload") for
|
||||
* downstream handlers to inspect session_id if needed.
|
||||
*/
|
||||
export async function sessionIngressAuth(c: Context, next: Next) {
|
||||
const token = extractBearerToken(c);
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
||||
}
|
||||
|
||||
// Try API key first (backward compatible)
|
||||
if (validateApiKey(token)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try JWT verification — validate session_id matches route param
|
||||
const payload = verifyWorkerJwt(token);
|
||||
if (payload) {
|
||||
const routeSessionId = c.req.param("id") || c.req.param("sessionId");
|
||||
if (routeSessionId && payload.session_id !== routeSessionId) {
|
||||
return c.json({ error: { type: "forbidden", message: "JWT session_id does not match target session" } }, 403);
|
||||
}
|
||||
c.set("jwtPayload", payload);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid API key or JWT" } }, 401);
|
||||
}
|
||||
|
||||
/** Accept CLI headers but don't validate them */
|
||||
export async function acceptCliHeaders(c: Context, next: Next) {
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UUID from request — query param ?uuid= or header X-UUID
|
||||
*/
|
||||
export function getUuidFromRequest(c: Context): string | undefined {
|
||||
return c.req.query("uuid") || c.req.header("X-UUID");
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID-based auth for Web UI routes (no-login mode).
|
||||
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
|
||||
*/
|
||||
export async function uuidAuth(c: Context, next: Next) {
|
||||
const uuid = getUuidFromRequest(c);
|
||||
if (!uuid) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
||||
}
|
||||
c.set("uuid", uuid);
|
||||
await next();
|
||||
}
|
||||
24
packages/remote-control-server/src/auth/token.ts
Normal file
24
packages/remote-control-server/src/auth/token.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { storeCreateToken, storeGetUserByToken } from "../store";
|
||||
|
||||
let tokenCounter = 0;
|
||||
|
||||
/** Generate a random session token and associate it with a user */
|
||||
export function issueToken(username: string): { token: string; expires_in: number } {
|
||||
// Use crypto.getRandomValues for uniqueness
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
const token = `rct_${tokenCounter++}_${hex}`;
|
||||
storeCreateToken(username, token);
|
||||
return { token, expires_in: 86400 };
|
||||
}
|
||||
|
||||
/** Resolve a token to a username. Returns null if invalid. */
|
||||
export function resolveToken(token: string | undefined): string | null {
|
||||
if (!token) return null;
|
||||
const entry = storeGetUserByToken(token);
|
||||
if (!entry) return null;
|
||||
return entry.username;
|
||||
}
|
||||
Reference in New Issue
Block a user