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
|
|
@ -19,7 +19,9 @@ EngineProvider = Literal[
|
|||
"claude_cli",
|
||||
"claude_api",
|
||||
"codex_cli",
|
||||
"codex_api",
|
||||
"agy_cli",
|
||||
"antigravity_api",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"solar",
|
||||
|
|
@ -40,7 +42,9 @@ ENGINE_PROVIDERS: tuple[EngineProvider, ...] = (
|
|||
"claude_cli",
|
||||
"claude_api",
|
||||
"codex_cli",
|
||||
"codex_api",
|
||||
"agy_cli",
|
||||
"antigravity_api",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"solar",
|
||||
|
|
@ -57,7 +61,9 @@ ENGINE_PROVIDER_DEFAULTS: dict[EngineProvider, tuple[str, Optional[ReasoningEffo
|
|||
"claude_cli": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
|
||||
"claude_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
|
||||
"codex_cli": ("gpt-5.6-terra", "medium"),
|
||||
"codex_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
"agy_cli": ("gemini-3.6-flash-high", "high"),
|
||||
"antigravity_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
"openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
"openrouter": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
"solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class StartOAuthTest(unittest.TestCase):
|
|||
|
||||
def test_unsupported_provider_rejected(self):
|
||||
with self.assertRaises(svc.ProviderCredentialError):
|
||||
svc.start_oauth("codex", "admin@example.com")
|
||||
svc.start_oauth("solar", "admin@example.com")
|
||||
|
||||
def test_claude_authorize_url_contains_pkce_and_state(self):
|
||||
result = svc.start_oauth("claude", "admin@example.com")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue