Skip to content

Commit f4d0327

Browse files
committed
feat: add Codex auth login and export flow
Add Codex Auth support in account management so selected accounts can complete a Codex-compatible OAuth login flow and export usable auth.json files. This commit includes: - account-management UI entrypoints for Codex Auth login and auth.json download - backend SSE routes for single-account and batch Codex Auth login execution - persistence of freshly returned Codex-compatible tokens back into the account database - Codex auth export support for direct auth.json download and batch zip packaging - tests covering the Codex Auth login flow and export behavior The OTP verification failure was caused by manually sending a second OTP after password verification. The flow now reuses the existing proven login path: login re-entry, password verification, automatic OTP reception, consent page handling, workspace selection, and OAuth callback exchange. Successful logins now also persist workspace_id together with the refreshed Codex-compatible tokens, making later re-export of auth.json possible without requiring the browser-downloaded file to still exist locally. Change-Id: I59df518ef4dc05f8bc52c734dd1b738fcb0b7a4e
1 parent 1cbb95f commit f4d0327

11 files changed

Lines changed: 1266 additions & 13 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
- 单个账号导出为独立 `.json` 文件
4949
- 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
5050
- Sub2API 格式所有账号合并为单个 JSON
51+
- Codex Auth 格式需先在账号管理中手动执行 `Codex Auth 登录` 成功后才能导出
5152
- 上传目标(直连不走代理):
5253
- **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
5354
- **Sub2API**:支持多服务配置,标准 sub2api-data 格式

src/config/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ class EmailServiceType(str, Enum):
5959
OAUTH_REDIRECT_URI = "http://localhost:15555/auth/callback"
6060
OAUTH_SCOPE = "openid email profile offline_access"
6161

