codexのOAuthを使う実装

This commit is contained in:
2026-04-20 23:13:52 +09:00
parent 24ade197d1
commit 6c6eb0dcb6
16 changed files with 1652 additions and 69 deletions
+9
View File
@@ -5,11 +5,20 @@ edition.workspace = true
license.workspace = true
[dependencies]
async-trait = "0.1.89"
base64 = "0.22.1"
chrono = { version = "0.4.44", default-features = false, features = ["serde", "clock"] }
llm-worker = { version = "0.2.1", path = "../llm-worker" }
manifest = { version = "0.1.0", path = "../manifest" }
reqwest = { version = "0.13.2", features = ["json", "native-tls"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0"
tokio = { version = "1.52.1", features = ["sync", "fs", "rt"] }
tracing = "0.1.44"
[dev-dependencies]
serial_test = "3.4.0"
tempfile = "3.27.0"
toml = "1.1.2"
wiremock = "0.6.5"
@@ -0,0 +1,265 @@
//! `~/.codex/auth.json` の読み書き。
//!
//! Codex CLI と schema を共有するが、insomnia は知らないフィールドを
//! 失わないようファイル全体を `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 / 別 insomnia プロセスが先に 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);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Codex OAuth サブモジュール内部のエラー型。
//!
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の
//! 再実行案内付きメッセージにする。
use std::path::PathBuf;
use llm_worker::llm_client::ClientError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CodexAuthError {
#[error(
"not logged in to ChatGPT: {0} not found. Run `codex login` and ensure cli_auth_credentials_store = \"file\"."
)]
NotLoggedIn(PathBuf),
#[error("malformed ~/.codex/auth.json: {0}")]
MalformedAuthJson(String),
#[error("io error: {0}")]
Io(String),
#[error("token refresh failed (transient): {0}")]
RefreshTransient(String),
/// refresh_token が永続的に失効。再ログインが必要。
#[error("token refresh failed permanently ({reason:?}): {message}")]
RefreshPermanent {
reason: PermanentReason,
message: String,
},
#[error("invalid header value: {0}")]
InvalidHeader(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermanentReason {
Expired,
Reused,
Revoked,
Other,
}
impl CodexAuthError {
/// `LlmClient` トランスポート境界向けに変換する。
pub fn to_client_error(self) -> ClientError {
match self {
CodexAuthError::NotLoggedIn(_)
| CodexAuthError::MalformedAuthJson(_)
| CodexAuthError::Io(_)
| CodexAuthError::InvalidHeader(_) => ClientError::Config(self.to_string()),
CodexAuthError::RefreshTransient(msg) => ClientError::Api {
status: None,
code: Some("refresh_transient".into()),
message: msg,
},
CodexAuthError::RefreshPermanent { reason, message } => ClientError::Api {
status: Some(401),
code: Some(match reason {
PermanentReason::Expired => "refresh_token_expired".into(),
PermanentReason::Reused => "refresh_token_reused".into(),
PermanentReason::Revoked => "refresh_token_invalidated".into(),
PermanentReason::Other => "refresh_token_failed".into(),
}),
message: format!("{message}. Please run `codex login` again."),
},
}
}
}
+104
View File
@@ -0,0 +1,104 @@
//! JWT payload の最小限のパース(署名検証なし)。
//!
//! Codex CLI と同じく、access_token / id_token の payload を base64url
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::Deserialize;
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
#[derive(Deserialize)]
struct Claims {
exp: Option<i64>,
}
let claims: Claims = decode_payload(jwt).ok()?;
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
}
/// id_token JWT から ChatGPT 固有 claims を取り出す。
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
#[derive(Deserialize)]
struct IdClaims {
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_account_id: Option<String>,
#[serde(default)]
chatgpt_account_is_fedramp: bool,
}
let claims: IdClaims = decode_payload(jwt).ok()?;
let auth = claims.auth?;
Some(ChatGptClaims {
account_id: auth.chatgpt_account_id,
is_fedramp: auth.chatgpt_account_is_fedramp,
})
}
#[derive(Debug, Clone)]
pub struct ChatGptClaims {
pub account_id: Option<String>,
pub is_fedramp: bool,
}
fn decode_payload<T: for<'de> Deserialize<'de>>(jwt: &str) -> Result<T, JwtError> {
let payload = jwt.split('.').nth(1).ok_or(JwtError::InvalidFormat)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| JwtError::InvalidBase64)?;
serde_json::from_slice(&bytes).map_err(|_| JwtError::InvalidJson)
}
#[derive(Debug)]
enum JwtError {
InvalidFormat,
InvalidBase64,
InvalidJson,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_jwt(payload: &serde_json::Value) -> String {
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(payload).unwrap());
format!("h.{payload_b64}.s")
}
#[test]
fn parses_exp() {
let jwt = make_jwt(&serde_json::json!({ "exp": 1_700_000_000_i64 }));
let dt = parse_exp(&jwt).unwrap();
assert_eq!(dt.timestamp(), 1_700_000_000);
}
#[test]
fn parses_chatgpt_claims() {
let jwt = make_jwt(&serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-123",
"chatgpt_account_is_fedramp": true,
}
}));
let c = parse_chatgpt_claims(&jwt).unwrap();
assert_eq!(c.account_id.as_deref(), Some("acc-123"));
assert!(c.is_fedramp);
}
#[test]
fn missing_exp_returns_none() {
let jwt = make_jwt(&serde_json::json!({}));
assert!(parse_exp(&jwt).is_none());
}
#[test]
fn malformed_jwt_returns_none() {
assert!(parse_exp("not-a-jwt").is_none());
assert!(parse_chatgpt_claims("x.y").is_none());
}
}
+334
View File
@@ -0,0 +1,334 @@
//! 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(3);
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,
));
// 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}");
}
}
+134
View File
@@ -0,0 +1,134 @@
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
//!
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
//! 401 + `error.code` で永続失敗を分類する。
use serde::{Deserialize, Serialize};
use super::error::{CodexAuthError, PermanentReason};
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
#[derive(Serialize)]
struct RefreshRequest<'a> {
client_id: &'static str,
grant_type: &'static str,
refresh_token: &'a str,
}
#[derive(Deserialize, Debug, Default, Clone)]
pub struct RefreshResponse {
#[serde(default)]
pub id_token: Option<String>,
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
}
/// refresh_token を使って新しいトークン群を取得する。
///
/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
pub async fn request_refresh(
client: &reqwest::Client,
endpoint: &str,
refresh_token: &str,
) -> Result<RefreshResponse, CodexAuthError> {
let body = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token,
};
let response = client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))?;
let status = response.status();
if status.is_success() {
response
.json::<RefreshResponse>()
.await
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
} else {
let body = response.text().await.unwrap_or_default();
if status == reqwest::StatusCode::UNAUTHORIZED {
let (reason, message) = classify_permanent(&body);
Err(CodexAuthError::RefreshPermanent { reason, message })
} else {
Err(CodexAuthError::RefreshTransient(format!(
"{status}: {body}"
)))
}
}
}
fn classify_permanent(body: &str) -> (PermanentReason, String) {
let code = extract_error_code(body);
let reason = match code.as_deref() {
Some("refresh_token_expired") => PermanentReason::Expired,
Some("refresh_token_reused") => PermanentReason::Reused,
Some("refresh_token_invalidated") => PermanentReason::Revoked,
_ => PermanentReason::Other,
};
let message = match reason {
PermanentReason::Expired => "Your refresh token has expired".to_string(),
PermanentReason::Reused => "Your refresh token was already used".to_string(),
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
};
(reason, message)
}
fn extract_error_code(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
if let Some(error) = value.get("error") {
if let Some(obj) = error.as_object() {
if let Some(code) = obj.get("code").and_then(|v| v.as_str()) {
return Some(code.to_string());
}
}
if let Some(s) = error.as_str() {
return Some(s.to_string());
}
}
value.get("code").and_then(|v| v.as_str()).map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_expired() {
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Expired);
}
#[test]
fn classify_reused() {
let body = r#"{"error":{"code":"refresh_token_reused"}}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Reused);
}
#[test]
fn classify_unknown_falls_to_other() {
let body = r#"{"error":{"code":"weird"}}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Other);
}
#[test]
fn classify_top_level_code() {
let body = r#"{"code":"refresh_token_invalidated"}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Revoked);
}
}
+21 -7
View File
@@ -10,6 +10,10 @@
//! なる認証ストア解決(Codex OAuth の `~/.codex/auth.json` 読取等)は
//! このクレートに追加する。
pub mod codex_oauth;
use std::sync::Arc;
use llm_worker::llm_client::{
LlmClient,
capability::ModelCapability,
@@ -74,12 +78,25 @@ fn resolve_auth(
}
Err(ProviderError::ApiKeyMissing { scheme })
}
AuthRef::CodexOAuth => Err(ProviderError::Config(
"codex_oauth auth not yet implemented (tickets/llm-auth-codex-oauth)".into(),
)),
AuthRef::CodexOAuth => {
let provider = codex_oauth::CodexAuthProvider::from_default_home()
.map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider)))
}
}
}
/// `AuthRef::CodexOAuth` 指定時、`base_url` 未指定なら ChatGPT backend を既定とする。
fn effective_base_url<S: Scheme>(scheme: &S, config: &ModelConfig) -> String {
if let Some(b) = &config.base_url {
return b.clone();
}
if matches!(config.auth, AuthRef::CodexOAuth) {
return "https://chatgpt.com/backend-api".to_string();
}
scheme.default_base_url().to_string()
}
fn build_transport<S: Scheme>(
scheme: S,
config: &ModelConfig,
@@ -100,10 +117,7 @@ fn build_transport<S: Scheme>(
.clone()
.or_else(|| scheme.capability_for(&config.model_id))
.unwrap_or_else(|| scheme.default_capability());
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| scheme.default_base_url().to_string());
let base_url = effective_base_url(&scheme, config);
Ok(Box::new(HttpTransport::new(
scheme,
config.model_id.clone(),