From a3d308f882ba3203fc3920ebd994157577d433fb Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 12 Jun 2026 15:56:47 +0800 Subject: [PATCH] feat: add Codex CLI auth import Co-authored-by: Codex --- docs/implementation-status.md | 4 +- .../2026-06-12-openai-gpt-codex-parity.md | 9 +- src/app.rs | 5 +- src/auth/cli_import.rs | 93 +++ src/auth/mod.rs | 1 + src/http/admin.rs | 610 +++++++++++------- tests/admin_accounts_route_test.rs | 92 ++- tests/cli_auth_import_test.rs | 44 ++ 8 files changed, 627 insertions(+), 231 deletions(-) create mode 100644 src/auth/cli_import.rs create mode 100644 tests/cli_auth_import_test.rs diff --git a/docs/implementation-status.md b/docs/implementation-status.md index c7d8bf504..7a311b7ac 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -13,10 +13,10 @@ Update this file before each feature commit. | Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, admin `/admin/logs` query API, admin `/admin/usage-stats` and `/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. | | TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | | Cookie persistence | Completed | `4710926` + `5d3cc6e` + `203270f` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before `203270f` | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. Admin-pasted browser Cookie headers are stored encrypted and can be cleared per account. | -| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, and `cargo test --test account_pool_scheduling_test`; full `fmt/test/clippy` before `db6cb98` | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, 401-triggered refresh and single retry for `/v1/responses`, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, admin label/status/delete runtime pool synchronization, and shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, and Chat Completions. Still missing durable release-time slot state, WebSocket/history fallback policy, durable quota cooldown persistence, and remaining admin account operations. | +| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, and `cargo test --test account_pool_scheduling_test`; full `fmt/test/clippy` before `db6cb98` | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, 401-triggered refresh and single retry for `/v1/responses`, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process manual/import/CLI import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, admin label/status/delete runtime pool synchronization, and shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, and Chat Completions. Still missing durable release-time slot state, WebSocket/history fallback policy, durable quota cooldown persistence, and remaining admin account operations. | | Account repository | Completed | `14fe706` + `db6cb98` + `72438c6` | `cargo test --test account_repository_test`; `cargo test --test admin_accounts_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `72438c6` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, full export reads, status/label updates, account deletion, release-time usage counter persistence, refresh-token-preserving token updates, ChatGPT account/user identity lookup, and JWT-claim metadata/token updates that preserve existing labels. | | Translation | Completed | `eff182e` + `b9c30ba` | `cargo test --test routes_chat_test`; `cargo test --test chat_completions_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` | Adds OpenAI Chat request types, expanded Codex Responses request type, OpenAI Chat to Codex translation for system/developer instructions, multimodal content, tool/function calls, response formats, reasoning effort, service tier, and parallel tool calls. Codex SSE now converts back to OpenAI chat completions and chat chunks with text deltas, reasoning content, tool calls, usage details, and OpenAI-compatible error bodies. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6`; current manual account correction `cargo test --test admin_accounts_route_test` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/export`, `/admin/accounts/batch-delete`, `/admin/accounts/batch-status`, `/admin/accounts/{id}`, `/admin/accounts/{id}/label`, `/admin/accounts/{id}/status`, `/admin/accounts/{id}/cookies`, `/admin/accounts/import`, `/admin/api-keys`, `/admin/api-keys/batch-delete`, `/admin/api-keys/{id}`, `/admin/api-keys/{id}/label`, `/admin/api-keys/{id}/status`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` now accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. `/admin/accounts/export` returns native or Sub2API OpenAI OAuth exports without proxy fields. `/admin/accounts/{id}/label`, `/status`, delete, batch-delete, and batch-status mutate SQLite plus runtime account pool. `/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; these are local auth utilities, not TS provider key pool parity. `/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing OAuth/device/CLI account login flows, admin account health/quota/reset-usage routes, settings mutation/persistence, diagnostics, live Chat streaming transform, and WebSocket/history fallback policy. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test cli_auth_import_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6`; current manual account correction `cargo test --test admin_accounts_route_test` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/export`, `/admin/accounts/batch-delete`, `/admin/accounts/batch-status`, `/admin/accounts/{id}`, `/admin/accounts/{id}/label`, `/admin/accounts/{id}/status`, `/admin/accounts/{id}/cookies`, `/admin/accounts/import`, `/admin/accounts/import-cli`, `/admin/api-keys`, `/admin/api-keys/batch-delete`, `/admin/api-keys/{id}`, `/admin/api-keys/{id}/label`, `/admin/api-keys/{id}/status`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` now accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. `/admin/accounts/import-cli` reads Codex CLI `auth.json` from `$CODEX_HOME` or an explicit admin fixture path, uses the same validated JWT-claim account import path as manual add, ignores caller metadata, and returns only sanitized import counts. `/admin/accounts/export` returns native or Sub2API OpenAI OAuth exports without proxy fields. `/admin/accounts/{id}/label`, `/status`, delete, batch-delete, and batch-status mutate SQLite plus runtime account pool. `/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; these are local auth utilities, not TS provider key pool parity. `/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing OAuth/device account login flows, admin account health/quota/reset-usage routes, settings mutation/persistence, diagnostics, live Chat streaming transform, and WebSocket/history fallback policy. | | Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. | | HTTP auth boundary | Completed | `cec4607` + `e9fdea0` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only enabled local `Bearer cpr_...` client API keys verified from SQLite, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | | Model catalog | Partial | `6341336` + `1738f24` + `f75db60` + `4b19846` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models`; `cargo test --test admin_models_route_test`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846` | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing including `none` and `minimal`, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, debug summary, SQLite-backed backend model snapshots by plan, cached backend catalog merge, model-to-plan allowlist derivation, and an admin refresh route that probes Codex model endpoints with one imported account per plan and syncs the runtime account-pool allowlist. Still missing full model metadata parity. | diff --git a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md index b5d4bb8c5..c543a525f 100644 --- a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md +++ b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md @@ -8,7 +8,7 @@ **Tech Stack:** Rust, Axum, Reqwest/rustls, serde, sqlx/SQLite, wiremock, tokio, futures, optional WebSocket transport dependency. -**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and Sub2API account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens; the current correction narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. Remaining major work continues with WebSocket/history fallback policy and scoped admin operations. +**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and Sub2API account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens; the current correction narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts and adds deterministic Codex CLI `auth.json` import through the same validated JWT-claim path. Remaining major work continues with WebSocket/history fallback policy, PKCE/device login, and scoped admin operations. --- @@ -27,12 +27,12 @@ This audit is the correction point against the TypeScript reference at `/home/zy | Local client API key label/batch-delete / `960bbb2` | TS label/batch-delete applies to third-party provider key pool entries. | Rust label/batch-delete applies to local HMAC-hashed `cpr_` client keys. | Rust local extension, possible drift if strict parity is required. | Reclassify in docs as local admin utility. Remove only if strict original parity excludes local client-key management beyond create/list/status/delete. | | API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. | Intentional scope cut. | Do not port TS provider key import/export. Document that local client keys cannot be safely exported after creation. | | Model refresh and plan allowlist sync / `f75db60`, `4b19846` | TS has provider/key model catalog behavior mixed with account/provider management. | Rust probes Codex backend models via imported accounts and syncs plan allowlists into the account pool. | Partial parity, scoped Rust adaptation. | Keep; avoid third-party provider catalog behavior. Finish full Codex model metadata parity later if needed. | -| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust still lacks OAuth PKCE login-start/code-relay/callback, device login/poll, CLI import, validated manual token import, logout/auth status equivalents, quota fetch, and health check routes. | OpenAI/Codex parity gap. | Continue after correcting manual account creation. | +| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports deterministic admin CLI import from Codex `auth.json` through `/admin/accounts/import-cli`, storing tokens encrypted and syncing the runtime account pool. Rust still lacks OAuth PKCE login-start/code-relay/callback, device login/poll, validated manual token import, logout/auth status equivalents, quota fetch, and health check routes. | Partial OpenAI/Codex parity. | Continue after correcting manual account creation. | | Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, Anthropic/Gemini/Ollama/custom provider support, or Electron/frontend flows. | Immediate priority after this audit: -1. Finish in-scope account operation parity: OAuth PKCE, device login, CLI import, quota fetch, health check, logout/status/reset-usage. +1. Finish in-scope account operation parity: OAuth PKCE, device login, quota fetch, health check, logout/status/reset-usage. 2. Finish Task 5 WebSocket/history fallback policy without breaking `previous_response_id` account affinity. 3. Keep local client API key utilities documented as Rust-local auth, not TypeScript provider-key parity. @@ -165,6 +165,9 @@ Immediate priority after this audit: - [x] Correct manual account creation to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. - [x] Run: `cargo test --test admin_accounts_route_test` - [ ] Reclassify local client API key label/batch operations as Rust-local admin utilities, and avoid treating TS provider API key export/import as in-scope parity. +- [x] Write failing tests for Codex CLI `auth.json` import through admin account import. +- [x] Implement `/admin/accounts/import-cli` using a reusable CLI auth parser and the shared validated JWT-claim account import path, without frontend/proxy/provider behavior. +- [x] Run: `cargo test --test cli_auth_import_test`; `cargo test --test admin_accounts_route_test admin_accounts_import_cli_should_read_codex_auth_file_store_encrypted_and_sync_pool` - [ ] Write failing tests for quota fetch, health check, account login/import variants, and any accepted local client-key utilities. - [ ] Run: `cargo test --test admin_accounts_route_test --test admin_api_keys_route_test` - [ ] Implement scoped admin operations needed to operate Codex-backed OpenAI GPT routes. diff --git a/src/app.rs b/src/app.rs index 0aa6dbf7d..e0c2b4333 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,8 +11,8 @@ use crate::{ accounts, api_keys, batch_delete_accounts, batch_delete_api_keys, batch_update_account_status, create_account, create_api_key, delete_account, delete_account_cookies, delete_api_key, export_accounts, get_account_cookies, - import_accounts, login, logs, refresh_models, set_account_cookies, settings, - update_account_label, update_account_status, update_api_key_label, + import_accounts, import_cli_auth, login, logs, refresh_models, set_account_cookies, + settings, update_account_label, update_account_status, update_api_key_label, update_api_key_status, usage_stats, usage_stats_summary, }, health::health, @@ -64,6 +64,7 @@ pub fn build_router(state: AppState) -> Router { patch(update_account_status), ) .route("/admin/accounts/import", post(import_accounts)) + .route("/admin/accounts/import-cli", post(import_cli_auth)) .route("/admin/api-keys", get(api_keys).post(create_api_key)) .route("/admin/api-keys/batch-delete", post(batch_delete_api_keys)) .route("/admin/api-keys/{key_id}", delete(delete_api_key)) diff --git a/src/auth/cli_import.rs b/src/auth/cli_import.rs new file mode 100644 index 000000000..e9fb8e7c4 --- /dev/null +++ b/src/auth/cli_import.rs @@ -0,0 +1,93 @@ +use std::{ + env, fs, io, + path::{Path, PathBuf}, +}; + +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CliAuthImportError { + #[error("CODEX_HOME is not set and HOME is unavailable")] + MissingHome, + #[error("failed to read CLI auth file {path}: {source}")] + Read { path: PathBuf, source: io::Error }, + #[error("failed to parse CLI auth.json: {0}")] + Parse(#[from] serde_json::Error), + #[error("CLI auth.json does not contain access_token")] + MissingAccessToken, +} + +#[derive(Debug, Clone)] +pub struct CliAuth { + access_token: SecretString, + refresh_token: Option, + id_token: Option, + expires_at: Option, +} + +impl CliAuth { + pub fn access_token(&self) -> &str { + self.access_token.expose_secret() + } + + pub fn refresh_token(&self) -> Option<&str> { + self.refresh_token + .as_ref() + .map(|token| token.expose_secret()) + } + + pub fn id_token(&self) -> Option<&str> { + self.id_token.as_ref().map(|token| token.expose_secret()) + } + + pub fn expires_at(&self) -> Option { + self.expires_at + } +} + +#[derive(Debug, Deserialize)] +struct CliAuthJson { + access_token: Option, + refresh_token: Option, + id_token: Option, + expires_at: Option, +} + +pub fn default_codex_home() -> Result { + if let Some(codex_home) = env::var_os("CODEX_HOME") { + return Ok(PathBuf::from(codex_home)); + } + let home = env::var_os("HOME").ok_or(CliAuthImportError::MissingHome)?; + Ok(PathBuf::from(home).join(".codex")) +} + +pub fn read_cli_auth_from_home(codex_home: &Path) -> Result { + let auth_path = codex_home.join("auth.json"); + let raw = fs::read_to_string(&auth_path).map_err(|source| CliAuthImportError::Read { + path: auth_path, + source, + })?; + parse_cli_auth_json(&raw) +} + +pub fn parse_cli_auth_json(raw: &str) -> Result { + let parsed = serde_json::from_str::(raw)?; + let access_token = + non_empty_secret(parsed.access_token).ok_or(CliAuthImportError::MissingAccessToken)?; + + Ok(CliAuth { + access_token, + refresh_token: non_empty_secret(parsed.refresh_token), + id_token: non_empty_secret(parsed.id_token), + expires_at: parsed.expires_at, + }) +} + +fn non_empty_secret(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| SecretString::new(value.into())) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index caf1b9c7a..3ea33c700 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,6 +1,7 @@ pub mod admin_session; pub mod api_key; pub mod api_key_repository; +pub mod cli_import; pub mod error; pub mod oauth; pub mod refresh; diff --git a/src/http/admin.rs b/src/http/admin.rs index 1aabc2818..b1c8188f5 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -1,4 +1,5 @@ use axum::{ + body::Bytes, extract::{Path, Query, State}, http::{ header::{HeaderValue, SET_COOKIE}, @@ -24,7 +25,11 @@ use crate::{ }, }, auth::api_key_repository::StoredClientApiKey, - auth::{admin_session::verify_admin_password, refresh::RefreshFailure}, + auth::{ + admin_session::verify_admin_password, + cli_import::{default_codex_home, read_cli_auth_from_home}, + refresh::RefreshFailure, + }, codex::client::{build_reqwest_client, CodexBackendClient, CodexRequestContext}, config::{AppConfig, QuotaWarningThresholds}, fingerprint::model::Fingerprint, @@ -199,6 +204,7 @@ pub struct AdminSettingsData { enum AccountImportFormat { Native, Sub2Api, + CodexCli, } impl AccountImportFormat { @@ -206,6 +212,7 @@ impl AccountImportFormat { match self { Self::Native => "native", Self::Sub2Api => "sub2api", + Self::CodexCli => "codex_cli", } } } @@ -244,6 +251,12 @@ pub struct CreateAccountRequest { pub refresh_token: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportCliAuthRequest { + pub codex_home: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AccountExportFormat { Native, @@ -1121,167 +1134,13 @@ pub async fn create_account( .into_response(); }; - let (access_token, refresh_token_update, new_account_refresh_token) = match ( - empty_to_none(payload.token.map(normalize_bearer_token)), - empty_to_none(payload.refresh_token), - ) { - (Some(token), refresh_token) => (token, refresh_token.clone(), refresh_token), - (None, Some(refresh_token)) => { - let Some(refresher) = state.token_refresher() else { - return AdminResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminEnvelope::new(50001, "Token refresher is not initialized", (), request_id), - ) - .into_response(); - }; - let tokens = match refresher.refresh(&refresh_token).await { - Ok(tokens) => tokens, - Err(RefreshFailure::Transport) => { - return AdminResponse::new( - StatusCode::BAD_GATEWAY, - AdminEnvelope::new(50201, "Refresh token exchange failed", (), request_id), - ) - .into_response(); - } - Err(_) => { - return AdminResponse::new( - StatusCode::BAD_REQUEST, - AdminEnvelope::new(40001, "Refresh token exchange failed", (), request_id), - ) - .into_response(); - } - }; - let access_token = normalize_bearer_token(tokens.access_token); - let rotated_refresh_token = empty_to_none(tokens.refresh_token); - let new_account_refresh_token = rotated_refresh_token - .clone() - .or_else(|| Some(refresh_token.clone())); - ( - access_token, - rotated_refresh_token, - new_account_refresh_token, - ) - } - (None, None) => { - return AdminResponse::new( - StatusCode::BAD_REQUEST, - AdminEnvelope::new( - 40001, - "Either token or refreshToken is required", - (), - request_id, - ), - ) - .into_response(); - } - }; - - // 手动导入只信任 ChatGPT JWT claim;客户端传来的展示字段不能参与账号身份判定。 - let claims = match manual_account_claims(&access_token, Utc::now()) { - Ok(claims) => claims, - Err(message) => { - return AdminResponse::new( - StatusCode::BAD_REQUEST, - AdminEnvelope::new(40001, message, (), request_id), - ) - .into_response(); - } - }; - - let existing = match repo - .find_by_chatgpt_identity(&claims.account_id, claims.user_id.as_deref()) - .await - { - Ok(account) => account, - Err(_) => { - return AdminResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminEnvelope::new(50001, "Failed to inspect account", (), request_id), - ) - .into_response(); - } - }; - - let account_id = if let Some(existing) = existing { - let updated = repo - .update_from_claims( - &existing.id, - AccountClaimsUpdate { - email: claims.email.clone(), - account_id: Some(claims.account_id.clone()), - user_id: claims.user_id.clone(), - plan_type: claims.plan_type.clone(), - access_token: SecretString::new(access_token.into()), - refresh_token: refresh_token_update - .map(|token| SecretString::new(token.into())), - access_token_expires_at: Some(claims.expires_at), - status: AccountStatus::Active, - }, - ) - .await; - match updated { - Ok(true) => existing.id, - Ok(false) => { - return AdminResponse::new( - StatusCode::NOT_FOUND, - AdminEnvelope::new(40401, "Account not found", (), request_id), - ) - .into_response(); - } - Err(_) => { - return AdminResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminEnvelope::new(50001, "Failed to update account", (), request_id), - ) - .into_response(); - } - } - } else { - let id = normalized_account_id(None); - let account = NewAccount { - id: id.clone(), - email: claims.email.clone(), - account_id: Some(claims.account_id.clone()), - user_id: claims.user_id.clone(), - label: None, - plan_type: claims.plan_type.clone(), - access_token: SecretString::new(access_token.into()), - refresh_token: new_account_refresh_token.map(|token| SecretString::new(token.into())), - access_token_expires_at: Some(claims.expires_at), - status: AccountStatus::Active, + let stored = + match store_validated_account_import(&state, &repo, payload.token, payload.refresh_token) + .await + { + Ok(stored) => stored, + Err(error) => return validated_account_import_error_response(error, request_id), }; - if repo.insert(account).await.is_err() { - return AdminResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminEnvelope::new(50001, "Failed to create account", (), request_id), - ) - .into_response(); - } - id - }; - - let stored = match repo.get(&account_id).await { - Ok(Some(account)) => account, - Ok(None) => { - return AdminResponse::new( - StatusCode::NOT_FOUND, - AdminEnvelope::new(40401, "Account not found", (), request_id), - ) - .into_response(); - } - Err(_) => { - return AdminResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminEnvelope::new(50001, "Failed to load account", (), request_id), - ) - .into_response(); - } - }; - state - .account_pool() - .lock() - .await - .insert(pool_account_from_stored(stored.clone())); // 手动添加账号的响应只返回可展示元数据,OAuth token 永不回显。 AdminResponse::new( @@ -1937,82 +1796,35 @@ pub async fn import_accounts( let mut imported = 0u32; let mut skipped = 0u32; for entry in parsed.accounts { - let token = entry.token.as_deref().unwrap_or_default().trim(); - if token.is_empty() { - skipped += 1; - continue; - } - let id = normalized_account_id(entry.id); - match repo.exists(&id).await { - Ok(true) => { - skipped += 1; - continue; + match store_import_account_entry(&state, &repo, entry).await { + Ok(StoredImportAccount::Imported) => { + imported += 1; } - Ok(false) => {} - Err(_) => { + Ok(StoredImportAccount::Skipped) => { + skipped += 1; + } + Err(StoreImportAccountError::Inspect) => { return AdminResponse::new( StatusCode::INTERNAL_SERVER_ERROR, AdminEnvelope::new(50001, "Failed to inspect account", (), request_id), ) .into_response(); } - } - - let status = match parse_import_status(entry.status.as_deref()) { - Ok(status) => status, - Err(message) => { + Err(StoreImportAccountError::Invalid(message)) => { return AdminResponse::new( StatusCode::BAD_REQUEST, AdminEnvelope::new(40001, message, (), request_id), ) .into_response(); } - }; - let email = empty_to_none(entry.email); - let account_id = empty_to_none(entry.account_id); - let user_id = empty_to_none(entry.user_id); - let label = empty_to_none(entry.label); - let plan_type = empty_to_none(entry.plan_type); - let refresh_token = empty_to_none(entry.refresh_token); - let access_token = token.to_string(); - let pool_account = Account { - id: id.clone(), - email: email.clone(), - account_id: account_id.clone(), - user_id: user_id.clone(), - label: label.clone(), - plan_type: plan_type.clone(), - access_token: access_token.clone(), - refresh_token: refresh_token.clone(), - access_token_expires_at: None, - status, - quota_limit_reached: false, - quota_cooldown_until: None, - cloudflare_cooldown_until: None, - added_at: chrono::Utc::now().to_rfc3339(), - last_used_at: None, - }; - let account = NewAccount { - id, - email, - account_id, - user_id, - label, - plan_type, - access_token: SecretString::new(access_token.into()), - refresh_token: refresh_token.map(|token| SecretString::new(token.into())), - access_token_expires_at: None, - status, - }; - if repo.insert(account).await.is_err() { - return AdminResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - AdminEnvelope::new(50001, "Failed to import account", (), request_id), - ) - .into_response(); + Err(StoreImportAccountError::Insert) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to import account", (), request_id), + ) + .into_response(); + } } - state.account_pool().lock().await.insert(pool_account); - imported += 1; } AdminResponse::new( @@ -2029,6 +1841,98 @@ pub async fn import_accounts( .into_response() } +pub async fn import_cli_auth( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + body: Bytes, +) -> Response { + let request_id = request_id.as_str().to_string(); + let Some(pool) = state.db() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Database is not initialized", (), request_id), + ) + .into_response(); + }; + if let Err(response) = require_admin_session(pool, &headers, &request_id).await { + return response; + } + let Some(repo) = state.account_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Account repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + let payload = if body.is_empty() { + ImportCliAuthRequest::default() + } else { + match serde_json::from_slice::(&body) { + Ok(payload) => payload, + Err(_) => { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "Invalid CLI import request", (), request_id), + ) + .into_response(); + } + } + }; + let codex_home = match empty_to_none(payload.codex_home) { + Some(path) => std::path::PathBuf::from(path), + None => match default_codex_home() { + Ok(path) => path, + Err(error) => { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, error.to_string(), (), request_id), + ) + .into_response(); + } + }, + }; + let cli_auth = match read_cli_auth_from_home(&codex_home) { + Ok(auth) => auth, + Err(error) => { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, error.to_string(), (), request_id), + ) + .into_response(); + } + }; + let _stored = match store_validated_account_import( + &state, + &repo, + Some(cli_auth.access_token().to_string()), + cli_auth.refresh_token().map(str::to_string), + ) + .await + { + Ok(stored) => stored, + Err(error) => return validated_account_import_error_response(error, request_id), + }; + + AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + AccountImportData { + imported: 1, + skipped: 0, + source_format: AccountImportFormat::CodexCli.as_str().to_string(), + }, + request_id, + ), + ) + .into_response() +} + pub async fn api_keys( State(state): State, Extension(request_id): Extension, @@ -2546,6 +2450,180 @@ fn admin_account_data_from_stored(account: StoredAccount) -> AdminAccountData { } } +#[derive(Debug)] +enum ValidatedAccountImportError { + TokenRequired, + TokenRefresherUnavailable, + RefreshTransport, + RefreshRejected, + InvalidToken(&'static str), + Inspect, + NotFound, + Update, + Insert, + Load, +} + +async fn store_validated_account_import( + state: &AppState, + repo: &crate::accounts::repository::AccountRepository, + token: Option, + refresh_token: Option, +) -> Result { + let (access_token, refresh_token_update, new_account_refresh_token) = match ( + empty_to_none(token.map(normalize_bearer_token)), + empty_to_none(refresh_token), + ) { + (Some(token), refresh_token) => (token, refresh_token.clone(), refresh_token), + (None, Some(refresh_token)) => { + let Some(refresher) = state.token_refresher() else { + return Err(ValidatedAccountImportError::TokenRefresherUnavailable); + }; + let tokens = match refresher.refresh(&refresh_token).await { + Ok(tokens) => tokens, + Err(RefreshFailure::Transport) => { + return Err(ValidatedAccountImportError::RefreshTransport); + } + Err(_) => return Err(ValidatedAccountImportError::RefreshRejected), + }; + let access_token = normalize_bearer_token(tokens.access_token); + let rotated_refresh_token = empty_to_none(tokens.refresh_token); + let new_account_refresh_token = rotated_refresh_token.clone().or(Some(refresh_token)); + ( + access_token, + rotated_refresh_token, + new_account_refresh_token, + ) + } + (None, None) => return Err(ValidatedAccountImportError::TokenRequired), + }; + + // 手动和 CLI 导入都只信任 ChatGPT JWT claim;请求体里的展示字段不能参与账号身份判定。 + let claims = manual_account_claims(&access_token, Utc::now()) + .map_err(ValidatedAccountImportError::InvalidToken)?; + let existing = repo + .find_by_chatgpt_identity(&claims.account_id, claims.user_id.as_deref()) + .await + .map_err(|_| ValidatedAccountImportError::Inspect)?; + + let account_id = if let Some(existing) = existing { + let updated = repo + .update_from_claims( + &existing.id, + AccountClaimsUpdate { + email: claims.email.clone(), + account_id: Some(claims.account_id.clone()), + user_id: claims.user_id.clone(), + plan_type: claims.plan_type.clone(), + access_token: SecretString::new(access_token.into()), + refresh_token: refresh_token_update + .map(|token| SecretString::new(token.into())), + access_token_expires_at: Some(claims.expires_at), + status: AccountStatus::Active, + }, + ) + .await + .map_err(|_| ValidatedAccountImportError::Update)?; + if !updated { + return Err(ValidatedAccountImportError::NotFound); + } + existing.id + } else { + let id = normalized_account_id(None); + let account = NewAccount { + id: id.clone(), + email: claims.email.clone(), + account_id: Some(claims.account_id.clone()), + user_id: claims.user_id.clone(), + label: None, + plan_type: claims.plan_type.clone(), + access_token: SecretString::new(access_token.into()), + refresh_token: new_account_refresh_token.map(|token| SecretString::new(token.into())), + access_token_expires_at: Some(claims.expires_at), + status: AccountStatus::Active, + }; + repo.insert(account) + .await + .map_err(|_| ValidatedAccountImportError::Insert)?; + id + }; + + let stored = repo + .get(&account_id) + .await + .map_err(|_| ValidatedAccountImportError::Load)? + .ok_or(ValidatedAccountImportError::NotFound)?; + state + .account_pool() + .lock() + .await + .insert(pool_account_from_stored(stored.clone())); + Ok(stored) +} + +fn validated_account_import_error_response( + error: ValidatedAccountImportError, + request_id: String, +) -> Response { + match error { + ValidatedAccountImportError::TokenRequired => AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new( + 40001, + "Either token or refreshToken is required", + (), + request_id, + ), + ) + .into_response(), + ValidatedAccountImportError::TokenRefresherUnavailable => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Token refresher is not initialized", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::RefreshTransport => AdminResponse::new( + StatusCode::BAD_GATEWAY, + AdminEnvelope::new(50201, "Refresh token exchange failed", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::RefreshRejected => AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "Refresh token exchange failed", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::InvalidToken(message) => AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, message, (), request_id), + ) + .into_response(), + ValidatedAccountImportError::Inspect => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to inspect account", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::NotFound => AdminResponse::new( + StatusCode::NOT_FOUND, + AdminEnvelope::new(40401, "Account not found", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::Update => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to update account", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::Insert => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to create account", (), request_id), + ) + .into_response(), + ValidatedAccountImportError::Load => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to load account", (), request_id), + ) + .into_response(), + } +} + fn manual_account_claims( token: &str, now: DateTime, @@ -2606,6 +2684,92 @@ fn string_claim(map: &Map, key: &str) -> Option { .map(ToString::to_string) } +#[derive(Debug)] +enum StoredImportAccount { + Imported, + Skipped, +} + +#[derive(Debug)] +enum StoreImportAccountError { + Inspect, + Invalid(String), + Insert, +} + +async fn store_import_account_entry( + state: &AppState, + repo: &crate::accounts::repository::AccountRepository, + entry: AccountImportEntry, +) -> Result { + let access_token = entry.token.as_deref().unwrap_or_default().trim(); + if access_token.is_empty() { + return Ok(StoredImportAccount::Skipped); + } + if entry + .label + .as_ref() + .is_some_and(|label| label.chars().count() > 64) + { + return Err(StoreImportAccountError::Invalid( + "Account label must be 64 characters or fewer".to_string(), + )); + } + + let id = normalized_account_id(entry.id); + match repo.exists(&id).await { + Ok(true) => return Ok(StoredImportAccount::Skipped), + Ok(false) => {} + Err(_) => return Err(StoreImportAccountError::Inspect), + } + + let status = + parse_import_status(entry.status.as_deref()).map_err(StoreImportAccountError::Invalid)?; + let email = empty_to_none(entry.email); + let account_id = empty_to_none(entry.account_id); + let user_id = empty_to_none(entry.user_id); + let label = empty_to_none(entry.label); + let plan_type = empty_to_none(entry.plan_type); + let refresh_token = empty_to_none(entry.refresh_token); + let access_token = access_token.to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let pool_account = Account { + id: id.clone(), + email: email.clone(), + account_id: account_id.clone(), + user_id: user_id.clone(), + label: label.clone(), + plan_type: plan_type.clone(), + access_token: access_token.clone(), + refresh_token: refresh_token.clone(), + access_token_expires_at: None, + status, + quota_limit_reached: false, + quota_cooldown_until: None, + cloudflare_cooldown_until: None, + added_at: now.clone(), + last_used_at: None, + }; + let account = NewAccount { + id: id.clone(), + email: email.clone(), + account_id: account_id.clone(), + user_id: user_id.clone(), + label: label.clone(), + plan_type: plan_type.clone(), + access_token: SecretString::new(access_token.into()), + refresh_token: refresh_token.map(|token| SecretString::new(token.into())), + access_token_expires_at: None, + status, + }; + repo.insert(account) + .await + .map_err(|_| StoreImportAccountError::Insert)?; + state.account_pool().lock().await.insert(pool_account); + + Ok(StoredImportAccount::Imported) +} + fn normalized_account_id(id: Option) -> String { id.and_then(|id| empty_to_none(Some(id))) .unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple())) diff --git a/tests/admin_accounts_route_test.rs b/tests/admin_accounts_route_test.rs index deaf7478e..b5bf2bff4 100644 --- a/tests/admin_accounts_route_test.rs +++ b/tests/admin_accounts_route_test.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, fs, sync::Arc}; use async_trait::async_trait; use axum::{ @@ -1348,6 +1348,96 @@ async fn manual_add_should_preserve_existing_refresh_when_refresh_only_omits_rot assert_eq!(stored.plan_type.as_deref(), Some("team")); } +#[tokio::test] +async fn admin_accounts_import_cli_should_read_codex_auth_file_store_encrypted_and_sync_pool() { + let (app, state, pool, dir) = + admin_accounts_test_app("admin-account-import-cli.sqlite", 28).await; + let codex_home = dir.path().join("codex-home"); + fs::create_dir_all(&codex_home).unwrap(); + let token = test_jwt( + Some("cli-account"), + Some("cli-user"), + Some("cli@example.com"), + Some("plus"), + 3600, + ); + fs::write( + codex_home.join("auth.json"), + json!({ + "access_token": token, + "refresh_token": "cli-refresh-secret" + }) + .to_string(), + ) + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/accounts/import-cli") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_import_cli") + .body(Body::from( + json!({ + "codexHome": codex_home.display().to_string(), + "id": "caller-id", + "email": "caller@example.com", + "accountId": "caller-account", + "userId": "caller-user", + "label": "caller-label", + "planType": "caller-plan" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["sourceFormat"], "codex_cli"); + assert_eq!(body["data"]["imported"], 1); + assert!(body["data"].get("token").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + + let stored: ( + String, + String, + String, + String, + Option, + String, + String, + String, + ) = sqlx::query_as( + "select id, email, account_id, user_id, label, plan_type, access_token_cipher, refresh_token_cipher from accounts limit 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(stored.0, "caller-id"); + assert_eq!(stored.1, "cli@example.com"); + assert_eq!(stored.2, "cli-account"); + assert_eq!(stored.3, "cli-user"); + assert_eq!(stored.4, None); + assert_eq!(stored.5, "plus"); + assert!(stored.6.starts_with("v1:")); + assert!(!stored.6.contains(&token)); + assert!(stored.7.starts_with("v1:")); + assert!(!stored.7.contains("cli-refresh-secret")); + + let acquired = state + .account_pool() + .lock() + .await + .acquire("gpt-5.5") + .unwrap(); + assert_eq!(acquired.id, stored.0); +} + async fn admin_accounts_test_app( db_name: &str, key_byte: u8, diff --git a/tests/cli_auth_import_test.rs b/tests/cli_auth_import_test.rs new file mode 100644 index 000000000..dc6ae0246 --- /dev/null +++ b/tests/cli_auth_import_test.rs @@ -0,0 +1,44 @@ +use std::fs; + +use codex_proxy_rs::auth::cli_import::{parse_cli_auth_json, read_cli_auth_from_home}; + +#[test] +fn parse_cli_auth_json_should_accept_codex_cli_tokens() { + let auth = parse_cli_auth_json( + r#"{ + "access_token": "cli-access-secret", + "refresh_token": "cli-refresh-secret", + "id_token": "cli-id-token", + "expires_at": 1893456000 + }"#, + ) + .unwrap(); + + assert_eq!(auth.access_token(), "cli-access-secret"); + assert_eq!(auth.refresh_token(), Some("cli-refresh-secret")); +} + +#[test] +fn parse_cli_auth_json_should_reject_missing_access_token() { + let error = parse_cli_auth_json(r#"{"refresh_token":"cli-refresh-secret"}"#).unwrap_err(); + + assert_eq!( + error.to_string(), + "CLI auth.json does not contain access_token" + ); +} + +#[test] +fn read_cli_auth_from_home_should_load_auth_json_from_codex_home() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("auth.json"), + r#"{"access_token":"file-access-secret","refresh_token":"file-refresh-secret"}"#, + ) + .unwrap(); + + let auth = read_cli_auth_from_home(dir.path()).unwrap(); + + assert_eq!(auth.access_token(), "file-access-secret"); + assert_eq!(auth.refresh_token(), Some("file-refresh-secret")); +}