OAuth 로그인 버튼으로 provider 연결 + 제공자 연결 패널 그리드 이동
Some checks failed
API contract / OpenAPI type drift (push) Failing after 59s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 59s
claude(setup-token과 동일한 PKCE, code#state 수동 흐름)와 openrouter(헤드리스 코드 표시 모드)는 OAuth 로그인 버튼으로 연결한다. 관리자가 제공자 로그인 뒤 표시되는 인증 코드를 붙여넣으면 서버가 토큰/키로 교환해 저장·게이트웨이 push한다. state는 서버 발급·15분 단일 사용으로 CSRF를 막는다. codex·agy는 붙여넣기 방식을 유지한다. 제공자 연결 패널은 우측 사이드에서 메인 컬럼의 Provider·모델별 사용 원장 아래로 옮기고 반응형 그리드(너비에 따라 1~n열)로 배치한다.
This commit is contained in:
parent
e3a9fc717c
commit
cb4d45579d
9 changed files with 752 additions and 39 deletions
|
|
@ -39,6 +39,7 @@ from ..engine_client import engine_client
|
||||||
from ..runtime_policy import require_runtime_fallback_allowed
|
from ..runtime_policy import require_runtime_fallback_allowed
|
||||||
from ..services import evaluator, notifications, rag
|
from ..services import evaluator, notifications, rag
|
||||||
from ..services import provider_credentials as provider_credentials_service
|
from ..services import provider_credentials as provider_credentials_service
|
||||||
|
from ..services import provider_oauth as provider_oauth_service
|
||||||
from ..services.llm_pricing import (
|
from ..services.llm_pricing import (
|
||||||
estimate_reference_cost,
|
estimate_reference_cost,
|
||||||
provider_uses_reference_cost,
|
provider_uses_reference_cost,
|
||||||
|
|
@ -350,6 +351,7 @@ class AdminProviderCredentialStatus(BaseModel):
|
||||||
token_page_label: str
|
token_page_label: str
|
||||||
guide: str
|
guide: str
|
||||||
allowed_auth_kinds: list[str]
|
allowed_auth_kinds: list[str]
|
||||||
|
oauth_supported: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AdminProvidersResponse(BaseModel):
|
class AdminProvidersResponse(BaseModel):
|
||||||
|
|
@ -384,6 +386,18 @@ class AdminProviderVerifyResponse(BaseModel):
|
||||||
verified_at: float | None = None
|
verified_at: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminProviderOAuthStartResponse(BaseModel):
|
||||||
|
provider: str
|
||||||
|
authorize_url: str
|
||||||
|
state: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminProviderOAuthFinishRequest(BaseModel):
|
||||||
|
code: str = Field(min_length=4, max_length=8192)
|
||||||
|
state: str = Field(min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class AdminTicketPatch(BaseModel):
|
class AdminTicketPatch(BaseModel):
|
||||||
status: TicketStatus | None = None
|
status: TicketStatus | None = None
|
||||||
priority: TicketPriority | None = None
|
priority: TicketPriority | None = None
|
||||||
|
|
@ -2391,6 +2405,7 @@ async def list_providers(principal: AdminPrincipal) -> AdminProvidersResponse:
|
||||||
token_page_label=meta.token_page_label,
|
token_page_label=meta.token_page_label,
|
||||||
guide=meta.guide,
|
guide=meta.guide,
|
||||||
allowed_auth_kinds=list(meta.auth_kinds),
|
allowed_auth_kinds=list(meta.auth_kinds),
|
||||||
|
oauth_supported=provider_oauth_service.oauth_supported(meta.code),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return AdminProvidersResponse(
|
return AdminProvidersResponse(
|
||||||
|
|
@ -2487,6 +2502,54 @@ async def verify_provider_credential(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider}/oauth/start",
|
||||||
|
response_model=AdminProviderOAuthStartResponse,
|
||||||
|
)
|
||||||
|
async def start_provider_oauth(
|
||||||
|
provider: str,
|
||||||
|
principal: AdminPrincipal,
|
||||||
|
) -> AdminProviderOAuthStartResponse:
|
||||||
|
"""OAuth 로그인 시작 — 제공자 인증 화면 URL을 발급한다."""
|
||||||
|
try:
|
||||||
|
result = provider_oauth_service.start_oauth(provider, principal.email)
|
||||||
|
except provider_oauth_service.ProviderCredentialError as exc:
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
return AdminProviderOAuthStartResponse(
|
||||||
|
provider=provider,
|
||||||
|
authorize_url=result["authorize_url"],
|
||||||
|
state=result["state"],
|
||||||
|
expires_in=result["expires_in"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider}/oauth/finish",
|
||||||
|
response_model=AdminProviderCredentialResponse,
|
||||||
|
)
|
||||||
|
async def finish_provider_oauth(
|
||||||
|
provider: str,
|
||||||
|
body: AdminProviderOAuthFinishRequest,
|
||||||
|
principal: AdminPrincipal,
|
||||||
|
) -> AdminProviderCredentialResponse:
|
||||||
|
"""붙여넣은 인증 코드를 토큰으로 교환해 저장하고 게이트웨이에 push한다."""
|
||||||
|
try:
|
||||||
|
result = await provider_oauth_service.finish_oauth(
|
||||||
|
provider, body.code, body.state, principal.email
|
||||||
|
)
|
||||||
|
except provider_oauth_service.ProviderCredentialError as exc:
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
stored = result["stored"]
|
||||||
|
return AdminProviderCredentialResponse(
|
||||||
|
provider=stored.provider,
|
||||||
|
token_hint=stored.token_hint,
|
||||||
|
auth_kind=stored.auth_kind,
|
||||||
|
updated_by=stored.updated_by,
|
||||||
|
updated_at=stored.updated_at,
|
||||||
|
gateway_sync=_provider_sync_response(result["gateway_sync"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=AdminUsersResponse)
|
@router.get("/users", response_model=AdminUsersResponse)
|
||||||
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
|
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
|
||||||
"""Return users observed by the server-side auth/session boundary."""
|
"""Return users observed by the server-side auth/session boundary."""
|
||||||
|
|
|
||||||
202
apps/api/app/services/provider_oauth.py
Normal file
202
apps/api/app/services/provider_oauth.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""provider OAuth 로그인(PKCE) — 로그인해서 코드를 받아 토큰으로 교환한다.
|
||||||
|
|
||||||
|
관리자가 "OAuth 로그인" 버튼으로 제공자 인증 화면을 열고, 제공자가 보여주는
|
||||||
|
인증 코드를 복사해 붙여넣으면 서버가 토큰으로 교환해 저장·게이트웨이에 push한다.
|
||||||
|
|
||||||
|
- claude: ``claude setup-token``과 동일한 Authorization Code + PKCE 흐름.
|
||||||
|
콜백 페이지가 보여주는 ``code#state`` 문자열을 그대로 붙여넣는다.
|
||||||
|
스펙 근거: code.claude.com/docs/en/authentication 및 Claude Code OAuth 설정.
|
||||||
|
- openrouter: 헤드리스 모드(콜백 URL 없이 코드를 화면에 표시). 스펙 근거:
|
||||||
|
openrouter.ai/docs/oauth. 교환 결과는 사용자 귀속 API 키다.
|
||||||
|
|
||||||
|
state는 서버가 발급해 15분 보관하며, 교환 시 provider·만료를 검증한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .provider_credentials import (
|
||||||
|
ProviderCredentialError,
|
||||||
|
push_credentials_to_gateway,
|
||||||
|
save_credential,
|
||||||
|
)
|
||||||
|
|
||||||
|
OAUTH_PENDING_TTL_SECONDS = 900.0
|
||||||
|
|
||||||
|
# claude: setup-token과 동일한 공개 클라이언트. 콜백 페이지가 code#state를
|
||||||
|
# 화면에 표시하는 수동 흐름이라 사전 등록·로컬 콜백 서버가 필요 없다.
|
||||||
|
_CLAUDE_OAUTH = {
|
||||||
|
"authorize_url": "https://claude.ai/oauth/authorize",
|
||||||
|
"token_url": "https://platform.claude.com/v1/oauth/token",
|
||||||
|
"redirect_uri": "https://platform.claude.com/oauth/code/callback",
|
||||||
|
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||||
|
"scope": "user:inference",
|
||||||
|
"expires_in": 365 * 24 * 60 * 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
# openrouter: 콜백 없이 인증 코드를 화면에 표시하는 헤드리스 모드.
|
||||||
|
_OPENROUTER_OAUTH = {
|
||||||
|
"authorize_url": "https://openrouter.ai/auth",
|
||||||
|
"exchange_url": "https://openrouter.ai/api/v1/auth/keys",
|
||||||
|
"key_label": "Vignette 관리자 연결",
|
||||||
|
}
|
||||||
|
|
||||||
|
OAUTH_PROVIDERS = ("claude", "openrouter")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PendingOAuth:
|
||||||
|
provider: str
|
||||||
|
code_verifier: str
|
||||||
|
created_by: str
|
||||||
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
|
_PENDING: dict[str, _PendingOAuth] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_supported(provider: str) -> bool:
|
||||||
|
return provider in OAUTH_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired() -> None:
|
||||||
|
now = time.time()
|
||||||
|
expired = [key for key, item in _PENDING.items() if now - item.created_at > OAUTH_PENDING_TTL_SECONDS]
|
||||||
|
for key in expired:
|
||||||
|
_PENDING.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pkce_pair() -> tuple[str, str]:
|
||||||
|
verifier = secrets.token_urlsafe(48)
|
||||||
|
challenge = base64.urlsafe_b64encode(
|
||||||
|
hashlib.sha256(verifier.encode("ascii")).digest()
|
||||||
|
).decode("ascii").rstrip("=")
|
||||||
|
return verifier, challenge
|
||||||
|
|
||||||
|
|
||||||
|
def start_oauth(provider: str, admin_email: str) -> dict[str, Any]:
|
||||||
|
"""OAuth 시작. 제공자 인증 화면 URL과 서버가 보관하는 state를 돌려준다."""
|
||||||
|
if provider not in OAUTH_PROVIDERS:
|
||||||
|
raise ProviderCredentialError(f"{provider}는 OAuth 로그인을 지원하지 않습니다.")
|
||||||
|
_purge_expired()
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
code_verifier, code_challenge = _pkce_pair()
|
||||||
|
_PENDING[state] = _PendingOAuth(
|
||||||
|
provider=provider,
|
||||||
|
code_verifier=code_verifier,
|
||||||
|
created_by=admin_email,
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider == "claude":
|
||||||
|
authorize_url = (
|
||||||
|
f"{_CLAUDE_OAUTH['authorize_url']}"
|
||||||
|
"?code=true"
|
||||||
|
"&response_type=code"
|
||||||
|
f"&client_id={_CLAUDE_OAUTH['client_id']}"
|
||||||
|
f"&redirect_uri={_CLAUDE_OAUTH['redirect_uri']}"
|
||||||
|
f"&scope={_CLAUDE_OAUTH['scope'].replace(':', '%3A')}"
|
||||||
|
f"&code_challenge={code_challenge}"
|
||||||
|
"&code_challenge_method=S256"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
authorize_url = (
|
||||||
|
f"{_OPENROUTER_OAUTH['authorize_url']}"
|
||||||
|
f"?code_challenge={code_challenge}"
|
||||||
|
"&code_challenge_method=S256"
|
||||||
|
f"&key_label={_OPENROUTER_OAUTH['key_label']}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"authorize_url": authorize_url,
|
||||||
|
"state": state,
|
||||||
|
"expires_in": int(OAUTH_PENDING_TTL_SECONDS),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pop_pending(provider: str, state: str) -> _PendingOAuth:
|
||||||
|
_purge_expired()
|
||||||
|
attempt = _PENDING.get(state)
|
||||||
|
if attempt is None:
|
||||||
|
raise ProviderCredentialError("OAuth 요청이 만료되었거나 존재하지 않습니다. 다시 로그인해 주세요.")
|
||||||
|
_PENDING.pop(state, None)
|
||||||
|
if attempt.provider != provider:
|
||||||
|
raise ProviderCredentialError("OAuth 요청의 provider가 일치하지 않습니다.")
|
||||||
|
return attempt
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_oauth(provider: str, code: str, state: str, admin_email: str) -> dict[str, Any]:
|
||||||
|
"""붙여넣은 인증 코드를 토큰/키로 교환해 저장하고 게이트웨이에 push한다."""
|
||||||
|
attempt = _pop_pending(provider, state)
|
||||||
|
pasted = code.strip()
|
||||||
|
|
||||||
|
if provider == "claude":
|
||||||
|
# 콜백 페이지 표시 형식: authorizationCode#state
|
||||||
|
parts = pasted.split("#")
|
||||||
|
if len(parts) != 2 or parts[1].strip() != state:
|
||||||
|
raise ProviderCredentialError(
|
||||||
|
"인증 코드 형식이 올바르지 않습니다. 콜백 화면의 전체 코드(code#state)를 붙여넣으세요."
|
||||||
|
)
|
||||||
|
auth_code = parts[0].strip()
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": auth_code,
|
||||||
|
"redirect_uri": _CLAUDE_OAUTH["redirect_uri"],
|
||||||
|
"client_id": _CLAUDE_OAUTH["client_id"],
|
||||||
|
"code_verifier": attempt.code_verifier,
|
||||||
|
"state": state,
|
||||||
|
"expires_in": _CLAUDE_OAUTH["expires_in"],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_CLAUDE_OAUTH["token_url"],
|
||||||
|
json=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
raise ProviderCredentialError(f"Claude 토큰 교환 실패: {exc}") from exc
|
||||||
|
token = str(body.get("access_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise ProviderCredentialError("Claude가 액세스 토큰을 반환하지 않았습니다.")
|
||||||
|
auth_kind = "oauth_token"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_OPENROUTER_OAUTH["exchange_url"],
|
||||||
|
json={
|
||||||
|
"code": pasted,
|
||||||
|
"code_verifier": attempt.code_verifier,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
raise ProviderCredentialError(f"OpenRouter 키 교환 실패: {exc}") from exc
|
||||||
|
token = str(body.get("key") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise ProviderCredentialError("OpenRouter가 API 키를 반환하지 않았습니다.")
|
||||||
|
auth_kind = "api_key"
|
||||||
|
|
||||||
|
stored = await save_credential(
|
||||||
|
provider=provider,
|
||||||
|
token=token,
|
||||||
|
auth_kind=auth_kind,
|
||||||
|
updated_by=admin_email,
|
||||||
|
)
|
||||||
|
sync = await push_credentials_to_gateway()
|
||||||
|
return {"stored": stored, "gateway_sync": sync}
|
||||||
136
apps/api/app/test_provider_oauth.py
Normal file
136
apps/api/app/test_provider_oauth.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""provider OAuth(PKCE) 서비스 단위 테스트 — 실제 네트워크 호출 없음."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.services import provider_oauth as svc
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload, status_code=200):
|
||||||
|
self._payload = payload
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
raise httpx.HTTPStatusError("err", request=None, response=None)
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._payload = payload
|
||||||
|
self.captured = {}
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, url, **kwargs):
|
||||||
|
self.captured = {"url": url, **kwargs}
|
||||||
|
return _FakeResponse(self._payload)
|
||||||
|
|
||||||
|
|
||||||
|
class StartOAuthTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
svc._PENDING.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
svc._PENDING.clear()
|
||||||
|
|
||||||
|
def test_unsupported_provider_rejected(self):
|
||||||
|
with self.assertRaises(svc.ProviderCredentialError):
|
||||||
|
svc.start_oauth("codex", "admin@example.com")
|
||||||
|
|
||||||
|
def test_claude_authorize_url_contains_pkce_and_state(self):
|
||||||
|
result = svc.start_oauth("claude", "admin@example.com")
|
||||||
|
self.assertIn("https://claude.ai/oauth/authorize", result["authorize_url"])
|
||||||
|
self.assertIn("code_challenge_method=S256", result["authorize_url"])
|
||||||
|
self.assertIn(f"&state={result['state']}", result["authorize_url"])
|
||||||
|
self.assertIn("user%3Ainference", result["authorize_url"])
|
||||||
|
self.assertIn(result["state"], svc._PENDING)
|
||||||
|
|
||||||
|
def test_openrouter_authorize_url_is_headless(self):
|
||||||
|
result = svc.start_oauth("openrouter", "admin@example.com")
|
||||||
|
self.assertIn("https://openrouter.ai/auth?", result["authorize_url"])
|
||||||
|
self.assertIn("code_challenge_method=S256", result["authorize_url"])
|
||||||
|
self.assertNotIn("callback_url", result["authorize_url"])
|
||||||
|
|
||||||
|
def test_pending_expires(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
result = svc.start_oauth("claude", "admin@example.com")
|
||||||
|
attempt = svc._PENDING[result["state"]]
|
||||||
|
stale = svc._PendingOAuth(
|
||||||
|
provider=attempt.provider,
|
||||||
|
code_verifier=attempt.code_verifier,
|
||||||
|
created_by=attempt.created_by,
|
||||||
|
created_at=time.time() - svc.OAUTH_PENDING_TTL_SECONDS - 1,
|
||||||
|
)
|
||||||
|
svc._PENDING[result["state"]] = stale
|
||||||
|
with self.assertRaises(svc.ProviderCredentialError):
|
||||||
|
svc._pop_pending("claude", result["state"])
|
||||||
|
|
||||||
|
|
||||||
|
class FinishOAuthTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
svc._PENDING.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
svc._PENDING.clear()
|
||||||
|
|
||||||
|
async def test_claude_code_state_exchange_and_save(self):
|
||||||
|
start = svc.start_oauth("claude", "admin@example.com")
|
||||||
|
fake = _FakeAsyncClient({"access_token": "sk-ant-oat-token-abc", "expires_in": 31536000})
|
||||||
|
with patch.object(svc.httpx, "AsyncClient", return_value=fake), patch.object(
|
||||||
|
svc, "save_credential"
|
||||||
|
) as save, patch.object(svc, "push_credentials_to_gateway") as push:
|
||||||
|
save.return_value = SimpleNamespace(
|
||||||
|
provider="claude", token_hint="…oat", auth_kind="oauth_token",
|
||||||
|
updated_by="admin@example.com", updated_at=1.0,
|
||||||
|
)
|
||||||
|
push.return_value = {"synced": True}
|
||||||
|
result = await svc.finish_oauth(
|
||||||
|
"claude", f"auth-code-123#{start['state']}", start["state"], "admin@example.com"
|
||||||
|
)
|
||||||
|
self.assertEqual(result["stored"].auth_kind, "oauth_token")
|
||||||
|
sent = fake.captured["json"]
|
||||||
|
self.assertEqual(sent["grant_type"], "authorization_code")
|
||||||
|
self.assertEqual(sent["code"], "auth-code-123")
|
||||||
|
self.assertIn("code_verifier", sent)
|
||||||
|
self.assertEqual(fake.captured["url"], svc._CLAUDE_OAUTH["token_url"])
|
||||||
|
|
||||||
|
async def test_state_mismatch_rejected(self):
|
||||||
|
start = svc.start_oauth("claude", "admin@example.com")
|
||||||
|
with self.assertRaises(svc.ProviderCredentialError):
|
||||||
|
await svc.finish_oauth("claude", "code#other-state", start["state"], "a@b.com")
|
||||||
|
|
||||||
|
async def test_openrouter_code_exchange_returns_api_key(self):
|
||||||
|
start = svc.start_oauth("openrouter", "admin@example.com")
|
||||||
|
fake = _FakeAsyncClient({"key": "sk-or-v1-oauth-key"})
|
||||||
|
with patch.object(svc.httpx, "AsyncClient", return_value=fake), patch.object(
|
||||||
|
svc, "save_credential"
|
||||||
|
) as save, patch.object(svc, "push_credentials_to_gateway") as push:
|
||||||
|
save.return_value = SimpleNamespace(
|
||||||
|
provider="openrouter", token_hint="…key", auth_kind="api_key",
|
||||||
|
updated_by="admin@example.com", updated_at=1.0,
|
||||||
|
)
|
||||||
|
push.return_value = {"synced": True}
|
||||||
|
result = await svc.finish_oauth(
|
||||||
|
"openrouter", "or-code-xyz", start["state"], "admin@example.com"
|
||||||
|
)
|
||||||
|
self.assertEqual(result["stored"].auth_kind, "api_key")
|
||||||
|
self.assertEqual(fake.captured["url"], svc._OPENROUTER_OAUTH["exchange_url"])
|
||||||
|
self.assertEqual(fake.captured["json"]["code"], "or-code-xyz")
|
||||||
|
self.assertEqual(fake.captured["json"]["code_challenge_method"], "S256")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -224,6 +224,46 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/providers/{provider}/oauth/finish": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Finish Provider Oauth
|
||||||
|
* @description 붙여넣은 인증 코드를 토큰으로 교환해 저장하고 게이트웨이에 push한다.
|
||||||
|
*/
|
||||||
|
post: operations["finish_provider_oauth_admin_providers__provider__oauth_finish_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/providers/{provider}/oauth/start": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Start Provider Oauth
|
||||||
|
* @description OAuth 로그인 시작 — 제공자 인증 화면 URL을 발급한다.
|
||||||
|
*/
|
||||||
|
post: operations["start_provider_oauth_admin_providers__provider__oauth_start_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/providers/{provider}/verify": {
|
"/admin/providers/{provider}/verify": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3024,6 +3064,11 @@ export interface components {
|
||||||
last_verify_error?: string | null;
|
last_verify_error?: string | null;
|
||||||
/** Last Verify Ok */
|
/** Last Verify Ok */
|
||||||
last_verify_ok?: boolean | null;
|
last_verify_ok?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Oauth Supported
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
oauth_supported: boolean;
|
||||||
/** Provider */
|
/** Provider */
|
||||||
provider: string;
|
provider: string;
|
||||||
/** Token Hint */
|
/** Token Hint */
|
||||||
|
|
@ -3054,6 +3099,24 @@ export interface components {
|
||||||
/** Synced */
|
/** Synced */
|
||||||
synced: boolean;
|
synced: boolean;
|
||||||
};
|
};
|
||||||
|
/** AdminProviderOAuthFinishRequest */
|
||||||
|
AdminProviderOAuthFinishRequest: {
|
||||||
|
/** Code */
|
||||||
|
code: string;
|
||||||
|
/** State */
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
/** AdminProviderOAuthStartResponse */
|
||||||
|
AdminProviderOAuthStartResponse: {
|
||||||
|
/** Authorize Url */
|
||||||
|
authorize_url: string;
|
||||||
|
/** Expires In */
|
||||||
|
expires_in: number;
|
||||||
|
/** Provider */
|
||||||
|
provider: string;
|
||||||
|
/** State */
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
/** AdminProviderVerifyResponse */
|
/** AdminProviderVerifyResponse */
|
||||||
AdminProviderVerifyResponse: {
|
AdminProviderVerifyResponse: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
|
|
@ -12103,6 +12166,78 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
finish_provider_oauth_admin_providers__provider__oauth_finish_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
provider: string;
|
||||||
|
};
|
||||||
|
cookie?: {
|
||||||
|
"__Host-vignette_sid"?: string | null;
|
||||||
|
vignette_sid?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminProviderOAuthFinishRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminProviderCredentialResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
start_provider_oauth_admin_providers__provider__oauth_start_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
provider: string;
|
||||||
|
};
|
||||||
|
cookie?: {
|
||||||
|
"__Host-vignette_sid"?: string | null;
|
||||||
|
vignette_sid?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminProviderOAuthStartResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
verify_provider_credential_admin_providers__provider__verify_post: {
|
verify_provider_credential_admin_providers__provider__verify_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -812,8 +812,21 @@ export const adminProviderApi = {
|
||||||
`/admin/providers/${encodeURIComponent(provider)}/verify`,
|
`/admin/providers/${encodeURIComponent(provider)}/verify`,
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
|
startOauth: (provider: string) =>
|
||||||
|
api.post<AdminProviderOAuthStartResponse>(
|
||||||
|
`/admin/providers/${encodeURIComponent(provider)}/oauth/start`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
finishOauth: (provider: string, body: { code: string; state: string }) =>
|
||||||
|
apiFetch<AdminProviderCredentialResponse>(
|
||||||
|
`/admin/providers/${encodeURIComponent(provider)}/oauth/finish`,
|
||||||
|
{ method: "POST", body },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminProviderOAuthStartResponse =
|
||||||
|
ApiSchema<"AdminProviderOAuthStartResponse">;
|
||||||
|
|
||||||
export const adminEngineApi = {
|
export const adminEngineApi = {
|
||||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
||||||
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
|
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
|
||||||
|
|
|
||||||
|
|
@ -690,10 +690,11 @@ export default function AdminAi() {
|
||||||
<div className="aic-empty">모델별 계량 행이 아직 없습니다.</div>
|
<div className="aic-empty">모델별 계량 행이 아직 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ProviderConnectionsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="aic-column aic-column--side">
|
<div className="aic-column aic-column--side">
|
||||||
<ProviderConnectionsPanel />
|
|
||||||
<section className="aic-panel aic-engine">
|
<section className="aic-panel aic-engine">
|
||||||
<div className="aic-panel__head">
|
<div className="aic-panel__head">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ export function ProviderConnectionsPanel() {
|
||||||
const [verifies, setVerifies] = useState<
|
const [verifies, setVerifies] = useState<
|
||||||
Record<string, { ok: boolean; detail: string }>
|
Record<string, { ok: boolean; detail: string }>
|
||||||
>({});
|
>({});
|
||||||
|
const [oauthStates, setOauthStates] = useState<
|
||||||
|
Record<string, { state: string; authorizing: boolean; error: string | null }>
|
||||||
|
>({});
|
||||||
const requestSeq = useRef(0);
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -143,6 +146,73 @@ export function ProviderConnectionsPanel() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startOauth = async (provider: AdminProviderCredentialStatus) => {
|
||||||
|
setOauthStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: { state: "", authorizing: true, error: null },
|
||||||
|
}));
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await adminProviderApi.startOauth(provider.provider);
|
||||||
|
window.open(result.authorize_url, "_blank", "noopener");
|
||||||
|
setOauthStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: { state: result.state, authorizing: false, error: null },
|
||||||
|
}));
|
||||||
|
} catch (cause) {
|
||||||
|
setOauthStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: {
|
||||||
|
state: "",
|
||||||
|
authorizing: false,
|
||||||
|
error:
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "OAuth 로그인을 시작하지 못했습니다.",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishOauth = async (provider: AdminProviderCredentialStatus) => {
|
||||||
|
const oauth = oauthStates[provider.provider];
|
||||||
|
const code = (drafts[provider.provider] ?? "").trim();
|
||||||
|
if (!oauth?.state || !code) return;
|
||||||
|
setBusy(`oauth:${provider.provider}`);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await adminProviderApi.finishOauth(provider.provider, {
|
||||||
|
code,
|
||||||
|
state: oauth.state,
|
||||||
|
});
|
||||||
|
setDrafts((current) => ({ ...current, [provider.provider]: "" }));
|
||||||
|
setOauthStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: { state: "", authorizing: false, error: null },
|
||||||
|
}));
|
||||||
|
showFlash(
|
||||||
|
result.gateway_sync.synced
|
||||||
|
? `${provider.label} OAuth 로그인으로 연결하고 게이트웨이에 적용했습니다.`
|
||||||
|
: `${provider.label} OAuth 연결은 저장됐지만 게이트웨이 적용은 대기 중입니다.`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (cause) {
|
||||||
|
setOauthStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: {
|
||||||
|
state: oauth.state,
|
||||||
|
authorizing: false,
|
||||||
|
error:
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "인증 코드를 확인하지 못했습니다.",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const providers = data?.providers ?? [];
|
const providers = data?.providers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -178,6 +248,8 @@ export function ProviderConnectionsPanel() {
|
||||||
const saving = busy === `save:${provider.provider}`;
|
const saving = busy === `save:${provider.provider}`;
|
||||||
const verifying = busy === `verify:${provider.provider}`;
|
const verifying = busy === `verify:${provider.provider}`;
|
||||||
const deleting = busy === `delete:${provider.provider}`;
|
const deleting = busy === `delete:${provider.provider}`;
|
||||||
|
const oauthFinishing = busy === `oauth:${provider.provider}`;
|
||||||
|
const oauth = oauthStates[provider.provider];
|
||||||
const verifyResult = verifies[provider.provider];
|
const verifyResult = verifies[provider.provider];
|
||||||
const hasDraft = Boolean((drafts[provider.provider] ?? "").trim());
|
const hasDraft = Boolean((drafts[provider.provider] ?? "").trim());
|
||||||
return (
|
return (
|
||||||
|
|
@ -205,10 +277,83 @@ export function ProviderConnectionsPanel() {
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="aic-provider__controls">
|
<div className="aic-provider__controls">
|
||||||
|
{provider.oauth_supported ? (
|
||||||
|
<div className="aic-provider__oauth">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={oauth?.authorizing || Boolean(oauth?.state)}
|
||||||
|
onClick={() => void startOauth(provider)}
|
||||||
|
>
|
||||||
|
{oauth?.authorizing ? "로그인 페이지 여는 중" : "OAuth 로그인"}
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
{oauth?.state
|
||||||
|
? "제공자 로그인 뒤 표시된 인증 코드를 아래에 붙여넣으세요."
|
||||||
|
: provider.connected
|
||||||
|
? "클릭하면 제공자 로그인 화면이 열립니다. 다른 계정으로 재연결할 수 있습니다."
|
||||||
|
: "클릭하면 제공자 로그인 화면이 열립니다."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{oauth?.state ? (
|
||||||
|
<>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={drafts[provider.provider] ?? ""}
|
value={drafts[provider.provider] ?? ""}
|
||||||
placeholder={provider.connected ? "새 토큰으로 교체" : "발급받은 토큰을 붙여넣으세요"}
|
placeholder={
|
||||||
|
provider.provider === "claude"
|
||||||
|
? "인증 코드 전체를 붙여넣으세요(예: code#state)"
|
||||||
|
: "표시된 인증 코드를 붙여넣으세요"
|
||||||
|
}
|
||||||
|
aria-label={`${provider.label} OAuth 인증 코드`}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="aic-provider__actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={oauthFinishing || !hasDraft}
|
||||||
|
onClick={() => void finishOauth(provider)}
|
||||||
|
>
|
||||||
|
{oauthFinishing ? "연결 중" : "인증 코드로 연결"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setOauthStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider.provider]: {
|
||||||
|
state: "",
|
||||||
|
authorizing: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{oauth.error ? (
|
||||||
|
<p className="aic-provider__verify is-fail" role="alert">
|
||||||
|
{oauth.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={drafts[provider.provider] ?? ""}
|
||||||
|
placeholder={
|
||||||
|
provider.connected ? "새 토큰으로 교체" : "발급받은 토큰을 붙여넣으세요"
|
||||||
|
}
|
||||||
aria-label={`${provider.label} 토큰`}
|
aria-label={`${provider.label} 토큰`}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
|
|
@ -245,6 +390,10 @@ export function ProviderConnectionsPanel() {
|
||||||
>
|
>
|
||||||
{saving ? "저장 중" : "저장"}
|
{saving ? "저장 중" : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="aic-provider__actions">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,7 @@
|
||||||
/* ── 제공자 연결 (ProviderConnectionsPanel) ───────────────────── */
|
/* ── 제공자 연결 (ProviderConnectionsPanel) ───────────────────── */
|
||||||
.aic-providers .aic-provider__list {
|
.aic-providers .aic-provider__list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -964,3 +965,15 @@
|
||||||
color: var(--ok-text, #2e7d32);
|
color: var(--ok-text, #2e7d32);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aic-provider__oauth {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aic-provider__oauth span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
|
||||||
1
infra/.tmp-colq.b64
Normal file
1
infra/.tmp-colq.b64
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
U0VMRUNUIGNvbHVtbl9uYW1lIEZST00gaW5mb3JtYXRpb25fc2NoZW1hLmNvbHVtbnMgV0hFUkUgdGFibGVfbmFtZSA9ICdhZG1pbl9wcm92aWRlcl9jcmVkZW50aWFsJyBPUkRFUiBCWSBvcmRpbmFsX3Bvc2l0aW9uOw==
|
||||||
Loading…
Add table
Add a link
Reference in a new issue