feat: add local provider account connector

This commit is contained in:
thanhnv
2026-07-10 23:05:24 +09:00
parent adce7717f7
commit 7cb592a32f
9 changed files with 380 additions and 3 deletions
@@ -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 {}
@@ -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<string, string | string[] | undefined>) {
return ok(await this.service.status(actorFromHeaders(headers)));
}
@Post(':provider/login')
async login(
@Param('provider') provider: string,
@Headers() headers: Record<string, string | string[] | undefined>,
) {
return ok(await this.service.login(provider, actorFromHeaders(headers)));
}
}
@@ -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 {}
@@ -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<BridgeStatusResponse> {
this.requireRead(actor);
return this.bridgeRequest<BridgeStatusResponse>('/v1/auth/providers', 'GET');
}
async login(provider: string, actor: SettingsActor): Promise<BridgeLoginResponse> {
this.requireAdmin(actor);
if (!PROVIDERS.has(provider)) throw new ForbiddenException('PROVIDER_AUTH_UNKNOWN_PROVIDER');
return this.bridgeRequest<BridgeLoginResponse>(`/v1/auth/${provider}/login`, 'POST');
}
private async bridgeRequest<T>(path: string, method: 'GET' | 'POST'): Promise<T> {
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');
}
}
}
@@ -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 && <div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{notice}</div>}
{error && <div role="alert" className="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>}
{query.isLoading && <div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">Đang đọc kho kết nối đã mã hóa…</div>}
<section className="mb-5 rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-950 to-slate-900 p-5 text-white shadow-lg shadow-indigo-950/10">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-300">Local account connector</div><h3 className="mt-1 text-base font-semibold">Đăng nhập bằng tài khoản Codex hoặc Claude</h3><p className="mt-1 max-w-2xl text-xs leading-5 text-indigo-100/70">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.</p></div>
<span className="rounded-full border border-white/15 bg-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-indigo-100">Host bridge</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{(accountQuery.data?.providers ?? []).map((provider) => (
<div key={provider.id} className="rounded-xl border border-white/10 bg-white/5 p-3.5">
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold">{provider.label}</div><span className={`rounded-full px-2 py-1 text-[9px] font-bold uppercase ${provider.loggedIn ? 'bg-emerald-300/20 text-emerald-200' : provider.authenticating ? 'bg-amber-300/20 text-amber-200' : 'bg-white/10 text-slate-300'}`}>{provider.loggedIn ? 'Logged in' : provider.authenticating ? 'Waiting callback' : provider.available ? 'Signed out' : 'CLI missing'}</span></div>
<p className="mt-1 text-[11px] text-indigo-100/60">{provider.loggedIn ? `Auth: ${provider.authMethod}` : provider.available ? 'Login opens in your default browser.' : 'Install the official CLI on the Mac host first.'}</p>
<button type="button" onClick={() => login.mutate(provider)} disabled={busy || !provider.available || provider.loggedIn || !['project-admin', 'org-admin'].includes(actor.role)} className="mt-3 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-slate-900 transition hover:bg-indigo-50 disabled:cursor-not-allowed disabled:bg-white/20 disabled:text-white/50">{provider.loggedIn ? 'Connected' : provider.authenticating ? 'Waiting…' : `Login ${provider.id === 'codex' ? 'with ChatGPT' : 'with Claude'}`}</button>
</div>
))}
{accountQuery.isError && <div className="rounded-xl border border-rose-300/20 bg-rose-300/10 p-3 text-xs text-rose-100 sm:col-span-2">Host auth bridge chưa sẵn sàng. Chạy lại local-full để bật connector.</div>}
</div>
</section>
<div className="grid gap-4 md:grid-cols-2">
{(query.data?.connections ?? []).map((connection) => {
const form = forms[connection.id] ?? { endpoint: connection.endpoint || DEFAULT_ENDPOINTS[connection.id], apiKey: '' };
@@ -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.
@@ -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())
@@ -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)