mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-08-13 00:14:06 +08:00
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
This commit is contained in:
204
tests/test_codex_auth_export_route.py
Normal file
204
tests/test_codex_auth_export_route.py
Normal file
@@ -0,0 +1,204 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import src.web.routes.accounts as accounts_routes
|
||||
from src.database import crud
|
||||
from src.database.session import DatabaseSessionManager
|
||||
from src.web.routes.accounts import BatchExportRequest
|
||||
|
||||
|
||||
async def _read_streaming_response_body(response) -> bytes:
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
if isinstance(chunk, bytes):
|
||||
chunks.append(chunk)
|
||||
else:
|
||||
chunks.append(chunk.encode("utf-8"))
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _build_fake_get_db(manager):
|
||||
@contextmanager
|
||||
def fake_get_db():
|
||||
with manager.session_scope() as session:
|
||||
yield session
|
||||
|
||||
return fake_get_db
|
||||
|
||||
|
||||
def test_export_codex_auth_single_account_uses_auth_json_filename(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/single.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
|
||||
with manager.session_scope() as session:
|
||||
account = crud.create_account(
|
||||
session,
|
||||
email="single@example.com",
|
||||
email_service="tempmail",
|
||||
access_token="access-token",
|
||||
refresh_token="refresh-token",
|
||||
id_token="id-token",
|
||||
account_id="acct-1",
|
||||
extra_data={"codex_auth": {"generated": True}},
|
||||
)
|
||||
account_id = account.id
|
||||
|
||||
monkeypatch.setattr(accounts_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
response = asyncio.run(
|
||||
accounts_routes.export_accounts_codex_auth(
|
||||
BatchExportRequest(ids=[account_id]),
|
||||
)
|
||||
)
|
||||
body = asyncio.run(_read_streaming_response_body(response))
|
||||
|
||||
assert response.headers["content-disposition"] == "attachment; filename=auth.json"
|
||||
assert json.loads(body.decode("utf-8")) == {
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": None,
|
||||
"tokens": {
|
||||
"id_token": "id-token",
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"account_id": "acct-1",
|
||||
},
|
||||
"last_refresh": "",
|
||||
}
|
||||
|
||||
|
||||
def test_export_codex_auth_multiple_accounts_zip_each_auth_json_under_email_directory(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/multi.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
|
||||
with manager.session_scope() as session:
|
||||
first = crud.create_account(
|
||||
session,
|
||||
email="first@example.com",
|
||||
email_service="tempmail",
|
||||
access_token="first-access",
|
||||
refresh_token="first-refresh",
|
||||
id_token="first-id",
|
||||
account_id="acct-first",
|
||||
extra_data={"codex_auth": {"generated": True}},
|
||||
)
|
||||
second = crud.create_account(
|
||||
session,
|
||||
email="second@example.com",
|
||||
email_service="tempmail",
|
||||
access_token="second-access",
|
||||
refresh_token="second-refresh",
|
||||
id_token="second-id",
|
||||
account_id="acct-second",
|
||||
extra_data={"codex_auth": {"generated": True}},
|
||||
)
|
||||
account_ids = [first.id, second.id]
|
||||
|
||||
monkeypatch.setattr(accounts_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
response = asyncio.run(
|
||||
accounts_routes.export_accounts_codex_auth(
|
||||
BatchExportRequest(ids=account_ids),
|
||||
)
|
||||
)
|
||||
body = asyncio.run(_read_streaming_response_body(response))
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(body), "r") as zf:
|
||||
assert sorted(zf.namelist()) == [
|
||||
"first@example.com/auth.json",
|
||||
"second@example.com/auth.json",
|
||||
]
|
||||
|
||||
first_auth = json.loads(zf.read("first@example.com/auth.json").decode("utf-8"))
|
||||
second_auth = json.loads(zf.read("second@example.com/auth.json").decode("utf-8"))
|
||||
|
||||
assert first_auth["tokens"]["access_token"] == "first-access"
|
||||
assert second_auth["tokens"]["access_token"] == "second-access"
|
||||
assert response.headers["content-disposition"].startswith("attachment; filename=codex_auth_")
|
||||
|
||||
|
||||
def test_export_codex_auth_requires_manual_generation_first(tmp_path, monkeypatch):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/missing-marker.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
|
||||
with manager.session_scope() as session:
|
||||
account = crud.create_account(
|
||||
session,
|
||||
email="plain@example.com",
|
||||
email_service="tempmail",
|
||||
access_token="plain-access",
|
||||
refresh_token="plain-refresh",
|
||||
id_token="plain-id",
|
||||
account_id="acct-plain",
|
||||
)
|
||||
account_id = account.id
|
||||
|
||||
monkeypatch.setattr(accounts_routes, "get_db", _build_fake_get_db(manager))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
accounts_routes.export_accounts_codex_auth(
|
||||
BatchExportRequest(ids=[account_id]),
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert (
|
||||
exc_info.value.detail
|
||||
== "以下账号尚未生成 Codex Auth,请先在账号管理中点击「Codex Auth 登录」后再导出:plain@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_persist_codex_auth_result_marks_account_generated(tmp_path):
|
||||
manager = DatabaseSessionManager(f"sqlite:///{tmp_path}/persist-marker.db")
|
||||
manager.create_tables()
|
||||
manager.migrate_tables()
|
||||
|
||||
with manager.session_scope() as session:
|
||||
account = crud.create_account(
|
||||
session,
|
||||
email="marked@example.com",
|
||||
email_service="tempmail",
|
||||
access_token="old-access",
|
||||
refresh_token="old-refresh",
|
||||
id_token="old-id",
|
||||
account_id="acct-old",
|
||||
extra_data={"note": "keep-me"},
|
||||
)
|
||||
account_id = account.id
|
||||
|
||||
with manager.session_scope() as session:
|
||||
accounts_routes._persist_codex_auth_result(
|
||||
session,
|
||||
account_id=account_id,
|
||||
auth_json={
|
||||
"tokens": {
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"id_token": "new-id",
|
||||
"account_id": "acct-new",
|
||||
}
|
||||
},
|
||||
workspace_id="ws-new",
|
||||
)
|
||||
|
||||
with manager.session_scope() as session:
|
||||
account = crud.get_account_by_id(session, account_id)
|
||||
assert account is not None
|
||||
assert account.access_token == "new-access"
|
||||
assert account.refresh_token == "new-refresh"
|
||||
assert account.id_token == "new-id"
|
||||
assert account.account_id == "acct-new"
|
||||
assert account.workspace_id == "ws-new"
|
||||
assert account.extra_data["note"] == "keep-me"
|
||||
assert account.extra_data["codex_auth"]["generated"] is True
|
||||
assert account.extra_data["codex_auth"]["workspace_id"] == "ws-new"
|
||||
assert account.extra_data["codex_auth"]["generated_at"]
|
||||
149
tests/test_codex_auth_flow.py
Normal file
149
tests/test_codex_auth_flow.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import src.core.codex_auth as codex_auth_module
|
||||
from src.core.codex_auth import CodexAuthEngine
|
||||
from src.core.register import PhaseResult, RegistrationEngine
|
||||
from src.services import EmailServiceType
|
||||
|
||||
|
||||
class DummySettings:
|
||||
openai_client_id = "client-id"
|
||||
openai_auth_url = "https://auth.example.test/oauth/authorize"
|
||||
openai_token_url = "https://auth.example.test/oauth/token"
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
service_type = EmailServiceType.TEMPMAIL
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, *, status_code=200, url="", text=""):
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.text = text
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.calls.append({"url": url, "kwargs": kwargs})
|
||||
return self.response
|
||||
|
||||
|
||||
def _build_engine(monkeypatch):
|
||||
monkeypatch.setattr(codex_auth_module, "get_settings", lambda: DummySettings())
|
||||
return CodexAuthEngine(
|
||||
email="tester@example.com",
|
||||
password="Pass12345",
|
||||
email_service=FakeEmailService(),
|
||||
email_service_id="svc-1",
|
||||
)
|
||||
|
||||
|
||||
def test_codex_auth_run_reuses_working_login_flow_without_manual_otp_send(monkeypatch):
|
||||
engine = _build_engine(monkeypatch)
|
||||
|
||||
def fake_start_oauth(self):
|
||||
self.oauth_start = SimpleNamespace(
|
||||
auth_url="https://auth.example.test/oauth/authorize",
|
||||
state="state-1",
|
||||
code_verifier="verifier-1",
|
||||
)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(RegistrationEngine, "_init_session", lambda self: True)
|
||||
monkeypatch.setattr(RegistrationEngine, "_start_oauth", fake_start_oauth)
|
||||
monkeypatch.setattr(RegistrationEngine, "_get_device_id", lambda self: "did-1")
|
||||
monkeypatch.setattr(engine, "_try_reenter_login_flow", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_send_verification_code",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("unexpected manual otp send")),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
seen = {}
|
||||
monkeypatch.setattr(codex_auth_module.time, "time", lambda: 1_700_000_000.0)
|
||||
|
||||
def fake_submit_login_password_step():
|
||||
seen["anchor_before_password"] = engine._otp_sent_at
|
||||
return True
|
||||
|
||||
def fake_phase_otp_secondary(context, started_at=None):
|
||||
seen["anchor_before_wait"] = context.otp_sent_at
|
||||
seen["otp_wait_started_at"] = started_at
|
||||
return "654321", PhaseResult(phase="otp_secondary", success=True)
|
||||
|
||||
monkeypatch.setattr(engine, "_submit_login_password_step", fake_submit_login_password_step)
|
||||
monkeypatch.setattr(engine, "_phase_otp_secondary", fake_phase_otp_secondary)
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_validate_verification_code_and_get_continue_url",
|
||||
lambda code: (True, "https://auth.example.test/consent"),
|
||||
)
|
||||
monkeypatch.setattr(engine, "_resolve_workspace_id", lambda consent_url: "ws-1")
|
||||
monkeypatch.setattr(
|
||||
RegistrationEngine,
|
||||
"_select_workspace",
|
||||
lambda self, workspace_id: "https://auth.example.test/continue",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
RegistrationEngine,
|
||||
"_follow_redirects",
|
||||
lambda self, continue_url: "http://localhost:1455/auth/callback?code=code-1&state=state-1",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
RegistrationEngine,
|
||||
"_handle_oauth_callback",
|
||||
lambda self, callback_url: {
|
||||
"id_token": "id-token",
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"account_id": "acct-1",
|
||||
},
|
||||
)
|
||||
|
||||
result = engine.run()
|
||||
|
||||
assert result.success is True
|
||||
assert result.workspace_id == "ws-1"
|
||||
assert result.auth_json["auth_mode"] == "chatgpt"
|
||||
assert result.auth_json["OPENAI_API_KEY"] is None
|
||||
assert result.auth_json["tokens"] == {
|
||||
"id_token": "id-token",
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"account_id": "acct-1",
|
||||
}
|
||||
assert result.auth_json["last_refresh"]
|
||||
assert seen["anchor_before_password"] == 1_700_000_000.0
|
||||
assert seen["anchor_before_wait"] == 1_700_000_000.0
|
||||
assert seen["otp_wait_started_at"] == 1_700_000_000.0
|
||||
|
||||
|
||||
def test_resolve_workspace_id_falls_back_to_cookie_path_when_consent_page_has_no_workspace(monkeypatch):
|
||||
engine = _build_engine(monkeypatch)
|
||||
consent_url = "https://auth.example.test/consent"
|
||||
engine.oauth_start = SimpleNamespace(auth_url="https://auth.example.test/oauth/authorize")
|
||||
engine.session = FakeSession(
|
||||
FakeResponse(
|
||||
status_code=200,
|
||||
url=consent_url,
|
||||
text="<html><body>consent</body></html>",
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_extract_workspace_id_from_response",
|
||||
lambda response=None, html=None, url=None: None,
|
||||
)
|
||||
monkeypatch.setattr(RegistrationEngine, "_get_workspace_id", lambda self: "ws-cookie")
|
||||
|
||||
workspace_id = engine._resolve_workspace_id(consent_url)
|
||||
|
||||
assert workspace_id == "ws-cookie"
|
||||
assert engine.session.calls == [{"url": consent_url, "kwargs": {"timeout": 20}}]
|
||||
@@ -104,7 +104,7 @@ def test_registration_task_fails_over_after_rate_limit(monkeypatch):
|
||||
attempts = []
|
||||
|
||||
class FakeRegistrationEngine:
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||
self.email_service = email_service
|
||||
self.phase_history = []
|
||||
|
||||
@@ -240,7 +240,7 @@ def test_registration_task_enters_deep_cooldown_after_three_otp_timeouts(monkeyp
|
||||
current_time = {"value": 1000.0}
|
||||
|
||||
class FakeRegistrationEngine:
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||
self.email_service = email_service
|
||||
self.phase_history = []
|
||||
|
||||
@@ -350,7 +350,7 @@ def test_registration_task_success_clears_email_service_backoff(monkeypatch):
|
||||
pass
|
||||
|
||||
class FakeRegistrationEngine:
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||
self.email_service = email_service
|
||||
self.phase_history = [
|
||||
PhaseResult(
|
||||
@@ -458,7 +458,7 @@ def test_registration_task_backoff_failures_do_not_get_lost_under_concurrency(mo
|
||||
peer_started = threading.Event()
|
||||
|
||||
class FakeRegistrationEngine:
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||
self.email_service = email_service
|
||||
self.phase_history = []
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_run_sync_registration_task_disables_bad_proxy_and_retries(monkeypatch,
|
||||
saved_results = []
|
||||
|
||||
class FakeRegistrationEngine:
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
|
||||
def __init__(self, email_service, proxy_url=None, callback_logger=None, status_callback=None, task_uuid=None):
|
||||
self.proxy_url = proxy_url
|
||||
|
||||
def run(self):
|
||||
|
||||
Reference in New Issue
Block a user