use crate::secret::Secret;
use std::path::Path;
pub fn base_url() -> String {
std::env::var("SWAPDEX_UPSTREAM_CODEX")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string())
}
pub fn upstream_url(base: &str, path: &str) -> String {
let rest = path.strip_prefix("/v1").unwrap_or(path);
format!("{}{}", base.trim_end_matches('/'), rest)
}
pub struct Auth {
pub token: Secret,
pub account_id: String,
}
pub fn slot_token_expiry(dir: &Path) -> Option<i64> {
let bytes = std::fs::read(dir.join("auth.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
jwt_expiry(v["tokens"]["access_token"].as_str()?)
}
pub fn jwt_expiry(token: &str) -> Option<i64> {
use base64::Engine;
let payload = token.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()?
.get("exp")?
.as_i64()
}
pub fn slot_token_expired(dir: &Path, now_secs: i64) -> bool {
slot_token_expiry(dir).is_some_and(|exp| exp <= now_secs)
}
pub fn slot_auth(dir: &Path) -> Option<Auth> {
let bytes = std::fs::read(dir.join("auth.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let t = v["tokens"]["access_token"]
.as_str()
.filter(|s| !s.is_empty())?;
let id = v["tokens"]["account_id"]
.as_str()
.filter(|s| !s.is_empty())?;
Some(Auth {
token: Secret::new(t.as_bytes().to_vec()),
account_id: id.to_string(),
})
}
pub fn slot_account_id(dir: &Path) -> Option<String> {
slot_auth(dir).map(|a| a.account_id)
}
pub fn apply_auth(headers: &mut Vec<(String, String)>, auth: &Auth) {
headers.retain(|(n, _)| {
!matches!(
n.to_ascii_lowercase().as_str(),
"authorization" | "chatgpt-account-id"
)
});
headers.push((
"authorization".into(),
format!("Bearer {}", String::from_utf8_lossy(auth.token.expose())),
));
headers.push(("chatgpt-account-id".into(), auth.account_id.clone()));
}
#[cfg(test)]
mod tests {
use super::*;
fn write_auth(dir: &Path, token: &str, account: &str) {
std::fs::write(
dir.join("auth.json"),
format!(
r#"{{"auth_mode":"chatgpt","tokens":{{"access_token":"{token}",
"refresh_token":"RT","account_id":"{account}"}}}}"#
),
)
.unwrap();
}
#[test]
fn the_version_prefix_is_dropped_for_the_chatgpt_backend() {
let base = "https://chatgpt.com/backend-api/codex";
assert_eq!(
upstream_url(base, "/v1/responses"),
"https://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(
upstream_url("http://127.0.0.1:1/", "/v1/responses"),
"http://127.0.0.1:1/responses"
);
assert_eq!(
upstream_url(base, "/responses"),
"https://chatgpt.com/backend-api/codex/responses"
);
}
#[test]
fn a_slots_login_is_read_as_a_token_and_account_id_together() {
let d = tempfile::tempdir().unwrap();
assert!(slot_auth(d.path()).is_none(), "no login yet");
write_auth(d.path(), "AT-1", "acct-1");
let a = slot_auth(d.path()).expect("login");
assert_eq!(a.token.expose(), b"AT-1");
assert_eq!(a.account_id, "acct-1");
}
#[test]
fn half_a_login_is_not_a_login() {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("auth.json"),
br#"{"tokens":{"access_token":"AT-1"}}"#,
)
.unwrap();
assert!(slot_auth(d.path()).is_none(), "no account id");
std::fs::write(
d.path().join("auth.json"),
br#"{"tokens":{"account_id":"acct-1"}}"#,
)
.unwrap();
assert!(slot_auth(d.path()).is_none(), "no token");
std::fs::write(d.path().join("auth.json"), b"not json").unwrap();
assert!(slot_auth(d.path()).is_none(), "unreadable");
}
#[test]
fn applying_a_slots_login_replaces_the_clients_own_pair() {
let d = tempfile::tempdir().unwrap();
write_auth(d.path(), "AT-NEW", "acct-new");
let auth = slot_auth(d.path()).unwrap();
let mut headers = vec![
("Authorization".into(), "Bearer AT-OLD".to_string()),
("ChatGPT-Account-ID".into(), "acct-old".to_string()),
("originator".into(), "codex_cli_rs".to_string()),
];
apply_auth(&mut headers, &auth);
let get = |k: &str| {
headers
.iter()
.filter(|(n, _)| n.eq_ignore_ascii_case(k))
.map(|(_, v)| v.clone())
.collect::<Vec<_>>()
};
assert_eq!(get("authorization"), vec!["Bearer AT-NEW".to_string()]);
assert_eq!(get("chatgpt-account-id"), vec!["acct-new".to_string()]);
assert_eq!(
get("originator"),
vec!["codex_cli_rs".to_string()],
"everything else the client sent is left alone"
);
}
}
#[cfg(test)]
mod expiry_tests {
use super::*;
fn jwt(exp: i64) -> String {
use base64::Engine;
let b = |s: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(s);
format!(
"{}.{}.sig",
b(r#"{"alg":"none"}"#),
b(&format!(r#"{{"exp":{exp}}}"#))
)
}
fn slot(token: &str) -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("auth.json"),
serde_json::to_vec(&serde_json::json!({
"tokens": {"access_token": token, "refresh_token": "RT", "account_id": "acct"}
}))
.unwrap(),
)
.unwrap();
d
}
#[test]
fn an_expired_codex_token_is_readable_as_expired() {
let d = slot(&jwt(1_788_527_580)); assert_eq!(slot_token_expiry(d.path()), Some(1_788_527_580));
assert!(
slot_token_expired(d.path(), 1_788_744_000),
"three days later"
);
assert!(
!slot_token_expired(d.path(), 1_788_000_000),
"and not before it"
);
assert!(
slot_auth(d.path()).is_some(),
"the login is still present - which is why presence alone was not enough"
);
}
#[test]
fn an_unreadable_expiry_is_never_treated_as_expired() {
for token in ["not-a-jwt", "a.b.c", ""] {
let d = slot(token);
assert_eq!(slot_token_expiry(d.path()), None, "token {token:?}");
assert!(!slot_token_expired(d.path(), i64::MAX), "token {token:?}");
}
let empty = tempfile::tempdir().unwrap();
assert_eq!(slot_token_expiry(empty.path()), None);
assert!(!slot_token_expired(empty.path(), i64::MAX));
}
}