feat: add Codex auth login and export flow#93
Conversation
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
There was a problem hiding this comment.
Pull request overview
This PR adds “Codex Auth” login and export capabilities to the account-management UI/API, allowing users to generate Codex CLI-compatible auth.json (single or batch), persist the refreshed tokens/workspace metadata, and export them later.
Changes:
- Added a
CodexAuthEnginethat reuses the existingRegistrationEngineOAuth/login chain to generate Codex-compatibleauth.json. - Added account-management routes/UI for “Codex Auth 登录” (SSE streaming logs/results) and a new export format (
/export/codex_auth) with ZIP support for multiple accounts. - Added regression tests covering the Codex auth flow, persistence marker, and export behavior; updated existing failover tests for the
RegistrationEngineconstructor signature.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/core/codex_auth.py |
New engine to produce Codex CLI auth.json by reusing existing login/OAuth steps. |
src/core/openai/oauth.py |
Adds optional originator parameter to OAuth URL generation/manager. |
src/web/routes/accounts.py |
Adds Codex Auth login (SSE), persistence helpers, and codex_auth export endpoint. |
static/js/accounts.js |
Adds UI handlers for Codex Auth login modal + export error parsing improvements. |
templates/accounts.html |
Adds Codex Auth button/modal and export menu item. |
src/config/constants.py |
Adds Codex OAuth redirect URI/scope/originator constants. |
README.md |
Documents that Codex Auth export requires generating via “Codex Auth 登录” first. |
tests/test_codex_auth_flow.py |
New unit tests for Codex auth run behavior + workspace resolution fallback. |
tests/test_codex_auth_export_route.py |
New tests for auth.json export (single) and ZIP export (multi) + persistence marker. |
tests/test_registration_*_failover.py |
Updates fake engine constructors to match new init signature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| scope=self.scope, | ||
| client_id=self.client_id | ||
| client_id=self.client_id, | ||
| originator=self.originator |
There was a problem hiding this comment.
OAuthManager.start_oauth() ignores the instance’s auth_url (and any value coming from settings) and always builds the authorize URL from the module-level OAUTH_AUTH_URL. This makes the auth_url constructor parameter effectively dead and can break deployments that override the auth endpoint. Consider passing auth_url into generate_oauth_url() (or constructing the URL directly from self.auth_url) so OAuthManager(auth_url=...) is actually respected.
| originator=self.originator | |
| originator=self.originator, | |
| auth_url=self.auth_url, |
| def build_auth_json(acc): | ||
| return { | ||
| "auth_mode": "chatgpt", | ||
| "OPENAI_API_KEY": None, | ||
| "tokens": { | ||
| "id_token": acc.id_token or "", | ||
| "access_token": acc.access_token or "", | ||
| "refresh_token": acc.refresh_token or "", | ||
| "account_id": acc.account_id or "" | ||
| }, | ||
| "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else "" | ||
| } |
There was a problem hiding this comment.
export_accounts_codex_auth() formats last_refresh using datetime.isoformat() (no timezone / Z), while CodexAuthEngine emits RFC3339 with Z. For Codex compatibility (and to keep a single canonical format), export should use the same UTC/Z formatting as the generated auth.json (or persist/serialize the engine’s last_refresh value consistently).
| "access_token": tokens.get("access_token", ""), | ||
| "refresh_token": tokens.get("refresh_token", ""), | ||
| "id_token": tokens.get("id_token", ""), | ||
| "last_refresh": datetime.utcnow(), | ||
| "extra_data": _build_codex_auth_extra_data( | ||
| _get_account_extra_data(account), | ||
| workspace_id=workspace_id, | ||
| ), | ||
| } |
There was a problem hiding this comment.
_persist_codex_auth_result() writes token fields using tokens.get(..., ""), which will overwrite existing DB tokens with empty strings if the auth payload is missing a field (e.g., refresh_token not returned). Consider only updating each token column when the corresponding value is present/non-empty in auth_json["tokens"], to avoid accidental token loss.
| "access_token": tokens.get("access_token", ""), | |
| "refresh_token": tokens.get("refresh_token", ""), | |
| "id_token": tokens.get("id_token", ""), | |
| "last_refresh": datetime.utcnow(), | |
| "extra_data": _build_codex_auth_extra_data( | |
| _get_account_extra_data(account), | |
| workspace_id=workspace_id, | |
| ), | |
| } | |
| "last_refresh": datetime.utcnow(), | |
| "extra_data": _build_codex_auth_extra_data( | |
| _get_account_extra_data(account), | |
| workspace_id=workspace_id, | |
| ), | |
| } | |
| # Only update token fields when a non-empty value is provided, | |
| # to avoid overwriting existing DB tokens with empty strings. | |
| for token_field in ("access_token", "refresh_token", "id_token"): | |
| raw_value = tokens.get(token_field) | |
| if isinstance(raw_value, str): | |
| raw_value = raw_value.strip() | |
| if raw_value: | |
| update_kwargs[token_field] = raw_value |
|
|
||
| yield f"data: {json.dumps({'type': 'result', **msg_data}, ensure_ascii=False)}\n\n" | ||
| break | ||
| except Exception: |
There was a problem hiding this comment.
The SSE event_generator() swallows all exceptions (except Exception: break), which can cause the stream to terminate without sending a final error/result event, and also prevents proper cancellation handling on client disconnect. Consider handling asyncio.CancelledError separately (re-raise), logging unexpected exceptions, and emitting a terminal result/error SSE event so the UI can surface failures deterministically.
| except Exception: | |
| except asyncio.CancelledError: | |
| # 客户端断开连接时,正确传播取消异常 | |
| logger.info("Codex auth login SSE stream cancelled by client") | |
| raise | |
| except Exception as e: | |
| # 记录异常并发送终止错误事件,避免静默中断 | |
| logger.exception("Unexpected error in Codex auth login SSE stream") | |
| error_event = { | |
| "type": "error", | |
| "message": "internal server error during Codex auth login", | |
| } | |
| yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" |
| 逐个执行登录,通过 SSE 推送每个账号的进度和结果。 | ||
| 全部完成后打包下载。 |
There was a problem hiding this comment.
The batch login endpoint docstring says “全部完成后打包下载”, but the implementation streams progress/results over SSE and does not create or return a downloadable archive. Please update the docstring to match actual behavior (or implement the described packaging/download) to avoid misleading API consumers.
| 逐个执行登录,通过 SSE 推送每个账号的进度和结果。 | |
| 全部完成后打包下载。 | |
| 逐个执行登录,通过 SSE(text/event-stream)推送每个账号的进度和结果, | |
| 客户端需监听事件流(如最终的 batch_done 事件)以获取所有账号的处理结果。 |
Summary
Testing