Files
cdxs/src/auth_file.rs
T

123 lines
4.0 KiB
Rust

//! Read and write Codex `auth.json`.
//!
//! The file format is not owned by cdxs, so this module keeps the compatibility
//! boundary small and converts between Codex's JSON shape and cdxs account
//! records.
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use crate::config_store::{Account, AuthMode, Tokens};
use crate::{atomic, config_store};
#[derive(Debug, Deserialize)]
pub struct CodexAuthFile {
/// Present for API-key based auth files.
#[serde(default)]
pub auth_mode: Option<String>,
#[serde(rename = "OPENAI_API_KEY", default)]
pub openai_api_key: Option<serde_json::Value>,
#[serde(default, alias = "api_base_url", alias = "apiBaseUrl")]
pub base_url: Option<String>,
#[serde(default)]
pub tokens: Option<CodexAuthTokens>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CodexAuthTokens {
pub id_token: String,
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
}
pub fn read_auth_file(path: &Path) -> Result<CodexAuthFile> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取 auth.json 失败: {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("解析 auth.json 失败: {}", path.display()))
}
pub fn write_account_to_auth(path: &Path, codex_home: &Path, account: &Account) -> Result<()> {
// Preserve Codex's expected auth.json shape for each auth mode. Existing
// files are backed up before replacement by the atomic helper.
let value = match account.auth_mode {
AuthMode::Oauth => {
let tokens = account
.tokens
.as_ref()
.ok_or_else(|| anyhow!("OAuth 账号缺少 tokens"))?;
serde_json::json!({
"OPENAI_API_KEY": null,
"tokens": {
"id_token": tokens.id_token,
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"account_id": account.account_id,
},
"last_refresh": chrono::Utc::now().to_rfc3339(),
})
}
AuthMode::ApiKey => {
let key = account
.openai_api_key
.as_deref()
.ok_or_else(|| anyhow!("API Key 账号缺少 OPENAI_API_KEY"))?;
serde_json::json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": key,
})
}
};
atomic::backup_if_exists(path, codex_home, "auth.json")?;
let content = serde_json::to_string_pretty(&value).context("序列化 auth.json 失败")?;
atomic::write_atomic(path, &content)
}
pub fn is_api_key_mode(auth: &CodexAuthFile) -> bool {
// Some auth files explicitly say apikey, while older/minimal files simply
// omit tokens and include OPENAI_API_KEY.
auth.auth_mode
.as_deref()
.map(|mode| mode.eq_ignore_ascii_case("apikey"))
.unwrap_or(false)
|| (auth.tokens.is_none() && extract_api_key(auth).is_some())
}
pub fn extract_api_key(auth: &CodexAuthFile) -> Option<String> {
auth.openai_api_key
.as_ref()
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn auth_tokens_to_store(tokens: CodexAuthTokens) -> Tokens {
Tokens {
id_token: tokens.id_token,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}
}
pub fn api_base_url(auth: &CodexAuthFile) -> Option<String> {
auth.base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_end_matches('/').to_string())
}
pub fn account_auth_mode_name(account: &config_store::Account) -> &'static str {
match account.auth_mode {
AuthMode::Oauth => "oauth",
AuthMode::ApiKey => "api_key",
}
}