feat: add Codex CLI auth import

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-12 17:18:33 +08:00
co-authored by Codex
parent e051b3ade9
commit a3d308f882
8 changed files with 627 additions and 231 deletions
File diff suppressed because one or more lines are too long
@@ -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.
+3 -2
View File
@@ -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))
+93
View File
@@ -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<SecretString>,
id_token: Option<SecretString>,
expires_at: Option<i64>,
}
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<i64> {
self.expires_at
}
}
#[derive(Debug, Deserialize)]
struct CliAuthJson {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
expires_at: Option<i64>,
}
pub fn default_codex_home() -> Result<PathBuf, CliAuthImportError> {
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<CliAuth, CliAuthImportError> {
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<CliAuth, CliAuthImportError> {
let parsed = serde_json::from_str::<CliAuthJson>(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<String>) -> Option<SecretString> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| SecretString::new(value.into()))
}
+1
View File
@@ -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;
+387 -223
View File
@@ -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<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportCliAuthRequest {
pub codex_home: Option<String>,
}
#[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<AppState>,
Extension(request_id): Extension<RequestId>,
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::<ImportCliAuthRequest>(&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<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -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<String>,
refresh_token: Option<String>,
) -> Result<StoredAccount, ValidatedAccountImportError> {
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<Utc>,
@@ -2606,6 +2684,92 @@ fn string_claim(map: &Map<String, Value>, key: &str) -> Option<String> {
.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<StoredImportAccount, StoreImportAccountError> {
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>) -> String {
id.and_then(|id| empty_to_none(Some(id)))
.unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple()))
+91 -1
View File
@@ -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,
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,
+44
View File
@@ -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"));
}