yoi/crates/provider/src/codex_oauth/auth_json.rs
2026-06-01 18:49:23 +09:00

276 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `~/.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);
}
}