62+
# Codex CLI 专用 OAuth 参数(用于生成 Codex 兼容的 auth.json)
63+
CODEX_OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback"
64+
CODEX_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"
65+
CODEX_OAUTH_ORIGINATOR = "codex_cli_rs"
66+
6267
# OpenAI API 端点
6368
OPENAI_API_ENDPOINTS = {
6469
"sentinel": "https://sentinel.openai.com/backend-api/sentinel/req",

src/core/codex_auth.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""
2+
Codex Auth 登录引擎
3+
复用仓库里已经验证通过的登录状态流,为已有账号生成 Codex CLI 可用的 auth.json。
4+
"""
5+
6+
import time
7+
from dataclasses import dataclass, field
8+
from typing import Any, Callable, Dict, List, Optional
9+
10+
from .openai.oauth import OAuthManager
11+
from .register import PhaseContext, RegistrationEngine
12+
from ..config.constants import (
13+
CODEX_OAUTH_ORIGINATOR,
14+
CODEX_OAUTH_REDIRECT_URI,
15+
CODEX_OAUTH_SCOPE,
16+
)
17+
from ..config.settings import get_settings
18+
from ..services.base import BaseEmailService
19+
20+
21+
@dataclass
22+
class CodexAuthResult:
23+
"""Codex Auth 登录结果"""
24+
25+
success: bool
26+
email: str = ""
27+
workspace_id: str = ""
28+
auth_json: Optional[Dict[str, Any]] = None
29+
error_message: str = ""
30+
logs: List[str] = field(default_factory=list)
31+
32+
33+
class CodexAuthEngine(RegistrationEngine):
34+
"""
35+
对已有账号执行 Codex CLI 兼容 OAuth 登录流程。
36+
37+
这里直接复用 RegistrationEngine 中已经跑通的:
38+
登录重入 → 密码校验 → OTP 校验 → consent/workspace → callback
39+
这条链路,避免与成功路径产生分叉。
40+
"""
41+
42+
def __init__(
43+
self,
44+
email: str,
45+
password: str,
46+
email_service: BaseEmailService,
47+
proxy_url: Optional[str] = None,
48+
callback_logger: Optional[Callable[[str], None]] = None,
49+
email_service_id: Optional[str] = None,
50+
):
51+
super().__init__(
52+
email_service=email_service,
53+
proxy_url=proxy_url,
54+
callback_logger=callback_logger,
55+
)
56+
self.email = email
57+
self.password = password
58+
self.email_service_id = email_service_id
59+
self.email_info = {"email": email}
60+
if email_service_id:
61+
self.email_info["service_id"] = email_service_id
62+
63+
settings = get_settings()
64+
self.oauth_manager = OAuthManager(
65+
client_id=settings.openai_client_id,
66+
auth_url=settings.openai_auth_url,
67+
token_url=settings.openai_token_url,
68+
redirect_uri=CODEX_OAUTH_REDIRECT_URI,
69+
scope=CODEX_OAUTH_SCOPE,
70+
proxy_url=proxy_url,
71+
originator=CODEX_OAUTH_ORIGINATOR,
72+
)
73+
74+
def _build_auth_json(self, token_info: Dict[str, Any]) -> Dict[str, Any]:
75+
"""构造 Codex CLI 兼容的 auth.json 内容。"""
76+
now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
77+
return {
78+
"auth_mode": "chatgpt",
79+
"OPENAI_API_KEY": None,
80+
"tokens": {
81+
"id_token": token_info.get("id_token", ""),
82+
"access_token": token_info.get("access_token", ""),
83+
"refresh_token": token_info.get("refresh_token", ""),
84+
"account_id": token_info.get("account_id", ""),
85+
},
86+
"last_refresh": now_rfc3339,
87+
}
88+
89+
def _resolve_workspace_id(self, consent_url: Optional[str]) -> Optional[str]:
90+
"""
91+
OTP 校验成功后优先请求 consent 页面提取 workspace。
92+
若页面未显式暴露 workspace_id,再回退到 Cookie 解析路径。
93+
"""
94+
if not self.session or not self.oauth_start:
95+
return None
96+
97+
auth_target = consent_url or self.oauth_start.auth_url
98+
try:
99+
self._log(f"请求 consent 页面: {auth_target[:120]}...")
100+
started_at = time.time()
101+
response = self.session.get(auth_target, timeout=20)
102+
self._log_timed_http_result("获取 consent 页面", started_at, response)
103+
104+
workspace_id = self._extract_workspace_id_from_response(
105+
response=response,
106+
html=response.text or "",
107+
url=str(getattr(response, "url", "") or "").strip(),
108+
)
109+
if workspace_id:
110+
self._log(f"Workspace ID: {workspace_id}")
111+
return workspace_id
112+
except Exception as e:
113+
self._log(f"请求 consent 页面失败: {e}", "warning")
114+
115+
self._log("consent 页面缺少 workspace_id,回退到 Cookie 解析路径", "warning")
116+
return RegistrationEngine._get_workspace_id(self)
117+
118+
def run(self) -> CodexAuthResult:
119+
"""执行 Codex Auth 登录并产出 auth.json。"""
120+
result = CodexAuthResult(success=False, email=self.email, logs=self.logs)
121+
122+
try:
123+
self._log("=" * 50)
124+
self._log(f"开始 Codex Auth 登录: {self.email}")
125+
self._log("=" * 50)
126+
127+
self._log("1. 初始化会话...")
128+
if not RegistrationEngine._init_session(self):
129+
result.error_message = "初始化会话失败"
130+
return result
131+
132+
self._log("2. 开始 Codex OAuth 流程...")
133+
if not RegistrationEngine._start_oauth(self):
134+
result.error_message = "OAuth 流程启动失败"
135+
return result
136+
137+
self._log("3. 获取 Device ID...")
138+
did = RegistrationEngine._get_device_id(self)
139+
if not did:
140+
result.error_message = "获取 Device ID 失败"
141+
return result
142+
143+
self._log("4. 重新进入登录流程...")
144+
if not self._try_reenter_login_flow():
145+
result.error_message = "进入登录流程失败"
146+
return result
147+
148+
self._log("5. 提交密码...")
149+
self._otp_sent_at = time.time()
150+
if not self._submit_login_password_step():
151+
result.error_message = "密码验证失败"
152+
return result
153+
154+
self._log("6. 等待验证码...")
155+
otp_started_at = time.time()
156+
code, otp_phase = self._phase_otp_secondary(
157+
PhaseContext(otp_sent_at=self._otp_sent_at),
158+
started_at=otp_started_at,
159+
)
160+
if not code:
161+
result.error_message = otp_phase.error_message or "获取验证码失败"
162+
return result
163+
164+
self._log("7. 验证验证码...")
165+
otp_valid, consent_url = self._validate_verification_code_and_get_continue_url(code)
166+
if not otp_valid:
167+
result.error_message = "验证码校验失败"
168+
return result
169+
170+
self._log("8. 获取 Workspace ID...")
171+
workspace_id = self._resolve_workspace_id(consent_url)
172+
if not workspace_id:
173+
result.error_message = "获取 Workspace ID 失败"
174+
return result
175+
result.workspace_id = workspace_id
176+
177+
self._log("9. 选择 Workspace...")
178+
continue_url = RegistrationEngine._select_workspace(self, workspace_id)
179+
if not continue_url:
180+
result.error_message = "选择 Workspace 失败"
181+
return result
182+
183+
self._log("10. 跟随重定向...")
184+
callback_url = RegistrationEngine._follow_redirects(self, continue_url)
185+
if not callback_url:
186+
result.error_message = "获取回调 URL 失败"
187+
return result
188+
189+
self._log("11. 处理 OAuth 回调...")
190+
token_info = RegistrationEngine._handle_oauth_callback(self, callback_url)
191+
if not token_info:
192+
result.error_message = "OAuth 回调处理失败"
193+
return result
194+
195+
result.auth_json = self._build_auth_json(token_info)
196+
result.success = True
197+
198+
self._log("=" * 50)
199+
self._log(f"Codex Auth 登录成功: {self.email}")
200+
self._log(f"Account ID: {token_info.get('account_id', '')}")
201+
self._log(f"Workspace ID: {workspace_id}")
202+
self._log("=" * 50)
203+
return result
204+
205+
except Exception as e:
206+
self._log(f"Codex Auth 登录异常: {e}", "error")
207+
result.error_message = str(e)
208+
return result
209+
finally:
210+
try:
211+
self.http_client.close()
212+
except Exception:
213+
pass

src/core/openai/oauth.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ def generate_oauth_url(
190190
*,
191191
redirect_uri: str = OAUTH_REDIRECT_URI,
192192
scope: str = OAUTH_SCOPE,
193-
client_id: str = OAUTH_CLIENT_ID
193+
client_id: str = OAUTH_CLIENT_ID,
194+
originator: Optional[str] = None
194195
) -> OAuthStart:
195196
"""
196197
生成 OAuth 授权 URL
@@ -199,6 +200,7 @@ def generate_oauth_url(
199200
redirect_uri: 回调地址
200201
scope: 权限范围
201202
client_id: OpenAI Client ID
203+
originator: 来源标识(如 codex_cli_rs)
202204
203205
Returns:
204206
OAuthStart 对象,包含授权 URL 和必要参数
@@ -219,6 +221,8 @@ def generate_oauth_url(
219221
"id_token_add_organizations": "true",
220222
"codex_cli_simplified_flow": "true",
221223
}
224+
if originator:
225+
params["originator"] = originator
222226
auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
223227
return OAuthStart(
224228
auth_url=auth_url,
@@ -321,21 +325,24 @@ def __init__(
321325
token_url: str = OAUTH_TOKEN_URL,
322326
redirect_uri: str = OAUTH_REDIRECT_URI,
323327
scope: str = OAUTH_SCOPE,
324-
proxy_url: Optional[str] = None
328+
proxy_url: Optional[str] = None,
329+
originator: Optional[str] = None
325330
):
326331
self.client_id = client_id
327332
self.auth_url = auth_url
328333
self.token_url = token_url
329334
self.redirect_uri = redirect_uri
330335
self.scope = scope
331336
self.proxy_url = proxy_url
337+
self.originator = originator
332338

333339
def start_oauth(self) -> OAuthStart:
334340
"""开始 OAuth 流程"""
335341
return generate_oauth_url(
336342
redirect_uri=self.redirect_uri,
337343
scope=self.scope,
338-
client_id=self.client_id
344+
client_id=self.client_id,
345+
originator=self.originator
339346
)
340347

341348
def handle_callback(

0 commit comments

Comments
 (0)