vignette/apps/api/app/services/provider_oauth.py
Yun Chan fefca4743e
Some checks failed
API contract / OpenAPI type drift (push) Failing after 48s
누락된 OAuth 콜백 코드 파싱과 계정 metadata 헬퍼 구현
2026-09-23 18:42:19 +09:00

441 lines
18 KiB
Python

"""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 asyncio
import base64
import hashlib
import json
import secrets
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import parse_qs, urlsplit
import httpx
from .provider_credentials import (
ProviderCredentialError,
push_credentials_to_gateway,
save_credential,
)
OAUTH_PENDING_TTL_SECONDS = 900.0
# Code Assist 온보딩(onboardUser)은 장기 실행 작업이라 완료까지 짧게 폴링한다.
_ONBOARD_POLL_ATTEMPTS = 10
_ONBOARD_POLL_INTERVAL_SECONDS = 2.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 관리자 연결",
}
# codex: Codex CLI의 공개 Auth0 클라이언트. 콜백은 로컬 루프백이라 아무것도
# 수신하지 않는다 — 브라우저 주소창에 남는 code(필요하면 state)를 복사한다.
# 토큰은 api.openai.com이 아니라 ChatGPT 백엔드(chatgpt.com/backend-api/codex)에서만 쓴다.
_CODEX_OAUTH = {
"authorize_url": "https://auth.openai.com/oauth/authorize",
"token_url": "https://auth.openai.com/oauth/token",
"redirect_uri": "http://localhost:1455/auth/callback",
"client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
"scope": "openid profile email offline_access",
}
# agy(Antigravity CLI): 구글 공개 네이티브 클라이언트. 콜백은 루프백이므로
# 브라우저 주소창의 code를 복사한다. 교환 후 Code Assist 프로젝트를 발급받아 metadata로 저장한다.
_AGY_OAUTH = {
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"redirect_uri": "http://localhost:1455/auth/callback",
"client_id": "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
"client_secret": "GOCSPX-K58FWR486LdLJ1mLB8sXC4zqDAf",
"scope": (
"https://www.googleapis.com/auth/cloud-platform "
"https://www.googleapis.com/auth/userinfo.email "
"https://www.googleapis.com/auth/userinfo.profile "
"https://www.googleapis.com/auth/cclog "
"https://www.googleapis.com/auth/experimentsandconfigs"
),
"user_agent": "antigravity/cli/1.0.0 (aidev_client; os_type=windows; arch=amd64; auth_method=consumer)",
"code_assist_base": "https://cloudcode-pa.googleapis.com",
"plugin_type": "GEMINI",
}
OAUTH_PROVIDERS = ("claude", "openrouter", "codex", "agy")
@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}"
)
elif provider == "openrouter":
authorize_url = (
f"{_OPENROUTER_OAUTH['authorize_url']}"
f"?code_challenge={code_challenge}"
"&code_challenge_method=S256"
f"&key_label={_OPENROUTER_OAUTH['key_label']}"
)
elif provider == "codex":
authorize_url = (
f"{_CODEX_OAUTH['authorize_url']}"
"?response_type=code"
f"&client_id={_CODEX_OAUTH['client_id']}"
f"&redirect_uri={_CODEX_OAUTH['redirect_uri']}"
f"&scope={_CODEX_OAUTH['scope'].replace(' ', '%20')}"
f"&code_challenge={code_challenge}"
"&code_challenge_method=S256"
f"&state={state}"
"&id_token_add_organizations=true"
"&codex_cli_simplified_flow=true"
"&originator=codex_cli_rs"
"&prompt=login"
)
else:
authorize_url = (
f"{_AGY_OAUTH['authorize_url']}"
"?response_type=code"
"&access_type=offline"
"&prompt=consent"
f"&client_id={_AGY_OAUTH['client_id']}"
f"&redirect_uri={_AGY_OAUTH['redirect_uri']}"
f"&scope={_AGY_OAUTH['scope'].replace(' ', '%20')}"
f"&state={state}"
)
return {
"authorize_url": authorize_url,
"state": state,
"expires_in": int(OAUTH_PENDING_TTL_SECONDS),
}
def _extract_code_from_paste(pasted: str) -> str:
"""붙여넣은 값에서 authorization code를 꺼낸다.
브라우저 주소창의 콜백 URL 전체와 코드만 복사한 값 모두를 받는다.
"""
value = pasted.strip().strip('"').strip("'")
if not value:
raise ProviderCredentialError("인증 코드가 비어 있습니다.")
if "code=" in value or "://" in value or value.startswith("?"):
query = urlsplit(value).query or value.split("?", 1)[-1]
candidates = parse_qs(query).get("code") or []
if not candidates:
raise ProviderCredentialError(
"붙여넣은 값에서 code를 찾지 못했습니다. 브라우저 주소창의 콜백 주소 전체를 붙여넣어 주세요."
)
value = candidates[0].strip()
if not value:
raise ProviderCredentialError("인증 코드가 비어 있습니다.")
return value
def _chatgpt_account_id(id_token: str | None) -> str | None:
"""id_token payload에서 ChatGPT 계정 id를 꺼낸다.
토큰은 방금 TLS로 받은 응답 본문이라 서명 검증 대신 그 신뢰를 그대로 쓴다.
"""
if not id_token:
return None
parts = str(id_token).split(".")
if len(parts) < 2:
return None
payload = parts[1]
try:
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return None
if not isinstance(claims, dict):
return None
namespace = claims.get("https://api.openai.com/auth")
if not isinstance(namespace, dict):
return None
account_id = namespace.get("chatgpt_account_id")
return str(account_id).strip() if account_id else None
def _code_assist_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": str(_AGY_OAUTH["user_agent"]),
}
async def _code_assist_post(
client: httpx.AsyncClient, method: str, token: str, payload: dict[str, Any]
) -> dict[str, Any]:
response = await client.post(
f"{_AGY_OAUTH['code_assist_base']}/v1internal:{method}",
json=payload,
headers=_code_assist_headers(token),
)
response.raise_for_status()
body = response.json()
if not isinstance(body, dict):
raise ValueError("Code Assist 응답이 객체가 아닙니다")
return body
def _default_tier_id(loaded: dict[str, Any]) -> str:
"""loadCodeAssist 응답에서 온보딩에 쓸 기본 등급 id를 고른다."""
tiers = loaded.get("allowedTiers")
if isinstance(tiers, list):
for tier in tiers:
if isinstance(tier, dict) and tier.get("isDefault"):
tier_id = str(tier.get("id") or "").strip()
if tier_id:
return tier_id
current = loaded.get("currentTier")
if isinstance(current, dict):
return str(current.get("id") or "").strip()
return ""
async def _antigravity_project_id(token: str) -> str:
"""Code Assist 프로젝트 id를 확보한다. 있으면 재사용하고 없으면 온보딩한다.
Antigravity 생성 요청이 이 project를 요구하므로 확정하지 못하면 연결을 실패시킨다.
"""
metadata = {"pluginType": str(_AGY_OAUTH["plugin_type"])}
try:
async with httpx.AsyncClient(timeout=30) as client:
loaded = await _code_assist_post(client, "loadCodeAssist", token, {"metadata": metadata})
existing = str(loaded.get("cloudaicompanionProject") or "").strip()
if existing:
return existing
tier_id = _default_tier_id(loaded)
if not tier_id:
raise ProviderCredentialError(
"Google 계정에 Code Assist 사용 등급이 없습니다. Antigravity에서 계정 상태를 확인해 주세요."
)
payload = {"tierId": tier_id, "metadata": metadata}
for _attempt in range(_ONBOARD_POLL_ATTEMPTS):
onboarding = await _code_assist_post(client, "onboardUser", token, payload)
if onboarding.get("done"):
created = ""
if isinstance(onboarding.get("response"), dict):
created = str(
(onboarding["response"] or {}).get("cloudaicompanionProject") or ""
).strip()
if created:
return created
break
await asyncio.sleep(_ONBOARD_POLL_INTERVAL_SECONDS)
except ProviderCredentialError:
raise
except (httpx.HTTPError, ValueError) as exc:
raise ProviderCredentialError(f"Agy(Google) Code Assist 프로젝트 확인 실패: {exc}") from exc
raise ProviderCredentialError("Google 계정의 Code Assist 프로젝트를 확정하지 못했습니다.")
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"
refresh_token: str | None = body.get("refresh_token")
extra: dict[str, Any] = {}
elif provider == "codex":
auth_code = _extract_code_from_paste(pasted)
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
_CODEX_OAUTH["token_url"],
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": _CODEX_OAUTH["redirect_uri"],
"client_id": _CODEX_OAUTH["client_id"],
"code_verifier": attempt.code_verifier,
},
)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise ProviderCredentialError(f"Codex(ChatGPT) 토큰 교환 실패: {exc}") from exc
token = str(body.get("access_token") or "").strip()
if not token:
raise ProviderCredentialError("Codex가 액세스 토큰을 반환하지 않았습니다.")
auth_kind = "oauth_token"
refresh_token = str(body.get("refresh_token") or "") or None
extra = {"chatgpt_account_id": _chatgpt_account_id(body.get("id_token"))}
elif provider == "agy":
auth_code = _extract_code_from_paste(pasted)
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
_AGY_OAUTH["token_url"],
data={
"code": auth_code,
"client_id": _AGY_OAUTH["client_id"],
"client_secret": _AGY_OAUTH["client_secret"],
"redirect_uri": _AGY_OAUTH["redirect_uri"],
"grant_type": "authorization_code",
"access_type": "offline",
},
)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise ProviderCredentialError(f"Agy(Google) 토큰 교환 실패: {exc}") from exc
token = str(body.get("access_token") or "").strip()
if not token:
raise ProviderCredentialError("Google이 액세스 토큰을 반환하지 않았습니다.")
auth_kind = "oauth_token"
refresh_token = str(body.get("refresh_token") or "") or None
extra = {"project_id": await _antigravity_project_id(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"
refresh_token = None
extra = {}
stored = await save_credential(
provider=provider,
token=token,
auth_kind=auth_kind,
updated_by=admin_email,
refresh_token=refresh_token,
extra=extra,
)
sync = await push_credentials_to_gateway()
return {"stored": stored, "gateway_sync": sync}