//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` / //! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる //! [`AuthProvider`] 実装。 //! //! 設計: //! //! - llm-worker は [`AuthProvider`] trait しか知らず、実体である //! [`CodexAuthProvider`] はこのクレートに置く(feedback_llm_worker_scope) //! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh //! (Codex CLI と同じバッファなし) //! - 並行する Codex CLI / 別 insomnia の refresh と取り違えないよう、 //! refresh 直前に再 load して account_id 一致を確認(guarded reload) //! - ファイルロックは取らず、書込前に再 load + diff merge で吸収 //! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内 mod auth_json; mod error; mod jwt; mod refresh; use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; use chrono::{Duration, Utc}; use llm_worker::llm_client::{ClientError, auth::AuthProvider}; use reqwest::header::{HeaderName, HeaderValue}; use tokio::sync::Mutex; use auth_json::AuthSnapshot; use error::CodexAuthError; pub use error::PermanentReason; /// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。 const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8; /// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。 pub struct CodexAuthProvider { auth_path: PathBuf, refresh_endpoint: String, http: reqwest::Client, state: Arc>, } impl std::fmt::Debug for CodexAuthProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CodexAuthProvider") .field("auth_path", &self.auth_path) .field("refresh_endpoint", &self.refresh_endpoint) .finish_non_exhaustive() } } #[derive(Default)] struct State { cached: Option, } impl CodexAuthProvider { /// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。 pub fn from_default_home() -> Result { let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") { PathBuf::from(p) } else { let home = std::env::var("HOME").map_err(|_| ClientError::Config("HOME not set".into()))?; PathBuf::from(home).join(".codex") }; Ok(Self::new(codex_home)) } /// 任意の codex_home から構築する。 pub fn new(codex_home: PathBuf) -> Self { Self { auth_path: codex_home.join("auth.json"), refresh_endpoint: refresh::REFRESH_URL.to_string(), http: reqwest::Client::new(), state: Arc::new(Mutex::new(State::default())), } } /// テスト用に refresh エンドポイントを差し替える。 #[cfg(test)] fn with_refresh_endpoint(mut self, endpoint: impl Into) -> Self { self.refresh_endpoint = endpoint.into(); self } /// テスト用に HTTP クライアントを差し替える。 #[cfg(test)] fn with_http_client(mut self, client: reqwest::Client) -> Self { self.http = client; self } async fn ensure_fresh(&self) -> Result { let mut state = self.state.lock().await; // 1. 常にディスクから最新を読む(他プロセスの更新を反映) let snap = auth_json::load(&self.auth_path).await?; // 2. stale でなければそのまま返す if !is_stale(&snap) { state.cached = Some(snap.clone()); return Ok(snap); } // 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。 // 一致しなければ他プロセスが先に更新済 → 自分は refresh しない let pre_refresh = auth_json::load(&self.auth_path).await?; if !is_stale(&pre_refresh) { state.cached = Some(pre_refresh.clone()); return Ok(pre_refresh); } if pre_refresh.account_id != snap.account_id { // account が切り替わった → 新しい auth を採用、refresh はしない state.cached = Some(pre_refresh.clone()); return Ok(pre_refresh); } // 4. Refresh 実行 let refreshed = refresh::request_refresh( &self.http, &self.refresh_endpoint, &pre_refresh.refresh_token, ) .await?; // 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施) let new_snap = auth_json::persist_refreshed( &self.auth_path, refreshed.id_token, refreshed.access_token, refreshed.refresh_token, ) .await?; state.cached = Some(new_snap.clone()); Ok(new_snap) } fn build_headers( snap: &AuthSnapshot, ) -> Result, CodexAuthError> { let mut out = Vec::with_capacity(5); let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token)) .map_err(|e| CodexAuthError::InvalidHeader(format!("Authorization: {e}")))?; out.push((HeaderName::from_static("authorization"), auth_val)); let acc_val = HeaderValue::from_str(&snap.account_id) .map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?; out.push((HeaderName::from_static("chatgpt-account-id"), acc_val)); // Cloudflare WAF は ChatGPT backend アクセス元を `originator` / // `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと // HTML challenge (403) を返されて SSE に到達できない。 out.push(( HeaderName::from_static("originator"), HeaderValue::from_static("codex_cli_rs"), )); out.push(( HeaderName::from_static("user-agent"), HeaderValue::from_static("codex_cli_rs/0.60.0"), )); // FedRAMP 組織は id_token JWT 内の claim で判定 if jwt::parse_chatgpt_claims(&snap.id_token) .map(|c| c.is_fedramp) .unwrap_or(false) { out.push(( HeaderName::from_static("x-openai-fedramp"), HeaderValue::from_static("true"), )); } Ok(out) } } #[async_trait] impl AuthProvider for CodexAuthProvider { async fn headers(&self) -> Result, ClientError> { let snap = self .ensure_fresh() .await .map_err(CodexAuthError::to_client_error)?; Self::build_headers(&snap).map_err(CodexAuthError::to_client_error) } } /// `access_token` の JWT `exp` を見て、期限切れなら true。 /// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。 fn is_stale(snap: &AuthSnapshot) -> bool { if let Some(exp) = jwt::parse_exp(&snap.access_token) { return exp <= Utc::now(); } match snap.last_refresh { Some(last) => last < Utc::now() - Duration::days(TOKEN_REFRESH_INTERVAL_DAYS), None => true, } } #[cfg(test)] mod tests { use super::*; use base64::Engine; use serde_json::json; use wiremock::matchers::{method, path as url_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn make_jwt(payload: serde_json::Value) -> String { let p = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(serde_json::to_vec(&payload).unwrap()); format!("h.{p}.s") } fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf { let id_token = make_jwt(json!({ "https://api.openai.com/auth": { "chatgpt_account_id": "acc-xyz", "chatgpt_account_is_fedramp": fedramp, } })); let access_token = make_jwt(json!({ "exp": exp })); let auth = json!({ "tokens": { "id_token": id_token, "access_token": access_token, "refresh_token": refresh, "account_id": "acc-xyz", }, "last_refresh": "2026-04-20T00:00:00Z", }); let path = dir.join("auth.json"); std::fs::write(&path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap(); path } #[tokio::test] async fn returns_headers_for_fresh_token() { let dir = tempfile::tempdir().unwrap(); // 期限を遠い未来に write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt"); let provider = CodexAuthProvider::new(dir.path().to_path_buf()); let headers = provider.headers().await.unwrap(); let names: Vec<_> = headers .iter() .map(|(n, _)| n.as_str().to_string()) .collect(); assert!(names.contains(&"authorization".to_string())); assert!(names.contains(&"chatgpt-account-id".to_string())); assert!(!names.contains(&"x-openai-fedramp".to_string())); let acc = headers .iter() .find(|(n, _)| n.as_str() == "chatgpt-account-id") .unwrap(); assert_eq!(acc.1.to_str().unwrap(), "acc-xyz"); } #[tokio::test] async fn fedramp_header_added_when_claim_set() { let dir = tempfile::tempdir().unwrap(); write_auth(dir.path(), Utc::now().timestamp() + 3600, true, "rt"); let provider = CodexAuthProvider::new(dir.path().to_path_buf()); let headers = provider.headers().await.unwrap(); let fedramp = headers .iter() .find(|(n, _)| n.as_str() == "x-openai-fedramp"); assert!(fedramp.is_some()); assert_eq!(fedramp.unwrap().1, "true"); } #[tokio::test] async fn refreshes_when_expired_and_persists() { let dir = tempfile::tempdir().unwrap(); let path = write_auth( dir.path(), Utc::now().timestamp() - 60, false, "old-refresh", ); // refresh エンドポイントを mock。新しい JWT (将来 exp) を返す let server = MockServer::start().await; let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 })); let new_refresh = "new-refresh-token"; Mock::given(method("POST")) .and(url_path("/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "access_token": new_access, "refresh_token": new_refresh, }))) .expect(1) .mount(&server) .await; let provider = CodexAuthProvider::new(dir.path().to_path_buf()) .with_refresh_endpoint(format!("{}/oauth/token", server.uri())) .with_http_client(reqwest::Client::new()); let headers = provider.headers().await.unwrap(); let auth = headers .iter() .find(|(n, _)| n.as_str() == "authorization") .unwrap(); assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}")); // ファイルに新しい refresh_token が書き戻されている let on_disk: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!( on_disk["tokens"]["refresh_token"].as_str(), Some(new_refresh) ); } #[tokio::test] async fn permanent_refresh_failure_surfaces_login_message() { let dir = tempfile::tempdir().unwrap(); write_auth( dir.path(), Utc::now().timestamp() - 60, false, "bad-refresh", ); let server = MockServer::start().await; Mock::given(method("POST")) .and(url_path("/oauth/token")) .respond_with(ResponseTemplate::new(401).set_body_json(json!({ "error": { "code": "refresh_token_expired" } }))) .mount(&server) .await; let provider = CodexAuthProvider::new(dir.path().to_path_buf()) .with_refresh_endpoint(format!("{}/oauth/token", server.uri())) .with_http_client(reqwest::Client::new()); let err = provider.headers().await.unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("codex login"), "expected hint, got: {msg}"); } #[tokio::test] async fn missing_auth_json_reports_not_logged_in() { let dir = tempfile::tempdir().unwrap(); let provider = CodexAuthProvider::new(dir.path().to_path_buf()); let err = provider.headers().await.unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("not logged in"), "got: {msg}"); } }