codex_codes/
auth_local.rs1use crate::error::{Error, Result};
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::path::PathBuf;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct LocalAuthStatus {
34 pub logged_in: bool,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub auth_mode: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub email: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub plan_type: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub account_id: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub last_refresh: Option<String>,
52}
53
54pub 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
63pub 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
86pub 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
136fn 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
143fn 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 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 #[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}