Files
CASAN/packages/casan-control-panel/scripts/provider-auth-bridge.py
T

332 lines
13 KiB
Python

#!/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 hashlib
import hmac
import json
import os
import shutil
import subprocess
import tempfile
import threading
import time
from collections import deque
from datetime import datetime, timezone
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()
MODEL_GATES = {provider: threading.Semaphore(1) for provider in PROVIDERS}
MODEL_WINDOWS = {provider: deque() for provider in PROVIDERS}
RATE_LOCK = threading.Lock()
AUDIT_LOCK = threading.Lock()
def model_call_allowed(provider: str) -> bool:
timestamp = time.monotonic()
with RATE_LOCK:
window = MODEL_WINDOWS[provider]
while window and timestamp - window[0] > 600:
window.popleft()
if len(window) >= 10:
return False
window.append(timestamp)
return True
def audit_model_call(path: str, provider: str, status: str, prompt: str, latency_ms: int) -> None:
if not path:
return
record = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"provider": provider,
"status": status,
"prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"prompt_characters": len(prompt),
"latency_ms": latency_ms,
}
os.makedirs(os.path.dirname(path), exist_ok=True)
with AUDIT_LOCK, open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
handle.flush()
os.chmod(path, 0o600)
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)}
def run_account_command(provider: str, prompt: str, directory: str):
if provider == "codex":
output_path = os.path.join(directory, "last-message.txt")
command = [
"codex", "exec", "--ephemeral", "--ignore-user-config", "--ignore-rules",
"--skip-git-repo-check", "--sandbox", "read-only", "--color", "never",
"--cd", directory, "--output-last-message", output_path, "-",
]
result = subprocess.run(
command, input=prompt, capture_output=True, text=True, timeout=300,
env={**os.environ, "NO_COLOR": "1"},
)
text = ""
if result.returncode == 0 and os.path.isfile(output_path):
with open(output_path, encoding="utf-8") as handle:
text = handle.read().strip()
return result, text, {}, "codex-account-default"
command = [
"claude", "--print", "--output-format", "json", "--permission-mode", "plan",
"--tools", "", "--safe-mode", "--no-session-persistence",
]
result = subprocess.run(
command, input=prompt, capture_output=True, text=True, timeout=300, cwd=directory,
env={**os.environ, "NO_COLOR": "1"},
)
try:
payload = json.loads(result.stdout or "{}")
except ValueError:
payload = {}
text = str(payload.get("result") or "").strip()
raw_usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
usage = {
"input_tokens": int(raw_usage.get("input_tokens") or 0),
"output_tokens": int(raw_usage.get("output_tokens") or 0),
}
return result, text, usage, str(payload.get("model") or "claude-account-default")
def generate_with_account(provider: str, prompt: str, audit_path: str) -> dict:
started = time.monotonic()
status = command_status(provider)
if not status["available"] or not status["loggedIn"]:
audit_model_call(audit_path, provider, "provider_not_logged_in", prompt, 0)
return {"success": False, "reason": "provider_not_logged_in"}
if not prompt or len(prompt) > 24000:
audit_model_call(audit_path, provider, "prompt_length_invalid", prompt, 0)
return {"success": False, "reason": "prompt_length_invalid"}
if not model_call_allowed(provider):
audit_model_call(audit_path, provider, "rate_limited", prompt, 0)
return {"success": False, "reason": "account_model_rate_limited"}
gate = MODEL_GATES[provider]
if not gate.acquire(blocking=False):
audit_model_call(audit_path, provider, "busy", prompt, 0)
return {"success": False, "reason": "account_model_busy"}
try:
with tempfile.TemporaryDirectory(prefix="casan-account-model-") as directory:
result, text, usage, model = run_account_command(provider, prompt, directory)
except (OSError, subprocess.TimeoutExpired):
audit_model_call(audit_path, provider, "unreachable", prompt, int((time.monotonic() - started) * 1000))
return {"success": False, "reason": "account_model_unreachable"}
finally:
gate.release()
if result.returncode != 0 or not text:
audit_model_call(audit_path, provider, "failed", prompt, int((time.monotonic() - started) * 1000))
return {"success": False, "reason": "account_model_failed"}
audit_model_call(audit_path, provider, "success", prompt, int((time.monotonic() - started) * 1000))
return {
"success": True,
"provider": provider,
"model": model,
"text": text,
"usage": usage,
"latency_ms": int((time.monotonic() - started) * 1000),
}
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.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
self.end_headers()
self.wfile.write(encoded)
def authorized(self) -> bool:
host = self.headers.get("Host", "").split(":", 1)[0].lower()
if host not in {"127.0.0.1", "localhost", "host.docker.internal"}:
return False
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 read_json(self) -> dict:
if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json":
return {}
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return {}
if length < 1 or length > 100000:
return {}
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return {}
return payload if isinstance(payload, dict) else {}
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
if len(parts) == 4 and parts[:2] == ["v1", "models"] and parts[3] == "generate" and parts[2] in PROVIDERS:
body = self.read_json()
result = generate_with_account(parts[2], str(body.get("prompt") or ""), self.server.audit_log) # type: ignore[attr-defined]
self.send_json(200 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)
parser.add_argument("--audit-log", default="")
args = parser.parse_args()
server = ThreadingHTTPServer((args.bind, args.port), BridgeHandler)
server.bridge_token = read_token(args.token_file) # type: ignore[attr-defined]
server.audit_log = os.path.abspath(args.audit_log) if args.audit_log else "" # type: ignore[attr-defined]
server.daemon_threads = True
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())