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