Files
Claude-Code-Workflow/ccw/src/core/auth/middleware.ts
catlog22 dfe153778c feat: Implement Cross-CLI Sync Panel for MCP servers
- Added CrossCliSyncPanel component for synchronizing MCP servers between Claude and Codex.
- Implemented server selection, copy operations, and result handling.
- Added tests for path mapping on Windows drives.
- Created E2E tests for ask_question Answer Broker functionality.
- Introduced MCP Tools Test Script for validating modified read_file and edit_file tools.
- Updated path_mapper to ensure correct drive formatting on Windows.
- Added .gitignore for ace-tool directory.
2026-02-08 23:19:19 +08:00

149 lines
4.7 KiB
TypeScript

import type http from 'http';
import type { IncomingMessage, ServerResponse } from 'http';
import type { TokenManager } from './token-manager.js';
export interface AuthMiddlewareContext {
pathname: string;
req: IncomingMessage;
res: ServerResponse;
tokenManager: TokenManager;
secretKey: string;
unauthenticatedPaths?: Set<string>;
}
function parseCookieHeader(cookieHeader: string | null | undefined): Record<string, string> {
if (!cookieHeader) return {};
const cookies: Record<string, string> = {};
for (const part of cookieHeader.split(';')) {
const [rawName, ...rawValueParts] = part.trim().split('=');
if (!rawName) continue;
const rawValue = rawValueParts.join('=');
try {
cookies[rawName] = decodeURIComponent(rawValue);
} catch {
cookies[rawName] = rawValue;
}
}
return cookies;
}
function getHeaderValue(header: string | string[] | undefined): string | null {
if (!header) return null;
if (Array.isArray(header)) return header[0] ?? null;
return header;
}
export function extractAuthToken(req: IncomingMessage): string | null {
const authorization = getHeaderValue(req.headers.authorization);
if (authorization) {
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (match?.[1]) return match[1].trim();
}
const cookies = parseCookieHeader(getHeaderValue(req.headers.cookie));
if (cookies.auth_token) return cookies.auth_token;
return null;
}
export function isLocalhostRequest(req: IncomingMessage): boolean {
const remote = req.socket?.remoteAddress ?? '';
return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
}
export function setAuthCookie(res: ServerResponse, token: string, expiresAt: Date): void {
const maxAgeSeconds = Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000));
const attributes = [
`auth_token=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Strict',
`Max-Age=${maxAgeSeconds}`,
];
res.setHeader('Set-Cookie', attributes.join('; '));
}
function writeJson(res: ServerResponse, status: number, body: Record<string, unknown>): void {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
}
/**
* Public API endpoints that can be accessed from localhost without authentication
* These are read-only endpoints used by the dashboard for data fetching
*/
const LOCALHOST_PUBLIC_PATHS = [
'/api/data',
'/api/orchestrator/flows',
'/api/orchestrator/templates',
'/api/orchestrator/executions',
'/api/orchestrator/templates/remote',
'/api/mcp-config',
'/api/ccw/tools',
'/api/ccw/installations',
'/api/cli/endpoints',
'/api/skills',
'/api/providers',
'/api/litellm-api/providers',
'/api/litellm-api/endpoints',
'/api/health',
'/api/a2ui/answer',
];
/**
* Check if a path is a public API endpoint (accessible from localhost without auth)
*/
function isLocalPublicPath(pathname: string): boolean {
// Exact match
if (LOCALHOST_PUBLIC_PATHS.includes(pathname)) return true;
// Prefix match for paths with parameters (e.g., /api/orchestrator/flows/:id)
for (const publicPath of LOCALHOST_PUBLIC_PATHS) {
if (pathname.startsWith(publicPath + '/') || pathname.startsWith(publicPath.replace(/\/[^/]*$/, '/'))) {
return true;
}
}
// Special handling for paths with wildcards
if (pathname.startsWith('/api/orchestrator/flows/')) return true;
if (pathname.startsWith('/api/orchestrator/executions/')) return true;
if (pathname.startsWith('/api/orchestrator/templates/')) return true;
if (pathname.startsWith('/api/litellm-api/providers/')) return true;
if (pathname.startsWith('/api/litellm-api/endpoints/')) return true;
if (pathname.startsWith('/api/litellm-api/models/')) return true;
return false;
}
export function authMiddleware(ctx: AuthMiddlewareContext): boolean {
const { pathname, req, res, tokenManager, secretKey, unauthenticatedPaths } = ctx;
if (!pathname.startsWith('/api/')) return true;
if (unauthenticatedPaths?.has(pathname)) return true;
// Allow localhost requests to public API endpoints without authentication
// This enables the Vite dev server (localhost:5173) to proxy API requests
if (isLocalhostRequest(req) && isLocalPublicPath(pathname)) {
(req as http.IncomingMessage & { authenticated?: boolean }).authenticated = true;
return true;
}
const token = extractAuthToken(req);
if (!token) {
writeJson(res, 401, { error: 'Unauthorized' });
return false;
}
const ok = tokenManager.validateToken(token, secretKey);
if (!ok) {
writeJson(res, 401, { error: 'Unauthorized' });
return false;
}
(req as http.IncomingMessage & { authenticated?: boolean }).authenticated = true;
return true;
}