codex·agy OAuth 계정 연결 지원 (ChatGPT/Antigravity 네이티브 어댑터)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m0s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m0s
omniroute와 동일하게 공개 클라이언트 자격증명으로 서버사이드 OAuth 교환을 제공한다. 관리자가 제공자 로그인 후 브라우저에 남는 code를 붙여넣으면 토큰·refresh token·메타데이터(account-id, Code Assist project)를 저장하고 게이트웨이에 push한다. - codex_api 어댑터: chatgpt.com/backend-api/codex/responses (Responses SSE, 401 시 refresh token으로 자가 갱신) - antigravity_api 어댑터: cloudcode-pa v1internal:streamGenerateContent (Gemini 형식 SSE, loadCodeAssist로 프로젝트 발급, 401 자가 갱신) - 자격증명 저장소에 refresh_token_encrypted·extra 컬럼 추가(부트스트랩 SQL 포함), 게이트웨이 push가 구조화 자격증명을 전달
This commit is contained in:
parent
1a925b33e0
commit
45b84faa0d
10 changed files with 581 additions and 18 deletions
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
|||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -49,7 +50,31 @@ _OPENROUTER_OAUTH = {
|
|||
"key_label": "Vignette 관리자 연결",
|
||||
}
|
||||
|
||||
OAUTH_PROVIDERS = ("claude", "openrouter")
|
||||
# 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": "auth/cloud-platform auth/userinfo.email auth/userinfo.profile auth/cclog 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",
|
||||
}
|
||||
|
||||
OAUTH_PROVIDERS = ("claude", "openrouter", "codex", "agy")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -109,13 +134,39 @@ def start_oauth(provider: str, admin_email: str) -> dict[str, Any]:
|
|||
"&code_challenge_method=S256"
|
||||
f"&state={state}"
|
||||
)
|
||||
else:
|
||||
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,
|
||||
|
|
@ -171,6 +222,57 @@ async def finish_oauth(provider: str, code: str, state: str, admin_email: str) -
|
|||
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:
|
||||
|
|
@ -191,12 +293,16 @@ async def finish_oauth(provider: str, code: str, state: str, admin_email: str) -
|
|||
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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue