codex·agy OAuth 계정 연결 지원 (ChatGPT/Antigravity 네이티브 어댑터)
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:
Yun Chan 2026-09-11 19:01:35 +09:00
parent 1a925b33e0
commit 45b84faa0d
10 changed files with 581 additions and 18 deletions

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass
@ -223,6 +224,16 @@ class StoredCredential:
last_verified_at: float | None
last_verify_ok: bool | None
last_verify_error: str | None
refresh_token: str | None = None
extra: dict[str, Any] | None = None
async def _credential_table_ready(conn) -> bool:
return bool(
await conn.fetchval(
"SELECT to_regclass('app.admin_provider_credential') IS NOT NULL"
)
)
async def ensure_table() -> None:
@ -234,10 +245,7 @@ async def ensure_table() -> None:
"""
pool = get_pool()
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT to_regclass('app.admin_provider_credential') IS NOT NULL"
)
if exists:
if await _credential_table_ready(conn):
return
await conn.execute(
"""
@ -251,10 +259,19 @@ async def ensure_table() -> None:
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_verified_at TIMESTAMPTZ,
last_verify_ok BOOLEAN,
last_verify_error TEXT
last_verify_error TEXT,
refresh_token_encrypted TEXT,
extra JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
await conn.execute(
"""
ALTER TABLE app.admin_provider_credential
ADD COLUMN IF NOT EXISTS refresh_token_encrypted TEXT,
ADD COLUMN IF NOT EXISTS extra JSONB NOT NULL DEFAULT '{}'::jsonb
"""
)
async def get_credential_row(provider: str) -> StoredCredential | None:
@ -281,6 +298,10 @@ async def get_credential_row(provider: str) -> StoredCredential | None:
last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"],
refresh_token=decrypt_token(row["refresh_token_encrypted"])
if row.get("refresh_token_encrypted")
else None,
extra=row.get("extra") if isinstance(row.get("extra"), dict) else None,
)
@ -299,7 +320,13 @@ async def get_plain_token(provider: str) -> tuple[str, str] | None:
async def save_credential(
*, provider: str, token: str, auth_kind: str, updated_by: str
*,
provider: str,
token: str,
auth_kind: str,
updated_by: str,
refresh_token: str | None = None,
extra: dict[str, Any] | None = None,
) -> StoredCredential:
if provider not in PROVIDER_CODES:
raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}")
@ -314,26 +341,32 @@ async def save_credential(
row = await conn.fetchrow(
"""
INSERT INTO app.admin_provider_credential (
provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at
provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at,
refresh_token_encrypted, extra
)
VALUES ($1, $2, $3, $4, $5, now())
VALUES ($1, $2, $3, $4, $5, now(), $6, $7::jsonb)
ON CONFLICT (provider) DO UPDATE SET
token_encrypted = EXCLUDED.token_encrypted,
token_hint = EXCLUDED.token_hint,
auth_kind = EXCLUDED.auth_kind,
updated_by = EXCLUDED.updated_by,
updated_at = now(),
refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, refresh_token_encrypted),
extra = CASE WHEN EXCLUDED.extra::text = '{}'::jsonb THEN extra ELSE EXCLUDED.extra END,
last_verified_at = NULL,
last_verify_ok = NULL,
last_verify_error = NULL
RETURNING provider, token_hint, auth_kind, updated_by, updated_at,
last_verified_at, last_verify_ok, last_verify_error
last_verified_at, last_verify_ok, last_verify_error,
refresh_token_encrypted, extra
""",
provider,
encrypt_token(plain),
token_hint(plain),
auth_kind,
updated_by,
encrypt_token(refresh_token) if refresh_token and refresh_token.strip() else None,
json.dumps(extra or {}),
)
return StoredCredential(
provider=row["provider"],
@ -344,6 +377,10 @@ async def save_credential(
last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"],
refresh_token=decrypt_token(row["refresh_token_encrypted"])
if row.get("refresh_token_encrypted")
else None,
extra=row.get("extra") if isinstance(row.get("extra"), dict) else None,
)
@ -395,12 +432,21 @@ async def push_credentials_to_gateway() -> dict[str, Any]:
pool = get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT provider, token_encrypted, auth_kind FROM app.admin_provider_credential"
"SELECT provider, token_encrypted, auth_kind, refresh_token_encrypted, extra "
"FROM app.admin_provider_credential"
)
providers = {
row["provider"]: {
"token": decrypt_token(row["token_encrypted"]),
"auth_kind": row["auth_kind"],
**(
{
"refresh_token": decrypt_token(row["refresh_token_encrypted"]),
}
if row["refresh_token_encrypted"]
else {}
),
"extra": row["extra"] if isinstance(row["extra"], dict) else {},
}
for row in rows
if row["provider"] in PROVIDER_CODES

View file

@ -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}