yoi/crates/provider/src/codex_oauth/mod.rs
2026-04-27 22:51:07 +09:00

359 lines
13 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.

//! 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<Mutex<State>>,
}
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<AuthSnapshot>,
}
impl CodexAuthProvider {
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
pub fn from_default_home() -> Result<Self, ClientError> {
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<String>) -> 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<AuthSnapshot, CodexAuthError> {
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<Vec<(HeaderName, HeaderValue)>, 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<Vec<(HeaderName, HeaderValue)>, 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}");
}
}