From 7cb592a32fbeade63c90561baa0969c83f1bfb2f Mon Sep 17 00:00:00 2001 From: thanhnv Date: Fri, 10 Jul 2026 23:05:24 +0900 Subject: [PATCH] feat: add local provider account connector --- docker-compose.control-panel.local.yml | 2 + .../backend/src/app.module.ts | 3 +- .../provider-auth/provider-auth.controller.ts | 22 +++ .../src/provider-auth/provider-auth.module.ts | 9 + .../provider-auth/provider-auth.service.ts | 70 +++++++ .../components/chat/ModelConnectionPanel.tsx | 36 +++- .../frontend/src/lib/api.ts | 13 ++ .../scripts/provider-auth-bridge.py | 186 ++++++++++++++++++ .../casan-harness/scripts/bash/local-full.sh | 42 ++++ 9 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 packages/casan-control-panel/backend/src/provider-auth/provider-auth.controller.ts create mode 100644 packages/casan-control-panel/backend/src/provider-auth/provider-auth.module.ts create mode 100644 packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts create mode 100644 packages/casan-control-panel/scripts/provider-auth-bridge.py diff --git a/docker-compose.control-panel.local.yml b/docker-compose.control-panel.local.yml index 8ab24fa..49098f5 100644 --- a/docker-compose.control-panel.local.yml +++ b/docker-compose.control-panel.local.yml @@ -29,6 +29,8 @@ services: CASAN_OPENAI_COMPATIBLE_API_KEY: ${CASAN_OPENAI_COMPATIBLE_API_KEY:-} CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS: ${CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS:-} CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0} + CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130 + CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-} volumes: - ./.specify:/app/.specify - ./docs/output:/app/docs/output:ro diff --git a/packages/casan-control-panel/backend/src/app.module.ts b/packages/casan-control-panel/backend/src/app.module.ts index 45039d1..60ea6ec 100644 --- a/packages/casan-control-panel/backend/src/app.module.ts +++ b/packages/casan-control-panel/backend/src/app.module.ts @@ -5,9 +5,10 @@ import { SettingsModule } from './settings/settings.module.js'; import { KillSwitchModule } from './kill-switch/kill-switch.module.js'; import { ApprovalsModule } from './approvals/approvals.module.js'; import { ChatModule } from './chat/chat.module.js'; +import { ProviderAuthModule } from './provider-auth/provider-auth.module.js'; @Module({ - imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule], + imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule], controllers: [HealthController], }) export class AppModule {} diff --git a/packages/casan-control-panel/backend/src/provider-auth/provider-auth.controller.ts b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.controller.ts new file mode 100644 index 0000000..abb94fe --- /dev/null +++ b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Headers, Inject, Param, Post } from '@nestjs/common'; +import { ok } from '../common/api-response.js'; +import { actorFromHeaders } from '../common/auth-context.js'; +import { ProviderAuthService } from './provider-auth.service.js'; + +@Controller('api/v1/provider-auth') +export class ProviderAuthController { + constructor(@Inject(ProviderAuthService) private readonly service: ProviderAuthService) {} + + @Get() + async status(@Headers() headers: Record) { + return ok(await this.service.status(actorFromHeaders(headers))); + } + + @Post(':provider/login') + async login( + @Param('provider') provider: string, + @Headers() headers: Record, + ) { + return ok(await this.service.login(provider, actorFromHeaders(headers))); + } +} diff --git a/packages/casan-control-panel/backend/src/provider-auth/provider-auth.module.ts b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.module.ts new file mode 100644 index 0000000..10d3ee1 --- /dev/null +++ b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ProviderAuthController } from './provider-auth.controller.js'; +import { ProviderAuthService } from './provider-auth.service.js'; + +@Module({ + controllers: [ProviderAuthController], + providers: [ProviderAuthService], +}) +export class ProviderAuthModule {} diff --git a/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts new file mode 100644 index 0000000..62d740f --- /dev/null +++ b/packages/casan-control-panel/backend/src/provider-auth/provider-auth.service.ts @@ -0,0 +1,70 @@ +import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import type { SettingsActor } from '../settings/settings.service.js'; + +export interface ProviderAuthStatus { + id: 'codex' | 'claude'; + label: string; + available: boolean; + loggedIn: boolean; + authenticating: boolean; + authMethod: string; +} + +interface BridgeStatusResponse { + success: boolean; + providers: ProviderAuthStatus[]; +} + +interface BridgeLoginResponse { + success: boolean; + reason: string; + provider: ProviderAuthStatus; +} + +const PROVIDERS = new Set(['codex', 'claude']); + +@Injectable() +export class ProviderAuthService { + private readonly bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || 'http://host.docker.internal:20130').replace(/\/$/, ''); + private readonly bridgeToken = process.env.CASAN_AUTH_BRIDGE_TOKEN || ''; + + async status(actor: SettingsActor): Promise { + this.requireRead(actor); + return this.bridgeRequest('/v1/auth/providers', 'GET'); + } + + async login(provider: string, actor: SettingsActor): Promise { + this.requireAdmin(actor); + if (!PROVIDERS.has(provider)) throw new ForbiddenException('PROVIDER_AUTH_UNKNOWN_PROVIDER'); + return this.bridgeRequest(`/v1/auth/${provider}/login`, 'POST'); + } + + private async bridgeRequest(path: string, method: 'GET' | 'POST'): Promise { + if (!this.bridgeToken) throw new ServiceUnavailableException('PROVIDER_AUTH_BRIDGE_NOT_CONFIGURED'); + try { + const response = await fetch(`${this.bridgeUrl}${path}`, { + method, + headers: { 'X-CASAN-Bridge-Token': this.bridgeToken }, + signal: AbortSignal.timeout(10_000), + }); + const payload = await response.json() as T & { reason?: string }; + if (!response.ok) throw new ServiceUnavailableException(payload.reason || 'PROVIDER_AUTH_BRIDGE_FAILED'); + return payload; + } catch (error: unknown) { + if (error instanceof ServiceUnavailableException) throw error; + throw new ServiceUnavailableException('PROVIDER_AUTH_BRIDGE_UNREACHABLE'); + } + } + + private requireRead(actor: SettingsActor) { + if (!['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'].includes(actor.role)) { + throw new ForbiddenException('PROVIDER_AUTH_READ_DENIED'); + } + } + + private requireAdmin(actor: SettingsActor) { + if (!['project-admin', 'org-admin'].includes(actor.role)) { + throw new ForbiddenException('PROVIDER_AUTH_ADMIN_REQUIRED'); + } + } +} diff --git a/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx b/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx index 595c9ad..8a0ef30 100644 --- a/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx +++ b/packages/casan-control-panel/frontend/src/components/chat/ModelConnectionPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { api, ChatModelConnection, SettingsActor } from '../../lib/api'; +import { api, ChatModelConnection, ProviderAuthStatus, SettingsActor } from '../../lib/api'; interface ModelConnectionPanelProps { actor: SettingsActor; @@ -47,6 +47,13 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo enabled: open, retry: false, }); + const accountQuery = useQuery({ + queryKey: ['provider-auth', actor], + queryFn: () => api.providerAuthStatus(actor), + enabled: open, + retry: false, + refetchInterval: (state) => state.state.data?.providers.some((provider) => provider.authenticating) ? 1500 : false, + }); useEffect(() => { if (!query.data?.connections) return; @@ -94,7 +101,16 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo onSuccess: async (result) => finish(`${result.connection.label} đã được ngắt kết nối; secret đã bị xóa.`), onError: (reason) => { setNotice(null); setError(messageOf(reason)); }, }); - const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending; + const login = useMutation({ + mutationFn: (provider: ProviderAuthStatus) => api.startProviderLogin(actor, provider.id), + onSuccess: async (result) => { + setError(null); + setNotice(result.reason === 'already_logged_in' ? `${result.provider.label} đã đăng nhập.` : `Đã mở browser login cho ${result.provider.label}. CASAN sẽ tự cập nhật khi callback hoàn tất.`); + await accountQuery.refetch(); + }, + onError: (reason) => { setNotice(null); setError(messageOf(reason)); }, + }); + const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending || login.isPending; if (!open) return null; return ( @@ -114,6 +130,22 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo {notice &&
{notice}
} {error &&
{error}
} {query.isLoading &&
Đang đọc kho kết nối đã mã hóa…
} +
+
+
Local account connector

Đăng nhập bằng tài khoản Codex hoặc Claude

Browser login do CLI chính thức trên máy Mac xử lý. Credential nằm trong Keychain/CLI store và không đi vào Docker hoặc trình duyệt CASAN.

+ Host bridge +
+
+ {(accountQuery.data?.providers ?? []).map((provider) => ( +
+
{provider.label}
{provider.loggedIn ? 'Logged in' : provider.authenticating ? 'Waiting callback' : provider.available ? 'Signed out' : 'CLI missing'}
+

{provider.loggedIn ? `Auth: ${provider.authMethod}` : provider.available ? 'Login opens in your default browser.' : 'Install the official CLI on the Mac host first.'}

+ +
+ ))} + {accountQuery.isError &&
Host auth bridge chưa sẵn sàng. Chạy lại local-full để bật connector.
} +
+
{(query.data?.connections ?? []).map((connection) => { const form = forms[connection.id] ?? { endpoint: connection.endpoint || DEFAULT_ENDPOINTS[connection.id], apiKey: '' }; diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index d51c138..6c3bc83 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -208,6 +208,15 @@ export interface ChatModelConnection { requiresKey: boolean; } +export interface ProviderAuthStatus { + id: 'codex' | 'claude'; + label: string; + available: boolean; + loggedIn: boolean; + authenticating: boolean; + authMethod: string; +} + export interface ChatReplay { ok: boolean; decision: 'MATCH' | 'DRIFT' | 'BREAK' | string; @@ -347,6 +356,10 @@ export const api = { post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/default', { provider, model }, actorHeaders(actor)), disconnectChatProvider: (actor: SettingsActor, provider: string) => post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/disconnect', { provider }, actorHeaders(actor)), + providerAuthStatus: (actor: SettingsActor) => + getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)), + startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) => + post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)), }; // Health is raw (not enveloped) + carries HTTP status. diff --git a/packages/casan-control-panel/scripts/provider-auth-bridge.py b/packages/casan-control-panel/scripts/provider-auth-bridge.py new file mode 100644 index 0000000..476a0a2 --- /dev/null +++ b/packages/casan-control-panel/scripts/provider-auth-bridge.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Local-only bridge between CASAN Control Panel and official provider CLIs. + +The bridge never reads credential files. It asks the installed Codex/Claude +CLI for a sanitized status and can launch their official browser login flow. +Only fixed commands are allowed and every request requires a generated bearer +token supplied to the backend container by local-full.sh. +""" +import argparse +import hmac +import json +import os +import shutil +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Dict, Optional + + +PROVIDERS = { + "codex": { + "label": "OpenAI Codex", + "binary": "codex", + "status": ["codex", "login", "status"], + "login": ["codex", "login"], + }, + "claude": { + "label": "Anthropic Claude Code", + "binary": "claude", + "status": ["claude", "auth", "status", "--json"], + "login": ["claude", "auth", "login", "--claudeai"], + }, +} +RUNNING: Dict[str, subprocess.Popen] = {} +LOCK = threading.Lock() + + +def command_status(provider: str) -> dict: + config = PROVIDERS[provider] + available = shutil.which(config["binary"]) is not None + with LOCK: + process = RUNNING.get(provider) + authenticating = bool(process and process.poll() is None) + if process and process.poll() is not None: + RUNNING.pop(provider, None) + if not available: + return { + "id": provider, + "label": config["label"], + "available": False, + "loggedIn": False, + "authenticating": False, + "authMethod": "unavailable", + } + try: + result = subprocess.run( + config["status"], capture_output=True, text=True, timeout=8, + env={**os.environ, "NO_COLOR": "1"}, + ) + except (OSError, subprocess.TimeoutExpired): + return { + "id": provider, + "label": config["label"], + "available": True, + "loggedIn": False, + "authenticating": authenticating, + "authMethod": "unknown", + } + if provider == "claude": + try: + payload = json.loads(result.stdout or "{}") + except ValueError: + payload = {} + logged_in = result.returncode == 0 and bool(payload.get("loggedIn")) + method = str(payload.get("authMethod") or "none") + else: + text = (result.stdout + result.stderr).lower() + logged_in = result.returncode == 0 and "logged in" in text + method = "chatgpt" if "chatgpt" in text else ("api" if "api" in text and logged_in else "none") + return { + "id": provider, + "label": config["label"], + "available": True, + "loggedIn": logged_in, + "authenticating": authenticating and not logged_in, + "authMethod": method, + } + + +def start_login(provider: str) -> dict: + status = command_status(provider) + if not status["available"]: + return {"success": False, "reason": "cli_not_installed", "provider": status} + if status["loggedIn"]: + return {"success": True, "reason": "already_logged_in", "provider": status} + with LOCK: + current = RUNNING.get(provider) + if current and current.poll() is None: + return {"success": True, "reason": "login_in_progress", "provider": {**status, "authenticating": True}} + process = subprocess.Popen( + PROVIDERS[provider]["login"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + env={**os.environ, "NO_COLOR": "1"}, + ) + RUNNING[provider] = process + return {"success": True, "reason": "browser_login_started", "provider": command_status(provider)} + + +class BridgeHandler(BaseHTTPRequestHandler): + server_version = "CASANAuthBridge/1.0" + + def log_message(self, _format: str, *_args) -> None: + return + + def send_json(self, status: int, payload: dict) -> None: + encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(encoded) + + def authorized(self) -> bool: + expected = self.server.bridge_token # type: ignore[attr-defined] + supplied = self.headers.get("X-CASAN-Bridge-Token", "") + return bool(expected) and hmac.compare_digest(expected, supplied) + + def do_GET(self) -> None: + if self.path == "/healthz": + self.send_json(200, {"status": "ok"}) + return + if not self.authorized(): + self.send_json(401, {"success": False, "reason": "unauthorized"}) + return + if self.path == "/v1/auth/providers": + self.send_json(200, {"success": True, "providers": [command_status(provider) for provider in PROVIDERS]}) + return + self.send_json(404, {"success": False, "reason": "not_found"}) + + def do_POST(self) -> None: + if not self.authorized(): + self.send_json(401, {"success": False, "reason": "unauthorized"}) + return + parts = [part for part in self.path.split("/") if part] + if len(parts) == 4 and parts[:2] == ["v1", "auth"] and parts[3] == "login" and parts[2] in PROVIDERS: + result = start_login(parts[2]) + self.send_json(202 if result["success"] else 503, result) + return + self.send_json(404, {"success": False, "reason": "not_found"}) + + +def read_token(path: str) -> str: + try: + with open(path, encoding="utf-8") as handle: + token = handle.read().strip() + except OSError: + raise SystemExit("AUTH_BRIDGE_TOKEN_MISSING") + if len(token) < 32: + raise SystemExit("AUTH_BRIDGE_TOKEN_WEAK") + return token + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=20130) + parser.add_argument("--token-file", required=True) + args = parser.parse_args() + server = ThreadingHTTPServer((args.bind, args.port), BridgeHandler) + server.bridge_token = read_token(args.token_file) # type: ignore[attr-defined] + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/casan-harness/scripts/bash/local-full.sh b/packages/casan-harness/scripts/bash/local-full.sh index 7a3086e..59792ec 100755 --- a/packages/casan-harness/scripts/bash/local-full.sh +++ b/packages/casan-harness/scripts/bash/local-full.sh @@ -14,9 +14,18 @@ INFRA="$SCRIPT_DIR/infra-lab.sh" CP_COMPOSE="$ROOT/docker-compose.control-panel.local.yml" LOCAL_ENV="$ROOT/infra/local-prod/casan.local.env" TLS_DIR="$ROOT/tmp/control-panel-local/tls" +AUTH_BRIDGE_DIR="$ROOT/tmp/control-panel-local/auth-bridge" +AUTH_BRIDGE_TOKEN_FILE="$AUTH_BRIDGE_DIR/token" +AUTH_BRIDGE_PID_FILE="$AUTH_BRIDGE_DIR/bridge.pid" +AUTH_BRIDGE_LOG="$AUTH_BRIDGE_DIR/bridge.log" +AUTH_BRIDGE="$ROOT/packages/casan-control-panel/scripts/provider-auth-bridge.py" CMD="${1:-status}" cp_compose() { + if [[ -f "$AUTH_BRIDGE_TOKEN_FILE" ]]; then + export CASAN_AUTH_BRIDGE_TOKEN + CASAN_AUTH_BRIDGE_TOKEN="$(tr -d '\r\n' < "$AUTH_BRIDGE_TOKEN_FILE")" + fi if [[ -f "$LOCAL_ENV" ]]; then docker compose --env-file "$LOCAL_ENV" -f "$CP_COMPOSE" "$@" else @@ -24,6 +33,31 @@ cp_compose() { fi } +start_auth_bridge() { + mkdir -p "$AUTH_BRIDGE_DIR" + if [[ ! -s "$AUTH_BRIDGE_TOKEN_FILE" ]]; then + openssl rand -hex 32 > "$AUTH_BRIDGE_TOKEN_FILE" + chmod 600 "$AUTH_BRIDGE_TOKEN_FILE" + fi + if [[ -f "$AUTH_BRIDGE_PID_FILE" ]] && kill -0 "$(cat "$AUTH_BRIDGE_PID_FILE")" 2>/dev/null; then + return 0 + fi + [[ -f "$AUTH_BRIDGE" ]] || { echo "CASAN_AUTH_BRIDGE_MISSING" >&2; return 1; } + nohup python3 "$AUTH_BRIDGE" --bind 0.0.0.0 --port 20130 --token-file "$AUTH_BRIDGE_TOKEN_FILE" > "$AUTH_BRIDGE_LOG" 2>&1 & + echo "$!" > "$AUTH_BRIDGE_PID_FILE" + chmod 600 "$AUTH_BRIDGE_PID_FILE" "$AUTH_BRIDGE_LOG" 2>/dev/null || true + wait_url "http://127.0.0.1:20130/healthz" +} + +stop_auth_bridge() { + if [[ -f "$AUTH_BRIDGE_PID_FILE" ]]; then + local pid + pid="$(cat "$AUTH_BRIDGE_PID_FILE")" + kill "$pid" 2>/dev/null || true + rm -f "$AUTH_BRIDGE_PID_FILE" + fi +} + need_docker() { command -v docker >/dev/null 2>&1 || { echo "CASAN_LOCAL_DOCKER_MISSING" >&2; exit 1; } docker compose version >/dev/null 2>&1 || { echo "CASAN_LOCAL_COMPOSE_MISSING" >&2; exit 1; } @@ -53,6 +87,7 @@ case "$CMD" in need_docker bash "$INFRA" start ensure_tls + start_auth_bridge cp_compose up -d --build wait_url "http://127.0.0.1:18082/healthz" # The unauthenticated console intentionally redirects, so test the OIDC IdP @@ -65,6 +100,7 @@ case "$CMD" in stop) need_docker cp_compose down --remove-orphans + stop_auth_bridge bash "$INFRA" stop echo "CASAN_LOCAL_FULL_STOPPED" ;; @@ -74,12 +110,18 @@ case "$CMD" in bash "$INFRA" status echo "=== control panel ===" cp_compose ps + if [[ -f "$AUTH_BRIDGE_PID_FILE" ]] && kill -0 "$(cat "$AUTH_BRIDGE_PID_FILE")" 2>/dev/null; then + echo "provider_auth_bridge=running pid=$(cat "$AUTH_BRIDGE_PID_FILE")" + else + echo "provider_auth_bridge=stopped" + fi ;; verify) need_docker bash "$INFRA" verify cp_compose ps wait_url "http://127.0.0.1:18082/healthz" + wait_url "http://127.0.0.1:20130/healthz" echo "CASAN_LOCAL_FULL_VERIFY_PASS" ;; smoke)