OAuth 로그인 버튼으로 provider 연결 + 제공자 연결 패널 그리드 이동
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:
Yun Chan 2026-09-11 17:33:51 +09:00
parent e3a9fc717c
commit cb4d45579d
9 changed files with 752 additions and 39 deletions

View file

@ -39,6 +39,7 @@ from ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, notifications, rag
from ..services import provider_credentials as provider_credentials_service
from ..services import provider_oauth as provider_oauth_service
from ..services.llm_pricing import (
estimate_reference_cost,
provider_uses_reference_cost,
@ -350,6 +351,7 @@ class AdminProviderCredentialStatus(BaseModel):
token_page_label: str
guide: str
allowed_auth_kinds: list[str]
oauth_supported: bool = False
class AdminProvidersResponse(BaseModel):
@ -384,6 +386,18 @@ class AdminProviderVerifyResponse(BaseModel):
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):
status: TicketStatus | None = None
priority: TicketPriority | None = None
@ -2391,6 +2405,7 @@ async def list_providers(principal: AdminPrincipal) -> AdminProvidersResponse:
token_page_label=meta.token_page_label,
guide=meta.guide,
allowed_auth_kinds=list(meta.auth_kinds),
oauth_supported=provider_oauth_service.oauth_supported(meta.code),
)
)
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)
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
"""Return users observed by the server-side auth/session boundary."""

View 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}

View 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()