use super::{Account, AuthTool, Snapshot};
use crate::paths::Paths;
use crate::secret::Secret;
use anyhow::{bail, Context, Result};
use serde_json::Value;
pub struct Codex;
impl AuthTool for Codex {
fn name(&self) -> &'static str {
"codex"
}
fn present(&self, paths: &Paths) -> bool {
paths.codex_auth().exists()
}
fn capture(&self, paths: &Paths) -> Result<Snapshot> {
let path = paths.codex_auth();
if !path.exists() {
bail!("not logged in to Codex (no {})", path.display());
}
let bytes = crate::atomic::read_regular(&path)?;
serde_json::from_slice::<Value>(&bytes).context("codex auth.json is not valid JSON")?;
Ok(Snapshot {
tool: "codex",
blobs: vec![("auth".into(), Secret::new(bytes))],
})
}
fn apply(&self, paths: &Paths, snap: &Snapshot) -> Result<()> {
let part = snap.part("auth").context("snapshot has no codex auth")?;
serde_json::from_slice::<Value>(part.expose())
.context("saved codex auth is not valid JSON; refusing to apply")?;
crate::atomic::write_secret(&paths.codex_auth(), part.expose())
}
fn identity(&self, paths: &Paths) -> Result<Option<Account>> {
let path = paths.codex_auth();
if !path.exists() {
return Ok(None);
}
let v: Value = serde_json::from_slice(&crate::atomic::read_regular(&path)?)
.context("parse codex auth.json")?;
let account_id = v["tokens"]["account_id"].as_str().unwrap_or("").to_string();
let email = decode_email_from_id_token(v["tokens"]["id_token"].as_str());
Ok(Some(Account {
tool: "codex",
account_id,
display: email.clone().unwrap_or_else(|| "codex account".into()),
email,
tier: v["auth_mode"].as_str().map(|s| s.to_string()),
expires_at: None,
}))
}
}
#[cfg(test)]
pub(crate) fn test_id_token(email: &str) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let body = format!(r#"{{"email":"{email}"}}"#);
let b = body.as_bytes();
let mut o = String::new();
for c in b.chunks(3) {
let n = ((c[0] as u32) << 16)
| ((*c.get(1).unwrap_or(&0) as u32) << 8)
| (*c.get(2).unwrap_or(&0) as u32);
for i in 0..(c.len() + 1) {
o.push(T[((n >> (18 - i * 6)) & 63) as usize] as char);
}
}
format!("h.{o}.s")
}
pub(crate) fn decode_email_from_id_token(id_token: Option<&str>) -> Option<String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let payload = id_token?.split('.').nth(1)?;
let json = URL_SAFE_NO_PAD.decode(payload).ok()?;
let v: Value = serde_json::from_slice(&json).ok()?;
v["email"].as_str().map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Paths;
fn seed_codex(p: &Paths, account_id: &str) {
let dir = p.codex_auth().parent().unwrap().to_path_buf();
std::fs::create_dir_all(&dir).unwrap();
let body = serde_json::json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": "sk-SENTINELKEY",
"tokens": {"id_token": "hdr.eyJlbWFpbCI6ImFAYi5jb20ifQ.sig",
"access_token": "AT-SENTINEL", "refresh_token": "RT-SENTINEL",
"account_id": account_id},
"last_refresh": "2026-07-03T00:00:00Z"
});
std::fs::write(p.codex_auth(), serde_json::to_vec(&body).unwrap()).unwrap();
}
#[test]
fn apply_refuses_a_garbage_snapshot_and_leaves_live_untouched() {
let dir = tempfile::tempdir().unwrap();
let p = Paths::rooted(dir.path());
seed_codex(&p, "acct-A");
let orig = std::fs::read(p.codex_auth()).unwrap();
let bad = Snapshot {
tool: "codex",
blobs: vec![("auth".into(), Secret::new(b"NOT JSON".to_vec()))],
};
assert!(Codex.apply(&p, &bad).is_err(), "garbage must be refused");
assert_eq!(
std::fs::read(p.codex_auth()).unwrap(),
orig,
"live login must be untouched when apply is refused"
);
}
#[test]
fn codex_capture_apply_roundtrip_and_identity() {
let a = tempfile::tempdir().unwrap();
let pa = Paths::rooted(a.path());
seed_codex(&pa, "acct-123");
let snap = Codex.capture(&pa).unwrap();
let b = tempfile::tempdir().unwrap();
let pb = Paths::rooted(b.path());
Codex.apply(&pb, &snap).unwrap();
assert_eq!(
std::fs::read(pb.codex_auth()).unwrap(),
std::fs::read(pa.codex_auth()).unwrap(),
"whole auth.json round-trips byte-for-byte"
);
let id = Codex.identity(&pb).unwrap().unwrap();
assert_eq!(id.account_id, "acct-123");
assert_eq!(id.tool, "codex");
assert_eq!(id.email.as_deref(), Some("a@b.com"));
}
}