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."""