"""provider OAuth 로그인(PKCE) — 로그인해서 코드를 받아 토큰으로 교환한다. 관리자가 "OAuth 로그인" 버튼으로 제공자 인증 화면을 열고, 제공자가 보여주는 인증 코드를 복사해 붙여넣으면 서버가 토큰으로 교환해 저장·게이트웨이에 push한다. - claude: ``claude setup-token``과 동일한 Authorization Code + PKCE 흐름. 콜백 페이지가 보여주는 ``code#state`` 문자열을 그대로 붙여넣는다. 스펙 근거: code.claude.com/docs/en/authentication 및 Claude Code OAuth 설정. - openrouter: 헤드리스 모드(콜백 URL 없이 코드를 화면에 표시). 스펙 근거: openrouter.ai/docs/oauth. 교환 결과는 사용자 귀속 API 키다. state는 서버가 발급해 15분 보관하며, 교환 시 provider·만료를 검증한다. """ from __future__ import annotations import base64 import hashlib import json import secrets import time from dataclasses import dataclass from typing import Any import httpx from .provider_credentials import ( ProviderCredentialError, push_credentials_to_gateway, save_credential, ) OAUTH_PENDING_TTL_SECONDS = 900.0 # claude: setup-token과 동일한 공개 클라이언트. 콜백 페이지가 code#state를 # 화면에 표시하는 수동 흐름이라 사전 등록·로컬 콜백 서버가 필요 없다. _CLAUDE_OAUTH = { "authorize_url": "https://claude.ai/oauth/authorize", "token_url": "https://platform.claude.com/v1/oauth/token", "redirect_uri": "https://platform.claude.com/oauth/code/callback", "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e", "scope": "user:inference", "expires_in": 365 * 24 * 60 * 60, } # openrouter: 콜백 없이 인증 코드를 화면에 표시하는 헤드리스 모드. _OPENROUTER_OAUTH = { "authorize_url": "https://openrouter.ai/auth", "exchange_url": "https://openrouter.ai/api/v1/auth/keys", "key_label": "Vignette 관리자 연결", } # 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) class _PendingOAuth: provider: str code_verifier: str created_by: str created_at: float _PENDING: dict[str, _PendingOAuth] = {} def oauth_supported(provider: str) -> bool: return provider in OAUTH_PROVIDERS def _purge_expired() -> None: now = time.time() expired = [key for key, item in _PENDING.items() if now - item.created_at > OAUTH_PENDING_TTL_SECONDS] for key in expired: _PENDING.pop(key, None) def _pkce_pair() -> tuple[str, str]: verifier = secrets.token_urlsafe(48) challenge = base64.urlsafe_b64encode( hashlib.sha256(verifier.encode("ascii")).digest() ).decode("ascii").rstrip("=") return verifier, challenge def start_oauth(provider: str, admin_email: str) -> dict[str, Any]: """OAuth 시작. 제공자 인증 화면 URL과 서버가 보관하는 state를 돌려준다.""" if provider not in OAUTH_PROVIDERS: raise ProviderCredentialError(f"{provider}는 OAuth 로그인을 지원하지 않습니다.") _purge_expired() state = secrets.token_urlsafe(24) code_verifier, code_challenge = _pkce_pair() _PENDING[state] = _PendingOAuth( provider=provider, code_verifier=code_verifier, created_by=admin_email, created_at=time.time(), ) if provider == "claude": authorize_url = ( f"{_CLAUDE_OAUTH['authorize_url']}" "?code=true" "&response_type=code" f"&client_id={_CLAUDE_OAUTH['client_id']}" f"&redirect_uri={_CLAUDE_OAUTH['redirect_uri']}" f"&scope={_CLAUDE_OAUTH['scope'].replace(':', '%3A')}" f"&code_challenge={code_challenge}" "&code_challenge_method=S256" f"&state={state}" ) 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, "expires_in": int(OAUTH_PENDING_TTL_SECONDS), } def _pop_pending(provider: str, state: str) -> _PendingOAuth: _purge_expired() attempt = _PENDING.get(state) if attempt is None: raise ProviderCredentialError("OAuth 요청이 만료되었거나 존재하지 않습니다. 다시 로그인해 주세요.") _PENDING.pop(state, None) if attempt.provider != provider: raise ProviderCredentialError("OAuth 요청의 provider가 일치하지 않습니다.") return attempt async def finish_oauth(provider: str, code: str, state: str, admin_email: str) -> dict[str, Any]: """붙여넣은 인증 코드를 토큰/키로 교환해 저장하고 게이트웨이에 push한다.""" attempt = _pop_pending(provider, state) pasted = code.strip() if provider == "claude": # 콜백 페이지 표시 형식: authorizationCode#state parts = pasted.split("#") if len(parts) != 2 or parts[1].strip() != state: raise ProviderCredentialError( "인증 코드 형식이 올바르지 않습니다. 콜백 화면의 전체 코드(code#state)를 붙여넣으세요." ) auth_code = parts[0].strip() payload: dict[str, Any] = { "grant_type": "authorization_code", "code": auth_code, "redirect_uri": _CLAUDE_OAUTH["redirect_uri"], "client_id": _CLAUDE_OAUTH["client_id"], "code_verifier": attempt.code_verifier, "state": state, "expires_in": _CLAUDE_OAUTH["expires_in"], } try: async with httpx.AsyncClient(timeout=30) as client: response = await client.post( _CLAUDE_OAUTH["token_url"], json=payload, headers={"Content-Type": "application/json"}, ) response.raise_for_status() body = response.json() except (httpx.HTTPError, ValueError) as exc: raise ProviderCredentialError(f"Claude 토큰 교환 실패: {exc}") from exc token = str(body.get("access_token") or "").strip() 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: response = await client.post( _OPENROUTER_OAUTH["exchange_url"], json={ "code": pasted, "code_verifier": attempt.code_verifier, "code_challenge_method": "S256", }, headers={"Content-Type": "application/json"}, ) response.raise_for_status() body = response.json() except (httpx.HTTPError, ValueError) as exc: raise ProviderCredentialError(f"OpenRouter 키 교환 실패: {exc}") from exc token = str(body.get("key") or "").strip() 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}