Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

515 lines
18 KiB
Python

"""OpenSRE-managed OpenAI Codex OAuth browser login."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import tempfile
import threading
import webbrowser
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_PORT = 1455
CODEX_OAUTH_CALLBACK_PATH = "/auth/callback"
CODEX_OAUTH_REDIRECT_URI = f"http://localhost:{CODEX_OAUTH_PORT}{CODEX_OAUTH_CALLBACK_PATH}"
CODEX_OAUTH_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"
CODEX_OAUTH_AUTH_MODE = "chatgpt"
CODEX_OAUTH_TIMEOUT_SECONDS = 300.0
class CodexOAuthError(RuntimeError):
"""Raised when OpenSRE-managed Codex OAuth login fails."""
@dataclass(frozen=True)
class CodexOAuthResult:
"""Result of a completed Codex OAuth login."""
account_id: str
auth_path: Path
detail: str
@dataclass(frozen=True)
class _OAuthRequest:
state: str
code_verifier: str
authorize_url: str
@dataclass(frozen=True)
class _CallbackResult:
login: CodexOAuthResult | None = None
error: str = ""
error_description: str = ""
def codex_home() -> Path:
"""Return the Codex home directory used for auth persistence."""
override = os.getenv("CODEX_HOME", "").strip()
if override:
return Path(override).expanduser()
return Path.home() / ".codex"
def codex_auth_path() -> Path:
"""Return the Codex-compatible auth file path."""
return codex_home() / "auth.json"
def _base64_url_no_padding(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _new_pkce_verifier() -> str:
return secrets.token_urlsafe(64)
def _pkce_challenge(verifier: str) -> str:
return _base64_url_no_padding(hashlib.sha256(verifier.encode("ascii")).digest())
def build_codex_oauth_request() -> _OAuthRequest:
"""Build the Codex OAuth authorize request with state and PKCE."""
state = secrets.token_urlsafe(32)
verifier = _new_pkce_verifier()
params = {
"response_type": "code",
"client_id": CODEX_OAUTH_CLIENT_ID,
"redirect_uri": CODEX_OAUTH_REDIRECT_URI,
"scope": CODEX_OAUTH_SCOPE,
"code_challenge": _pkce_challenge(verifier),
"code_challenge_method": "S256",
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"state": state,
"originator": "codex_cli_rs",
}
return _OAuthRequest(
state=state,
code_verifier=verifier,
authorize_url=f"{CODEX_OAUTH_AUTHORIZE_URL}?{urlencode(params)}",
)
class _CallbackHTTPServer(ThreadingHTTPServer):
expected_state: str
code_verifier: str
post: Callable[..., httpx.Response]
callback_result: _CallbackResult | None
callback_event: threading.Event
class _CallbackHandler(BaseHTTPRequestHandler):
server: _CallbackHTTPServer
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/success":
if self.server.callback_result is None:
self._handle_success_token_callback(parsed.query)
return
self._write_page(200, "OpenSRE OAuth login completed. You can close this tab.")
return
if parsed.path != CODEX_OAUTH_CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
query = parse_qs(parsed.query, keep_blank_values=True)
received_state = query.get("state", [""])[0]
if received_state != self.server.expected_state:
self.server.callback_result = _CallbackResult(
error="invalid_state",
error_description="OAuth callback state did not match the login request.",
)
self._write_page(
400,
"OpenSRE OAuth login failed. Return to the terminal and retry.",
)
self.server.callback_event.set()
return
provider_error = query.get("error", [""])[0]
if provider_error:
self.server.callback_result = _CallbackResult(
error=provider_error,
error_description=query.get("error_description", [""])[0],
)
self._write_page(
400,
"OpenSRE OAuth login was not completed. Return to the terminal.",
)
self.server.callback_event.set()
return
code = query.get("code", [""])[0].strip()
if not code:
self.server.callback_result = _CallbackResult(
error="missing_code",
error_description="OAuth callback did not include an authorization code.",
)
self._write_page(
400,
"OpenSRE OAuth login failed. Return to the terminal and retry.",
)
self.server.callback_event.set()
return
try:
token_response = exchange_codex_oauth_code(
code=code,
code_verifier=self.server.code_verifier,
post=self.server.post,
)
result = persist_codex_auth_tokens(token_response)
except CodexOAuthError as exc:
self.server.callback_result = _CallbackResult(
error="token_exchange_failed",
error_description=str(exc),
)
self._write_page(
400,
"OpenSRE OAuth login failed while saving credentials. Return to the terminal.",
)
self.server.callback_event.set()
return
self.server.callback_result = _CallbackResult(login=result)
self._redirect(_compose_success_url(token_response))
self.server.callback_event.set()
def log_message(self, _format: str, *_args: object) -> None:
"""Suppress callback URL logging so codes and state are not emitted."""
def _handle_success_token_callback(self, query_string: str) -> None:
query = parse_qs(query_string, keep_blank_values=True)
id_token = query.get("id_token", [""])[0].strip()
if not id_token:
self.server.callback_result = _CallbackResult(
error="missing_token",
error_description="OAuth success callback did not include an id_token.",
)
self._write_page(
400,
"OpenSRE OAuth login failed. Return to the terminal and retry.",
)
self.server.callback_event.set()
return
token_response = {
"access_token": query.get("access_token", [id_token])[0].strip() or id_token,
"refresh_token": query.get("refresh_token", [""])[0].strip(),
"id_token": id_token,
}
account_id = query.get("account_id", [""])[0].strip()
if account_id:
token_response["account_id"] = account_id
try:
result = persist_codex_auth_tokens(
token_response,
require_refresh_token=False,
detail_prefix="OpenAI OAuth success token stored",
)
except CodexOAuthError as exc:
self.server.callback_result = _CallbackResult(
error="token_persist_failed",
error_description=str(exc),
)
self._write_page(
400,
"OpenSRE OAuth login failed while saving credentials. Return to the terminal.",
)
self.server.callback_event.set()
return
self.server.callback_result = _CallbackResult(login=result)
self._write_page(200, "OpenSRE OAuth login completed. You can close this tab.")
self.server.callback_event.set()
def _redirect(self, location: str) -> None:
self.send_response(302)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self.end_headers()
def _write_page(self, status: int, message: str) -> None:
body = (
'<!doctype html><html><head><meta charset="utf-8">'
"<title>OpenSRE OAuth</title></head><body>"
f"<p>{message}</p></body></html>"
).encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def wait_for_codex_oauth_callback(
*,
request: _OAuthRequest,
open_browser: Callable[[str], bool] = webbrowser.open,
post: Callable[..., httpx.Response] = httpx.post,
timeout_seconds: float = CODEX_OAUTH_TIMEOUT_SECONDS,
) -> CodexOAuthResult:
"""Open the browser and wait for OpenAI to redirect back with Codex auth."""
event = threading.Event()
try:
server = _CallbackHTTPServer(("localhost", CODEX_OAUTH_PORT), _CallbackHandler)
except OSError as exc:
raise CodexOAuthError(
f"Could not bind localhost:{CODEX_OAUTH_PORT}. "
f"Free port {CODEX_OAUTH_PORT}, then retry OpenAI OAuth login."
) from exc
server.expected_state = request.state
server.code_verifier = request.code_verifier
server.post = post
server.callback_result = None
server.callback_event = event
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
if not open_browser(request.authorize_url):
raise CodexOAuthError(
"Could not open the browser automatically. "
f"Open this URL manually to continue OAuth login: {request.authorize_url}"
)
if not event.wait(timeout_seconds):
raise CodexOAuthError("Timed out waiting for OpenAI OAuth callback.")
result = server.callback_result
if result is None:
raise CodexOAuthError("OAuth callback completed without a result.")
if result.error:
detail = f": {result.error_description}" if result.error_description else ""
raise CodexOAuthError(f"OpenAI OAuth callback failed ({result.error}){detail}.")
if result.login is None:
raise CodexOAuthError("OAuth callback completed without stored Codex auth.")
return result.login
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
def exchange_codex_oauth_code(
*,
code: str,
code_verifier: str,
post: Callable[..., httpx.Response] = httpx.post,
) -> dict[str, object]:
"""Exchange an authorization code for OpenAI OAuth tokens."""
try:
response = post(
CODEX_OAUTH_TOKEN_URL,
data={
"grant_type": "authorization_code",
"client_id": CODEX_OAUTH_CLIENT_ID,
"code": code,
"redirect_uri": CODEX_OAUTH_REDIRECT_URI,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
timeout=30.0,
)
except httpx.HTTPError as exc:
raise CodexOAuthError(f"OpenAI OAuth token exchange failed: {exc}") from exc
if response.status_code != 200:
raise CodexOAuthError(
f"OpenAI OAuth token exchange failed with HTTP {response.status_code}."
)
try:
data = response.json()
except ValueError as exc:
raise CodexOAuthError("OpenAI OAuth token exchange returned invalid JSON.") from exc
if not isinstance(data, dict):
raise CodexOAuthError("OpenAI OAuth token exchange returned an unexpected payload.")
return data
def _decode_jwt_payload(token: str) -> dict[str, object]:
parts = token.split(".")
if len(parts) < 2:
return {}
padded = parts[1] + "=" * (-len(parts[1]) % 4)
try:
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
data = json.loads(decoded)
except (ValueError, OSError):
return {}
return data if isinstance(data, dict) else {}
def _account_id_from_token_payload(payload: Mapping[str, object]) -> str:
auth_claim = payload.get("https://api.openai.com/auth")
if isinstance(auth_claim, Mapping):
chatgpt_account_id = auth_claim.get("chatgpt_account_id")
if isinstance(chatgpt_account_id, str) and chatgpt_account_id.strip():
return chatgpt_account_id.strip()
for key in ("account_id", "user_id", "sub"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _required_token(data: Mapping[str, object], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise CodexOAuthError(f"OpenAI OAuth token response did not include {key}.")
return value.strip()
def _utc_timestamp() -> str:
return datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="microseconds") + "Z"
def codex_auth_payload(
token_response: Mapping[str, object],
*,
require_refresh_token: bool = True,
) -> dict[str, object]:
"""Convert OpenAI's token response to Codex's auth.json shape."""
access_token = _required_token(token_response, "access_token")
if require_refresh_token:
refresh_token = _required_token(token_response, "refresh_token")
else:
refresh_token = str(token_response.get("refresh_token") or "").strip()
id_token = _required_token(token_response, "id_token")
account_id = ""
raw_account_id = token_response.get("account_id")
if isinstance(raw_account_id, str):
account_id = raw_account_id.strip()
if not account_id:
account_id = _account_id_from_token_payload(_decode_jwt_payload(id_token))
if not account_id:
raise CodexOAuthError("Could not derive ChatGPT account id from OpenAI OAuth tokens.")
return {
"OPENAI_API_KEY": None,
"auth_mode": CODEX_OAUTH_AUTH_MODE,
"tokens": {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": id_token,
"account_id": account_id,
},
"last_refresh": _utc_timestamp(),
}
def persist_codex_auth_tokens(
token_response: Mapping[str, object],
*,
require_refresh_token: bool = True,
detail_prefix: str = "OpenAI OAuth tokens stored",
) -> CodexOAuthResult:
"""Persist an OpenAI token response in Codex-compatible auth.json format."""
payload = codex_auth_payload(
token_response,
require_refresh_token=require_refresh_token,
)
auth_path = write_codex_auth(payload)
account_id = str(payload["tokens"]["account_id"]) # type: ignore[index]
return CodexOAuthResult(
account_id=account_id,
auth_path=auth_path,
detail=f"{detail_prefix} for Codex at {auth_path}.",
)
def _compose_success_url(token_response: Mapping[str, object]) -> str:
id_token = _required_token(token_response, "id_token")
access_token = str(token_response.get("access_token") or "")
token_claims = _decode_jwt_payload(id_token)
access_claims = _decode_jwt_payload(access_token)
params = {
"id_token": id_token,
"needs_setup": str(
bool(token_claims.get("is_org_owner"))
and not bool(token_claims.get("completed_platform_onboarding"))
).lower(),
"org_id": str(token_claims.get("organization_id") or ""),
"project_id": str(token_claims.get("project_id") or ""),
"plan_type": str(access_claims.get("chatgpt_plan_type") or ""),
"platform_url": "https://platform.openai.com",
}
return f"http://localhost:{CODEX_OAUTH_PORT}/success?{urlencode(params)}"
def write_codex_auth(payload: Mapping[str, object], *, path: Path | None = None) -> Path:
"""Atomically write Codex auth.json with owner-only permissions."""
auth_path = path or codex_auth_path()
auth_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with suppress(OSError):
auth_path.parent.chmod(0o700)
fd, tmp_name = tempfile.mkstemp(
prefix=f".{auth_path.name}.",
suffix=".tmp",
dir=str(auth_path.parent),
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(dict(payload), handle, indent=2, sort_keys=True)
handle.write("\n")
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, auth_path)
with suppress(OSError):
auth_path.chmod(0o600)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return auth_path
def run_codex_oauth_login(
*,
open_browser: Callable[[str], bool] = webbrowser.open,
post: Callable[..., httpx.Response] = httpx.post,
timeout_seconds: float = CODEX_OAUTH_TIMEOUT_SECONDS,
) -> CodexOAuthResult:
"""Complete the OpenSRE-managed Codex OAuth flow and persist auth.json."""
request = build_codex_oauth_request()
return wait_for_codex_oauth_callback(
request=request,
open_browser=open_browser,
post=post,
timeout_seconds=timeout_seconds,
)
__all__ = [
"CODEX_OAUTH_CALLBACK_PATH",
"CODEX_OAUTH_CLIENT_ID",
"CODEX_OAUTH_PORT",
"CODEX_OAUTH_REDIRECT_URI",
"CodexOAuthError",
"CodexOAuthResult",
"build_codex_oauth_request",
"codex_auth_path",
"codex_auth_payload",
"exchange_codex_oauth_code",
"persist_codex_auth_tokens",
"run_codex_oauth_login",
"wait_for_codex_oauth_callback",
"write_codex_auth",
]