누락된 OAuth 콜백 코드 파싱과 계정 metadata 헬퍼 구현
Some checks failed
API contract / OpenAPI type drift (push) Failing after 48s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 48s
This commit is contained in:
parent
7dce818788
commit
fefca4743e
2 changed files with 252 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ state는 서버가 발급해 15분 보관하며, 교환 시 provider·만료를
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
|
|
@ -21,6 +22,7 @@ import secrets
|
|||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -31,6 +33,9 @@ from .provider_credentials import (
|
|||
)
|
||||
|
||||
OAUTH_PENDING_TTL_SECONDS = 900.0
|
||||
# Code Assist 온보딩(onboardUser)은 장기 실행 작업이라 완료까지 짧게 폴링한다.
|
||||
_ONBOARD_POLL_ATTEMPTS = 10
|
||||
_ONBOARD_POLL_INTERVAL_SECONDS = 2.0
|
||||
|
||||
# claude: setup-token과 동일한 공개 클라이언트. 콜백 페이지가 code#state를
|
||||
# 화면에 표시하는 수동 흐름이라 사전 등록·로컬 콜백 서버가 필요 없다.
|
||||
|
|
@ -78,6 +83,7 @@ _AGY_OAUTH = {
|
|||
),
|
||||
"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")
|
||||
|
|
@ -180,6 +186,127 @@ def start_oauth(provider: str, admin_email: str) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue