123 lines
4.0 KiB
Rust
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",
|
|
}
|
|
}
|