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
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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue