vignette/apps/api/app/services/provider_credentials.py
Yun Chan 45b84faa0d
Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m0s
codex·agy OAuth 계정 연결 지원 (ChatGPT/Antigravity 네이티브 어댑터)
omniroute와 동일하게 공개 클라이언트 자격증명으로 서버사이드 OAuth 교환을
제공한다. 관리자가 제공자 로그인 후 브라우저에 남는 code를 붙여넣으면
토큰·refresh token·메타데이터(account-id, Code Assist project)를 저장하고
게이트웨이에 push한다.

- codex_api 어댑터: chatgpt.com/backend-api/codex/responses (Responses SSE,
  401 시 refresh token으로 자가 갱신)
- antigravity_api 어댑터: cloudcode-pa v1internal:streamGenerateContent
  (Gemini 형식 SSE, loadCodeAssist로 프로젝트 발급, 401 자가 갱신)
- 자격증명 저장소에 refresh_token_encrypted·extra 컬럼 추가(부트스트랩
  SQL 포함), 게이트웨이 push가 구조화 자격증명을 전달
2026-09-11 19:01:35 +09:00

531 lines
20 KiB
Python

"""관리자 연결 AI provider 자격증명 저장소.
관리자가 연결 UI에서 붙여넣은 provider 토큰(OAuth 액세스 토큰 또는 API 키)을
DB(``app.admin_provider_credential``)에 암호화 저장하고, 엔진 게이트웨이 프로세스에
shared-secret으로 보호되는 내부 엔드포인트로 push해 ``os.environ``에 주입한다.
- 암호화는 표준 라이브러리만으로 구성한다(HMAC-SHA256 CTR 스트림 + encrypt-then-MAC).
마스터 키는 ``PROVIDER_CREDENTIAL_SECRET``, 비어 있으면 ``SESSION_SECRET``에서 파생한다.
- 토큰 원문은 로그·응답에 절대 내보내지 않는다. 마지막 4글자 힌트만 남긴다.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal
import httpx
from ..config import settings
from ..db import get_pool
from ..engine_client import EngineError, engine_client
PROVIDER_CODES = ("claude", "codex", "agy", "openrouter")
ProviderCode = Literal["claude", "codex", "agy", "openrouter"]
_TOKEN_ENVELOPE_PREFIX = "v1."
_KEY_SIZE = 32
_NONCE_SIZE = 16
_MAC_SIZE = 32
_HKDF_INFO_ENC = b"vignette/provider-credential/enc"
_HKDF_INFO_MAC = b"vignette/provider-credential/mac"
@dataclass(frozen=True, slots=True)
class ProviderMeta:
code: ProviderCode
label: str
description: str
token_page_url: str
token_page_label: str
auth_kinds: tuple[Literal["api_key", "oauth_token"], ...]
guide: str
PROVIDER_CATALOG: tuple[ProviderMeta, ...] = (
ProviderMeta(
code="claude",
label="Claude (Anthropic)",
description=(
"claude_api 엔진과 Claude CLI에 쓰는 Anthropic 자격증명입니다. "
"OAuth 액세스 토큰은 Authorization Bearer로, API 키는 x-api-key로 전송됩니다."
),
token_page_url="https://console.anthropic.com/settings/keys",
token_page_label="Anthropic 콘솔 · API 키",
auth_kinds=("api_key", "oauth_token"),
guide=(
"콘솔에서 로그인 뒤 API 키를 발급해 붙여넣거나, "
"Claude CLI OAuth 토큰(claude setup-token 출력값)을 붙여넣으세요."
),
),
ProviderMeta(
code="codex",
label="Codex / OpenAI",
description=(
"codex_cli 엔진과 OpenAI 호환 엔진에 쓰는 API 토큰입니다. "
"게이트웨이가 OPENAI_API_KEY로 주입합니다."
),
token_page_url="https://platform.openai.com/api-keys",
token_page_label="OpenAI 플랫폼 · API 키",
auth_kinds=("api_key",),
guide=(
"플랫폼에 로그인해 API 키를 발급한 뒤 붙여넣으세요. "
"ChatGPT OAuth 토큰은 Codex CLI 로그인으로도 대체할 수 있습니다."
),
),
ProviderMeta(
code="agy",
label="Agy CLI",
description=(
"agy_cli 엔진이 사용하는 CLI 계정 토큰입니다. "
"게이트웨이가 AGY_API_KEY로 자식 프로세스에 주입합니다."
),
token_page_url="",
token_page_label="Agy 계정 로그인",
auth_kinds=("api_key", "oauth_token"),
guide=(
"Agy 계정에 로그인해 발급받은 접근 토큰을 붙여넣으세요. "
"실제 가용 모델은 게이트웨이의 agy models 조회로 확인됩니다."
),
),
ProviderMeta(
code="openrouter",
label="OpenRouter",
description=(
"OpenRouter를 통해 다중 provider 모델을 쓰는 엔진입니다. "
"게이트웨이가 OPENROUTER_API_KEY로 주입합니다."
),
token_page_url="https://openrouter.ai/keys",
token_page_label="OpenRouter · API 키",
auth_kinds=("api_key",),
guide=(
"OpenRouter에 로그인해 API 키를 발급한 뒤 붙여넣으세요. "
"키에 연결된 계정 크레딧과 모델 목록이 자동 확인됩니다."
),
),
)
def provider_meta(provider: str) -> ProviderMeta | None:
for meta in PROVIDER_CATALOG:
if meta.code == provider:
return meta
return None
class ProviderCredentialError(RuntimeError):
"""자격증명 저장·검증 실패. 원문 토큰을 포함하지 않는다."""
def _master_secret() -> bytes:
configured = settings.provider_credential_secret.get_secret_value().strip()
if configured:
if len(configured) < 16:
raise ProviderCredentialError(
"PROVIDER_CREDENTIAL_SECRET은 16자 이상이어야 합니다."
)
return configured.encode("utf-8")
session_secret = settings.session_secret
if session_secret == "dev-insecure-change-me":
# dev 기본 시크릿은 prod 기동 가드에서 이미 막힌다. dev 편의를 위해 허용.
return ("dev::" + session_secret).encode("utf-8")
if not session_secret or len(session_secret) < 16:
raise ProviderCredentialError(
"자격증명 암호화 키가 없습니다. PROVIDER_CREDENTIAL_SECRET 또는 SESSION_SECRET을 설정하세요."
)
return session_secret.encode("utf-8")
def _derived_key(info: bytes) -> bytes:
return hmac.new(_master_secret(), info, hashlib.sha256).digest()
def encrypt_token(plain: str) -> str:
"""HMAC-SHA256 CTR 스트림 + encrypt-then-MAC 봉투(v1)."""
data = plain.encode("utf-8")
nonce = os.urandom(_NONCE_SIZE)
enc_key = _derived_key(_HKDF_INFO_ENC)
mac_key = _derived_key(_HKDF_INFO_MAC)
stream = bytearray()
counter = 0
while len(stream) < len(data):
stream.extend(
hashlib.sha256(
enc_key + nonce + counter.to_bytes(4, "big")
).digest()
)
counter += 1
ciphertext = bytes(a ^ b for a, b in zip(data, stream))
tag = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE]
blob = nonce + ciphertext + tag
return _TOKEN_ENVELOPE_PREFIX + base64.urlsafe_b64encode(blob).decode("ascii")
def decrypt_token(envelope: str) -> str:
raw = envelope.removeprefix(_TOKEN_ENVELOPE_PREFIX)
try:
blob = base64.urlsafe_b64decode(raw.encode("ascii"))
except (ValueError, UnicodeEncodeError) as exc:
raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.") from exc
if len(blob) < _NONCE_SIZE + _MAC_SIZE + 1:
raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.")
nonce = blob[:_NONCE_SIZE]
ciphertext = blob[_NONCE_SIZE:-_MAC_SIZE]
tag = blob[-_MAC_SIZE:]
mac_key = _derived_key(_HKDF_INFO_MAC)
expected = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE]
if not hmac.compare_digest(tag, expected):
raise ProviderCredentialError("자격증명 봉투 인증에 실패했습니다.")
enc_key = _derived_key(_HKDF_INFO_ENC)
stream = bytearray()
counter = 0
while len(stream) < len(ciphertext):
stream.extend(
hashlib.sha256(
enc_key + nonce + counter.to_bytes(4, "big")
).digest()
)
counter += 1
return bytes(a ^ b for a, b in zip(ciphertext, stream)).decode("utf-8")
def token_hint(plain: str) -> str:
tail = plain[-4:] if len(plain) >= 8 else ""
return f"{tail}" if tail else "저장됨"
def _row_ts(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, datetime):
value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
return value.timestamp()
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass(frozen=True, slots=True)
class StoredCredential:
provider: str
auth_kind: str
token_hint: str
updated_by: str | None
updated_at: float | None
last_verified_at: float | None
last_verify_ok: bool | None
last_verify_error: str | None
refresh_token: str | None = None
extra: dict[str, Any] | None = None
async def _credential_table_ready(conn) -> bool:
return bool(
await conn.fetchval(
"SELECT to_regclass('app.admin_provider_credential') IS NOT NULL"
)
)
async def ensure_table() -> None:
"""테이블이 없을 때만 DDL을 실행한다.
앱 접속 계정(vignette_app)에는 스키마 CREATE 권한이 없다. Postgres는
``CREATE TABLE IF NOT EXISTS``가 no-op이어도 스키마 CREATE 권한을 먼저
검사하므로, 부트스트랩 SQL이 이미 만든 테이블이면 검사 없이 건너뛴다.
"""
pool = get_pool()
async with pool.acquire() as conn:
if await _credential_table_ready(conn):
return
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.admin_provider_credential (
provider TEXT PRIMARY KEY,
token_encrypted TEXT NOT NULL,
token_hint TEXT NOT NULL DEFAULT '',
auth_kind TEXT NOT NULL DEFAULT 'api_key'
CHECK (auth_kind IN ('api_key','oauth_token')),
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_verified_at TIMESTAMPTZ,
last_verify_ok BOOLEAN,
last_verify_error TEXT,
refresh_token_encrypted TEXT,
extra JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
await conn.execute(
"""
ALTER TABLE app.admin_provider_credential
ADD COLUMN IF NOT EXISTS refresh_token_encrypted TEXT,
ADD COLUMN IF NOT EXISTS extra JSONB NOT NULL DEFAULT '{}'::jsonb
"""
)
async def get_credential_row(provider: str) -> StoredCredential | None:
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT provider, token_hint, auth_kind, updated_by, updated_at,
last_verified_at, last_verify_ok, last_verify_error
FROM app.admin_provider_credential
WHERE provider = $1
""",
provider,
)
if row is None:
return None
return StoredCredential(
provider=row["provider"],
auth_kind=row["auth_kind"],
token_hint=row["token_hint"] or "저장됨",
updated_by=row["updated_by"],
updated_at=_row_ts(row["updated_at"]),
last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"],
refresh_token=decrypt_token(row["refresh_token_encrypted"])
if row.get("refresh_token_encrypted")
else None,
extra=row.get("extra") if isinstance(row.get("extra"), dict) else None,
)
async def get_plain_token(provider: str) -> tuple[str, str] | None:
"""저장된 토큰 원문과 auth_kind를 반환한다. 없으면 None."""
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT token_encrypted, auth_kind FROM app.admin_provider_credential WHERE provider = $1",
provider,
)
if row is None:
return None
return decrypt_token(row["token_encrypted"]), row["auth_kind"]
async def save_credential(
*,
provider: str,
token: str,
auth_kind: str,
updated_by: str,
refresh_token: str | None = None,
extra: dict[str, Any] | None = None,
) -> StoredCredential:
if provider not in PROVIDER_CODES:
raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}")
if auth_kind not in {"api_key", "oauth_token"}:
raise ProviderCredentialError("auth_kind는 api_key 또는 oauth_token이어야 합니다.")
plain = token.strip()
if not plain:
raise ProviderCredentialError("토큰이 비어 있습니다.")
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.admin_provider_credential (
provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at,
refresh_token_encrypted, extra
)
VALUES ($1, $2, $3, $4, $5, now(), $6, $7::jsonb)
ON CONFLICT (provider) DO UPDATE SET
token_encrypted = EXCLUDED.token_encrypted,
token_hint = EXCLUDED.token_hint,
auth_kind = EXCLUDED.auth_kind,
updated_by = EXCLUDED.updated_by,
updated_at = now(),
refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, refresh_token_encrypted),
extra = CASE WHEN EXCLUDED.extra::text = '{}'::jsonb THEN extra ELSE EXCLUDED.extra END,
last_verified_at = NULL,
last_verify_ok = NULL,
last_verify_error = NULL
RETURNING provider, token_hint, auth_kind, updated_by, updated_at,
last_verified_at, last_verify_ok, last_verify_error,
refresh_token_encrypted, extra
""",
provider,
encrypt_token(plain),
token_hint(plain),
auth_kind,
updated_by,
encrypt_token(refresh_token) if refresh_token and refresh_token.strip() else None,
json.dumps(extra or {}),
)
return StoredCredential(
provider=row["provider"],
auth_kind=row["auth_kind"],
token_hint=row["token_hint"] or "저장됨",
updated_by=row["updated_by"],
updated_at=_row_ts(row["updated_at"]),
last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"],
refresh_token=decrypt_token(row["refresh_token_encrypted"])
if row.get("refresh_token_encrypted")
else None,
extra=row.get("extra") if isinstance(row.get("extra"), dict) else None,
)
async def delete_credential(provider: str) -> bool:
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"DELETE FROM app.admin_provider_credential WHERE provider = $1 RETURNING provider",
provider,
)
return row is not None
async def mark_verified(provider: str, *, ok: bool, error: str | None) -> None:
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE app.admin_provider_credential
SET last_verified_at = now(), last_verify_ok = $2, last_verify_error = $3
WHERE provider = $1
""",
provider,
ok,
(error or "")[:500] if error else None,
)
# ── 게이트웨이 push 동기화 ────────────────────────────────────────────
_synced_boot_id: str | None = None
_synced_detail: str | None = None
def last_sync_state() -> tuple[str | None, str | None]:
return _synced_boot_id, _synced_detail
async def push_credentials_to_gateway() -> dict[str, Any]:
"""DB에 저장된 전체 자격증명을 게이트웨이 프로세스 환경으로 push한다.
실패해도 예외로 승격하지 않고 상태를 돌려준다 — 게이트웨이가 아직 안 떠 있어도
저장 자체는 성공해야 하고, 다음 동기화 기회(boot_id 불일치)에 다시 push된다.
"""
global _synced_boot_id, _synced_detail
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT provider, token_encrypted, auth_kind, refresh_token_encrypted, extra "
"FROM app.admin_provider_credential"
)
providers = {
row["provider"]: {
"token": decrypt_token(row["token_encrypted"]),
"auth_kind": row["auth_kind"],
**(
{
"refresh_token": decrypt_token(row["refresh_token_encrypted"]),
}
if row["refresh_token_encrypted"]
else {}
),
"extra": row["extra"] if isinstance(row["extra"], dict) else {},
}
for row in rows
if row["provider"] in PROVIDER_CODES
}
if not providers:
_synced_boot_id = None
_synced_detail = "저장된 자격증명이 없습니다."
return {"synced": False, "detail": _synced_detail}
try:
result = await engine_client.push_provider_credentials(providers)
status = await engine_client.provider_credentials_status()
_synced_boot_id = str(status.get("boot_id") or result.get("boot_id") or "")
_synced_detail = f"pushed: {','.join(result.get('applied') or [])}"
return {"synced": True, "boot_id": _synced_boot_id, "applied": result.get("applied") or []}
except EngineError as exc:
_synced_detail = str(exc)
return {"synced": False, "detail": _synced_detail}
async def ensure_gateway_synced() -> dict[str, Any]:
"""게이트웨이가 재시작(boot_id 변경)했으면 저장된 자격증명을 재-push한다."""
global _synced_boot_id, _synced_detail
try:
status = await engine_client.provider_credentials_status()
except EngineError as exc:
return {"synced": False, "detail": str(exc)}
boot_id = str(status.get("boot_id") or "")
if boot_id and boot_id == _synced_boot_id:
return {"synced": True, "boot_id": boot_id}
return await push_credentials_to_gateway()
# ── 연결 검증 ────────────────────────────────────────────────────────
_ANTHROPIC_BASE = os.environ.get("ANTHROPIC_API_BASE", "https://api.anthropic.com").rstrip("/")
_OPENAI_BASE = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
_OPENROUTER_BASE = os.environ.get("OPENROUTER_API_BASE", "https://openrouter.ai/api/v1").rstrip("/")
async def verify_provider(provider: str) -> dict[str, Any]:
"""저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다."""
stored = await get_plain_token(provider)
if stored is None:
return {"ok": False, "detail": "저장된 자격증명이 없습니다."}
token, auth_kind = stored
ok = False
detail = ""
try:
if provider == "claude":
headers = (
{"Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01"}
if auth_kind == "oauth_token"
else {"x-api-key": token, "anthropic-version": "2023-06-01"}
)
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(f"{_ANTHROPIC_BASE}/v1/models", params={"limit": 1}, headers=headers)
ok = response.status_code == 200
detail = "" if ok else f"Anthropic 응답 {response.status_code}"
elif provider == "codex":
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(f"{_OPENAI_BASE}/models", headers={"Authorization": f"Bearer {token}"})
ok = response.status_code == 200
detail = "" if ok else f"OpenAI 응답 {response.status_code}"
elif provider == "openrouter":
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(f"{_OPENROUTER_BASE}/key", headers={"Authorization": f"Bearer {token}"})
ok = response.status_code == 200
detail = "" if ok else f"OpenRouter 응답 {response.status_code}"
elif provider == "agy":
# Agy CLI 계정 인증은 게이트웨이의 agy models 조회(ready)가 담당한다.
# 여기서는 토큰 형식 보전 여부만 확인한다.
ok = len(token) >= 8
detail = "" if ok else "토큰이 너무 짧습니다."
else:
detail = f"지원하지 않는 provider입니다: {provider}"
except httpx.HTTPError as exc:
detail = f"연결 확인 요청 실패: {exc}"
await mark_verified(provider, ok=ok, error=None if ok else detail)
return {"ok": ok, "detail": detail, "verified_at": time.time()}