276 lines
10 KiB
Rust
276 lines
10 KiB
Rust
//! `~/.codex/auth.json` の読み書き。
|
||
//!
|
||
//! Codex CLI と schema を共有するが、yoi は知らないフィールドを
|
||
//! 失わないようファイル全体を `serde_json::Value` で保持し、必要箇所
|
||
//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、
|
||
//! ファイルロックは取らない(manager 側で guarded reload)。
|
||
|
||
use std::fs::OpenOptions;
|
||
use std::io::Write;
|
||
#[cfg(unix)]
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
use std::path::Path;
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use serde_json::Value;
|
||
|
||
use super::error::CodexAuthError;
|
||
|
||
/// auth.json から取り出した使い回す情報のスナップショット。
|
||
#[derive(Debug, Clone)]
|
||
pub struct AuthSnapshot {
|
||
pub access_token: String,
|
||
pub refresh_token: String,
|
||
pub account_id: String,
|
||
pub id_token: String,
|
||
pub last_refresh: Option<DateTime<Utc>>,
|
||
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
|
||
pub raw: Value,
|
||
}
|
||
|
||
impl AuthSnapshot {
|
||
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
|
||
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
|
||
let tokens = raw
|
||
.get("tokens")
|
||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing 'tokens'".into()))?;
|
||
|
||
let access_token = tokens
|
||
.get("access_token")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.access_token".into()))?
|
||
.to_string();
|
||
|
||
let refresh_token = tokens
|
||
.get("refresh_token")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| {
|
||
CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into())
|
||
})?
|
||
.to_string();
|
||
|
||
let id_token = tokens
|
||
.get("id_token")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
|
||
.to_string();
|
||
|
||
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
|
||
let account_id = tokens
|
||
.get("account_id")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
.or_else(|| super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id))
|
||
.ok_or_else(|| {
|
||
CodexAuthError::MalformedAuthJson(
|
||
"missing account_id in both tokens and id_token claims".into(),
|
||
)
|
||
})?;
|
||
|
||
let last_refresh = raw
|
||
.get("last_refresh")
|
||
.and_then(Value::as_str)
|
||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||
.map(|dt| dt.with_timezone(&Utc));
|
||
|
||
Ok(Self {
|
||
access_token,
|
||
refresh_token,
|
||
account_id,
|
||
id_token,
|
||
last_refresh,
|
||
raw,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// auth.json を読む。存在しなければ `NotLoggedIn`。
|
||
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||
let bytes = match tokio::fs::read(path).await {
|
||
Ok(b) => b,
|
||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||
return Err(CodexAuthError::NotLoggedIn(path.to_path_buf()));
|
||
}
|
||
Err(err) => {
|
||
return Err(CodexAuthError::Io(format!(
|
||
"failed to read {}: {err}",
|
||
path.display()
|
||
)));
|
||
}
|
||
};
|
||
let raw: Value = serde_json::from_slice(&bytes)
|
||
.map_err(|e| CodexAuthError::MalformedAuthJson(format!("json parse: {e}")))?;
|
||
AuthSnapshot::from_value(raw)
|
||
}
|
||
|
||
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
|
||
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
|
||
///
|
||
/// 並行する Codex CLI / 別 yoi プロセスが先に refresh していた場合の
|
||
/// fields を保護するため、書込前に再 load して merge する。
|
||
pub async fn persist_refreshed(
|
||
path: &Path,
|
||
new_id_token: Option<String>,
|
||
new_access_token: Option<String>,
|
||
new_refresh_token: Option<String>,
|
||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||
let mut current = load(path).await?;
|
||
let raw = &mut current.raw;
|
||
let tokens = raw
|
||
.get_mut("tokens")
|
||
.and_then(Value::as_object_mut)
|
||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("tokens not an object".into()))?;
|
||
if let Some(t) = new_id_token {
|
||
tokens.insert("id_token".into(), Value::String(t));
|
||
}
|
||
if let Some(t) = new_access_token {
|
||
tokens.insert("access_token".into(), Value::String(t));
|
||
}
|
||
if let Some(t) = new_refresh_token {
|
||
tokens.insert("refresh_token".into(), Value::String(t));
|
||
}
|
||
raw.as_object_mut()
|
||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))?
|
||
.insert(
|
||
"last_refresh".into(),
|
||
Value::String(Utc::now().to_rfc3339()),
|
||
);
|
||
|
||
write_atomic(path, raw)?;
|
||
AuthSnapshot::from_value(raw.clone())
|
||
}
|
||
|
||
fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent)
|
||
.map_err(|e| CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display())))?;
|
||
}
|
||
let json = serde_json::to_vec_pretty(value)
|
||
.map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?;
|
||
let mut options = OpenOptions::new();
|
||
options.truncate(true).write(true).create(true);
|
||
#[cfg(unix)]
|
||
{
|
||
options.mode(0o600);
|
||
}
|
||
let mut file = options
|
||
.open(path)
|
||
.map_err(|e| CodexAuthError::Io(format!("open {}: {e}", path.display())))?;
|
||
file.write_all(&json)
|
||
.map_err(|e| CodexAuthError::Io(format!("write {}: {e}", path.display())))?;
|
||
file.flush()
|
||
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
|
||
|
||
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
|
||
// OpenOptions の `mode()` は新規作成時しか効かないため。
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||
.map_err(|e| CodexAuthError::Io(format!("chmod {}: {e}", path.display())))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn write_auth_json(dir: &Path, content: &str) -> std::path::PathBuf {
|
||
let path = dir.join("auth.json");
|
||
std::fs::write(&path, content).unwrap();
|
||
path
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn load_round_trip() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = write_auth_json(
|
||
dir.path(),
|
||
r#"{
|
||
"auth_mode":"ChatgptAuthTokens",
|
||
"tokens":{
|
||
"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEifX0.s",
|
||
"access_token":"acc",
|
||
"refresh_token":"ref",
|
||
"account_id":"acc-1"
|
||
},
|
||
"last_refresh":"2026-04-20T00:00:00Z",
|
||
"OPENAI_API_KEY":"sk-extra"
|
||
}"#,
|
||
);
|
||
let snap = load(&path).await.unwrap();
|
||
assert_eq!(snap.access_token, "acc");
|
||
assert_eq!(snap.refresh_token, "ref");
|
||
assert_eq!(snap.account_id, "acc-1");
|
||
assert!(snap.last_refresh.is_some());
|
||
// 未知フィールドが raw に保持されている
|
||
assert_eq!(
|
||
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||
Some("sk-extra")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn account_id_falls_back_to_jwt_claim() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
|
||
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
|
||
let path = write_auth_json(
|
||
dir.path(),
|
||
&format!(
|
||
r#"{{ "tokens": {{ "id_token":"{id_token}", "access_token":"a", "refresh_token":"r" }} }}"#
|
||
),
|
||
);
|
||
let snap = load(&path).await.unwrap();
|
||
assert_eq!(snap.account_id, "from-jwt");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn missing_file_returns_not_logged_in() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let err = load(&dir.path().join("nope.json")).await.unwrap_err();
|
||
assert!(matches!(err, CodexAuthError::NotLoggedIn(_)));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn persist_preserves_unknown_fields() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = write_auth_json(
|
||
dir.path(),
|
||
r#"{
|
||
"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"old-acc","refresh_token":"old-ref","account_id":"a"},
|
||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||
}"#,
|
||
);
|
||
let updated =
|
||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(updated.access_token, "new-acc");
|
||
assert_eq!(updated.refresh_token, "new-ref");
|
||
// 未知フィールド agent_identity が保たれる
|
||
let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||
assert!(on_disk.get("agent_identity").is_some());
|
||
assert!(on_disk.get("last_refresh").is_some());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn write_uses_mode_600() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = write_auth_json(
|
||
dir.path(),
|
||
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
|
||
);
|
||
// 既存ファイルを 644 に変えてから persist → 600 に直るか
|
||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||
.await
|
||
.unwrap();
|
||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||
assert_eq!(mode, 0o600);
|
||
}
|
||
}
|