mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add Codex CLI auth import
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
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
@@ -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))
|
||||
|
||||
@@ -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,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
@@ -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()))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user