Skip to main content

codex_codes/
auth_local.rs

1//! Best-effort local auth status read — **private-contract territory**.
2//!
3//! The sanctioned way to read account state is protocol-native
4//! ([`AsyncClient::account_read`](crate::client_async::AsyncClient::account_read)),
5//! which requires standing up an app-server connection. For cheap status
6//! probes (dashboards, launcher matrices) this module reads what the codex
7//! CLI itself persists at `$CODEX_HOME/auth.json` (default
8//! `~/.codex/auth.json`) and decodes the display-only identity claims from
9//! the stored `id_token`.
10//!
11//! **Caveats, deliberately loud:**
12//!
13//! - `auth.json` is codex's internal storage, not a published interface.
14//!   Its layout can change in any CLI release. This crate owns that risk so
15//!   consumers don't have to: the shape is unit-tested against a captured
16//!   fixture and exercised against the real file by the live integration
17//!   suite, so a layout change becomes a crate patch, not a silent
18//!   downstream break.
19//! - The JWT payload is base64-decoded **without signature verification** —
20//!   the values come from a file the user's own CLI wrote, and they are fit
21//!   for display labels only. Never use them for authorization decisions.
22//! - A `logged_in: true` here means "credentials are stored", not "they
23//!   still work" — tokens expire and get refreshed by the CLI. For a
24//!   liveness answer use `account_read` or `codex login status`.
25
26use crate::error::{Error, Result};
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::path::PathBuf;
30
31/// Serde-shaped local auth snapshot, fit for relaying to UIs.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct LocalAuthStatus {
34    /// Credentials are present on disk (see module docs: stored ≠ live).
35    pub logged_in: bool,
36    /// `auth_mode` as stored, e.g. `"chatgpt"` or `"apikey"`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub auth_mode: Option<String>,
39    /// `email` claim from the stored id_token (display only).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub email: Option<String>,
42    /// `chatgpt_plan_type` from the id_token's OpenAI auth claim
43    /// (display only), e.g. `"plus"`, `"pro"`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub plan_type: Option<String>,
46    /// `tokens.account_id` as stored.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub account_id: Option<String>,
49    /// `last_refresh` timestamp string as stored.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub last_refresh: Option<String>,
52}
53
54/// `$CODEX_HOME/auth.json`, defaulting to `~/.codex/auth.json`.
55pub fn auth_json_path() -> Option<PathBuf> {
56    if let Some(home) = std::env::var_os("CODEX_HOME") {
57        return Some(PathBuf::from(home).join("auth.json"));
58    }
59    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
60    Some(PathBuf::from(home).join(".codex/auth.json"))
61}
62
63/// Read the local auth snapshot from the default location.
64///
65/// A missing file is `Ok` with `logged_in: false` (that's what logged-out
66/// looks like); an unreadable or unparseable file is an error.
67pub fn auth_status_local() -> Result<LocalAuthStatus> {
68    let Some(path) = auth_json_path() else {
69        return Err(Error::Protocol(
70            "no home directory to resolve auth.json against".to_string(),
71        ));
72    };
73    if !path.exists() {
74        return Ok(LocalAuthStatus {
75            logged_in: false,
76            auth_mode: None,
77            email: None,
78            plan_type: None,
79            account_id: None,
80            last_refresh: None,
81        });
82    }
83    auth_status_from_json(&std::fs::read_to_string(&path)?)
84}
85
86/// Parse a snapshot out of `auth.json` contents.
87pub fn auth_status_from_json(raw: &str) -> Result<LocalAuthStatus> {
88    let v: Value = serde_json::from_str(raw)?;
89    let auth_mode = v
90        .get("auth_mode")
91        .and_then(Value::as_str)
92        .map(str::to_string);
93    let api_key_present = v
94        .get("OPENAI_API_KEY")
95        .and_then(Value::as_str)
96        .is_some_and(|k| !k.trim().is_empty());
97    let tokens = v.get("tokens").filter(|t| t.is_object());
98    let account_id = tokens
99        .and_then(|t| t.get("account_id"))
100        .and_then(Value::as_str)
101        .map(str::to_string);
102    let last_refresh = v
103        .get("last_refresh")
104        .and_then(Value::as_str)
105        .map(str::to_string);
106
107    let mut email = None;
108    let mut plan_type = None;
109    if let Some(id_token) = tokens
110        .and_then(|t| t.get("id_token"))
111        .and_then(Value::as_str)
112    {
113        if let Some(claims) = decode_jwt_claims(id_token) {
114            email = claims
115                .get("email")
116                .and_then(Value::as_str)
117                .map(str::to_string);
118            plan_type = claims
119                .get("https://api.openai.com/auth")
120                .and_then(|a| a.get("chatgpt_plan_type"))
121                .and_then(Value::as_str)
122                .map(str::to_string);
123        }
124    }
125
126    Ok(LocalAuthStatus {
127        logged_in: tokens.is_some() || api_key_present,
128        auth_mode,
129        email,
130        plan_type,
131        account_id,
132        last_refresh,
133    })
134}
135
136/// Decode a JWT's payload segment (base64url, unverified) into JSON.
137fn decode_jwt_claims(jwt: &str) -> Option<Value> {
138    let payload = jwt.split('.').nth(1)?;
139    let bytes = base64url_decode(payload)?;
140    serde_json::from_slice(&bytes).ok()
141}
142
143/// Minimal base64url (no padding) decoder — display-only path, so a tiny
144/// hand-rolled table beats a dependency.
145fn base64url_decode(s: &str) -> Option<Vec<u8>> {
146    fn val(c: u8) -> Option<u32> {
147        match c {
148            b'A'..=b'Z' => Some((c - b'A') as u32),
149            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
150            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
151            b'-' => Some(62),
152            b'_' => Some(63),
153            _ => None,
154        }
155    }
156    let s = s.trim_end_matches('=');
157    let mut out = Vec::with_capacity(s.len() * 3 / 4);
158    let mut buf: u32 = 0;
159    let mut bits = 0;
160    for &c in s.as_bytes() {
161        buf = (buf << 6) | val(c)?;
162        bits += 6;
163        if bits >= 8 {
164            bits -= 8;
165            out.push((buf >> bits) as u8);
166        }
167    }
168    Some(out)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use serde_json::json;
175
176    fn fake_jwt(claims: Value) -> String {
177        fn enc(v: &Value) -> String {
178            // Std base64url without padding via the reverse of our decoder.
179            const TBL: &[u8; 64] =
180                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
181            let bytes = serde_json::to_vec(v).unwrap();
182            let mut out = String::new();
183            for chunk in bytes.chunks(3) {
184                let b = [
185                    chunk[0],
186                    *chunk.get(1).unwrap_or(&0),
187                    *chunk.get(2).unwrap_or(&0),
188                ];
189                let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
190                out.push(TBL[(n >> 18) as usize & 63] as char);
191                out.push(TBL[(n >> 12) as usize & 63] as char);
192                if chunk.len() > 1 {
193                    out.push(TBL[(n >> 6) as usize & 63] as char);
194                }
195                if chunk.len() > 2 {
196                    out.push(TBL[n as usize & 63] as char);
197                }
198            }
199            out
200        }
201        format!("{}.{}.sig", enc(&json!({"alg": "RS256"})), enc(&claims))
202    }
203
204    /// Fixture mirrors the real ~/.codex/auth.json layout (captured shape:
205    /// auth_mode / OPENAI_API_KEY / tokens{id_token,access_token,
206    /// refresh_token,account_id} / last_refresh).
207    #[test]
208    fn chatgpt_mode_snapshot_carries_email_and_plan() {
209        let jwt = fake_jwt(json!({
210            "email": "matt@example.com",
211            "email_verified": true,
212            "https://api.openai.com/auth": {
213                "chatgpt_plan_type": "pro",
214                "chatgpt_account_id": "acct_1"
215            }
216        }));
217        let raw = json!({
218            "auth_mode": "chatgpt",
219            "OPENAI_API_KEY": null,
220            "tokens": {
221                "id_token": jwt,
222                "access_token": "at",
223                "refresh_token": "rt",
224                "account_id": "acct_1"
225            },
226            "last_refresh": "2026-08-05T12:00:00Z"
227        });
228        let s = auth_status_from_json(&raw.to_string()).unwrap();
229        assert!(s.logged_in);
230        assert_eq!(s.auth_mode.as_deref(), Some("chatgpt"));
231        assert_eq!(s.email.as_deref(), Some("matt@example.com"));
232        assert_eq!(s.plan_type.as_deref(), Some("pro"));
233        assert_eq!(s.account_id.as_deref(), Some("acct_1"));
234    }
235
236    #[test]
237    fn api_key_mode_has_no_identity() {
238        let raw = json!({
239            "auth_mode": "apikey",
240            "OPENAI_API_KEY": "sk-test",
241            "tokens": null,
242            "last_refresh": null
243        });
244        let s = auth_status_from_json(&raw.to_string()).unwrap();
245        assert!(s.logged_in);
246        assert_eq!(s.auth_mode.as_deref(), Some("apikey"));
247        assert_eq!(s.email, None);
248        assert_eq!(s.plan_type, None);
249    }
250
251    #[test]
252    fn garbage_jwt_degrades_to_no_label_not_error() {
253        let raw = json!({
254            "auth_mode": "chatgpt",
255            "tokens": {"id_token": "not-a-jwt", "account_id": "a"},
256        });
257        let s = auth_status_from_json(&raw.to_string()).unwrap();
258        assert!(s.logged_in);
259        assert_eq!(s.email, None);
260        assert_eq!(s.account_id.as_deref(), Some("a"));
261    }
262
263    #[test]
264    fn base64url_roundtrips_jwt_segments() {
265        assert_eq!(base64url_decode("aGVsbG8").unwrap(), b"hello");
266        assert_eq!(base64url_decode("aGVsbG8=").unwrap(), b"hello");
267        assert!(base64url_decode("!!bad!!").is_none());
268    }
269}