1use crate::account_history::{
2 self, AccountHistoryAccount, AccountHistoryStore, UsageAccountHistory,
3};
4use crate::error::AppError;
5use crate::format::to_pretty_json;
6use crate::storage::{percent_encode, resolve_storage_paths, write_sensitive_file, StorageOptions};
7use chrono::{DateTime, TimeZone, Utc};
8use serde::Serialize;
9use serde_json::{Map, Value};
10use std::collections::BTreeSet;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15const OPENAI_AUTH_CLAIM: &str = "https://api.openai.com/auth";
16
17type JwtJsonObject = Map<String, Value>;
18
19#[derive(Debug, Clone, Default, Eq, PartialEq)]
20pub struct AuthCommandOptions {
21 pub auth_file: Option<PathBuf>,
22 pub codex_home: Option<PathBuf>,
23 pub store_dir: Option<PathBuf>,
24 pub account_history_file: Option<PathBuf>,
25}
26
27#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct AuthOrganization {
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub id: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub title: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub role: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub is_default: Option<bool>,
38}
39
40#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
41#[serde(rename_all = "camelCase")]
42pub struct AuthStatusSummary {
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub auth_mode: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub token_account_id: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub last_refresh: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub token_type: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub algorithm: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub key_id: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub issuer: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub subject: Option<String>,
59 pub audience: Vec<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub jwt_id: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub issued_at: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub expires_at: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub not_before: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub auth_time: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub requested_auth_time: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub is_expired: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub seconds_until_expiry: Option<i64>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub name: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub email: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub email_verified: Option<bool>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub auth_provider: Option<String>,
84 pub auth_methods: Vec<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub chatgpt_account_id: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub chatgpt_user_id: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub user_id: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub plan_type: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub subscription_active_start: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub subscription_active_until: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub subscription_last_checked: Option<String>,
99 pub organizations: Vec<AuthOrganization>,
100 pub scopes: Vec<String>,
101}
102
103#[derive(Debug, Clone, Eq, PartialEq)]
104pub struct AuthStatusReport {
105 pub auth_file: String,
106 pub token_name: String,
107 pub header: Value,
108 pub claims: Value,
109 pub summary: AuthStatusSummary,
110}
111
112#[derive(Debug, Clone, Eq, PartialEq)]
113pub struct AuthProfileEntry {
114 pub source: AuthProfileSource,
115 pub account_id: String,
116 pub profile_file: Option<String>,
117 pub auth_file: Option<String>,
118 pub summary: AuthStatusSummary,
119}
120
121#[derive(Debug, Clone, Eq, PartialEq)]
122pub enum AuthProfileSource {
123 Current,
124 Stored,
125}
126
127#[derive(Debug, Clone, Eq, PartialEq)]
128pub struct AuthProfileListReport {
129 pub auth_file: String,
130 pub store_dir: String,
131 pub current: Option<AuthProfileEntry>,
132 pub stored: Vec<AuthProfileEntry>,
133 pub skipped_stored: Vec<AuthProfileReadError>,
134}
135
136#[derive(Debug, Clone, Eq, PartialEq)]
137pub struct AuthProfileReadError {
138 pub profile_file: String,
139 pub reason: String,
140}
141
142#[derive(Debug, Clone, Eq, PartialEq)]
143pub struct AuthProfileSaveReport {
144 pub auth_file: String,
145 pub store_dir: String,
146 pub profile: AuthProfileEntry,
147}
148
149#[derive(Debug, Clone, Eq, PartialEq)]
150pub struct AuthProfileSwitchReport {
151 pub auth_file: String,
152 pub store_dir: String,
153 pub account_history_file: String,
154 pub saved_current: AuthProfileEntry,
155 pub activated: AuthProfileEntry,
156}
157
158#[derive(Debug, Clone, Eq, PartialEq)]
159pub struct AuthProfileRemoveReport {
160 pub store_dir: String,
161 pub removed: AuthProfileEntry,
162}
163
164struct ParsedAuthFile {
165 content: String,
166 report: AuthStatusReport,
167 account_id: String,
168}
169
170#[derive(Serialize)]
171#[serde(rename_all = "camelCase")]
172struct AuthStatusJson<'a> {
173 auth_file: &'a str,
174 token_name: &'a str,
175 token_claims_included: bool,
176 summary: &'a AuthStatusSummary,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 header: Option<&'a Value>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 claims: Option<&'a Value>,
181}
182
183pub fn read_codex_auth_status(
184 options: &AuthCommandOptions,
185 now: DateTime<Utc>,
186) -> Result<AuthStatusReport, AppError> {
187 let auth_file = auth_file_path(options);
188 let parsed = read_codex_auth_file(&auth_file, now)?;
189 Ok(parsed.report)
190}
191
192pub fn ensure_usage_account_history(
193 account_history_file: &Path,
194 options: &AuthCommandOptions,
195 now: DateTime<Utc>,
196) -> Result<UsageAccountHistory, AppError> {
197 let mut store = account_history::read_account_history_store(account_history_file)?;
198 if store.default_account.is_none() {
199 let report = read_codex_auth_status(options, now)?;
200 let account_id = report
201 .summary
202 .chatgpt_account_id
203 .clone()
204 .or(report.summary.token_account_id.clone())
205 .ok_or_else(|| AppError::new("No account id found in auth.json."))?;
206 store = account_history::ensure_default_account_in_file(
207 account_history_file,
208 AccountHistoryAccount::auth_json(
209 account_id,
210 now,
211 report.summary.name.clone(),
212 report.summary.email.clone(),
213 report.summary.plan_type.clone(),
214 ),
215 )?;
216 }
217 account_history::usage_account_history_from_store(store)?
218 .ok_or_else(|| AppError::new("No account history default account found."))
219}
220
221pub fn save_current_codex_auth_profile(
222 options: &AuthCommandOptions,
223 now: DateTime<Utc>,
224) -> Result<AuthProfileSaveReport, AppError> {
225 let auth_file = auth_file_path(options);
226 let store_dir = profile_store_dir(options);
227 let current = read_codex_auth_file(&auth_file, now)?;
228 let profile_file = resolve_profile_file(&store_dir, ¤t.account_id);
229
230 write_sensitive_file(&profile_file, ¤t.content)
231 .map_err(|error| AppError::new(error.to_string()))?;
232
233 Ok(AuthProfileSaveReport {
234 auth_file: path_to_string(&auth_file),
235 store_dir: path_to_string(&store_dir),
236 profile: to_auth_profile_entry(
237 current,
238 AuthProfileSource::Stored,
239 Some(profile_file),
240 None,
241 ),
242 })
243}
244
245pub fn list_codex_auth_profiles(
246 options: &AuthCommandOptions,
247 now: DateTime<Utc>,
248) -> Result<AuthProfileListReport, AppError> {
249 let auth_file = auth_file_path(options);
250 let store_dir = profile_store_dir(options);
251 let current = match read_codex_auth_file(&auth_file, now) {
252 Ok(parsed) => Some(to_auth_profile_entry(
253 parsed,
254 AuthProfileSource::Current,
255 None,
256 Some(auth_file.clone()),
257 )),
258 Err(error) if is_not_found_error(error.message()) => None,
259 Err(error) => return Err(error),
260 };
261 let stored = read_stored_codex_auth_profiles(&store_dir, now)?;
262
263 Ok(AuthProfileListReport {
264 auth_file: path_to_string(&auth_file),
265 store_dir: path_to_string(&store_dir),
266 current,
267 stored: stored.0,
268 skipped_stored: stored.1,
269 })
270}
271
272pub fn switch_codex_auth_profile(
273 account_id: &str,
274 options: &AuthCommandOptions,
275 now: DateTime<Utc>,
276) -> Result<AuthProfileSwitchReport, AppError> {
277 let auth_file = auth_file_path(options);
278 let store_dir = profile_store_dir(options);
279 let account_history_file = account_history_file_path(options);
280 let profile_file = resolve_profile_file(&store_dir, account_id);
281 let selected = read_codex_auth_file(&profile_file, now)?;
282 let current = read_codex_auth_file(&auth_file, now)?;
283
284 if selected.account_id != account_id {
285 return Err(AppError::new(format!(
286 "Stored auth profile {} contains account id {}, expected {account_id}.",
287 path_to_string(&profile_file),
288 selected.account_id
289 )));
290 }
291
292 let saved_profile_file = resolve_profile_file(&store_dir, ¤t.account_id);
293 let saved_current = to_auth_profile_entry(
294 current,
295 AuthProfileSource::Stored,
296 Some(saved_profile_file.clone()),
297 None,
298 );
299 let activated = to_auth_profile_entry(
300 selected,
301 AuthProfileSource::Current,
302 None,
303 Some(auth_file.clone()),
304 );
305 let previous_history_content = read_optional_file_content(&account_history_file)?;
306 let next_history_store = build_codex_auth_profile_switch_history(
307 &account_history_file,
308 &saved_current,
309 &activated,
310 now,
311 )?;
312
313 write_sensitive_file(&saved_profile_file, &read_file_content(&auth_file)?)
314 .map_err(|error| AppError::new(error.to_string()))?;
315 account_history::write_account_history_store(&account_history_file, &next_history_store)?;
316
317 let selected_content = read_file_content(&profile_file)?;
318 if let Err(error) = write_sensitive_file(&auth_file, &selected_content) {
319 let _ = restore_auth_account_history_file(&account_history_file, previous_history_content);
320 return Err(AppError::new(error.to_string()));
321 }
322
323 Ok(AuthProfileSwitchReport {
324 auth_file: path_to_string(&auth_file),
325 store_dir: path_to_string(&store_dir),
326 account_history_file: path_to_string(&account_history_file),
327 saved_current,
328 activated,
329 })
330}
331
332pub fn remove_codex_auth_profile(
333 account_id: &str,
334 options: &AuthCommandOptions,
335 now: DateTime<Utc>,
336) -> Result<AuthProfileRemoveReport, AppError> {
337 let store_dir = profile_store_dir(options);
338 let profile_file = resolve_profile_file(&store_dir, account_id);
339 let selected = read_codex_auth_file(&profile_file, now)?;
340
341 if selected.account_id != account_id {
342 return Err(AppError::new(format!(
343 "Stored auth profile {} contains account id {}, expected {account_id}.",
344 path_to_string(&profile_file),
345 selected.account_id
346 )));
347 }
348
349 fs::remove_file(&profile_file).map_err(|error| AppError::new(error.to_string()))?;
350
351 Ok(AuthProfileRemoveReport {
352 store_dir: path_to_string(&store_dir),
353 removed: to_auth_profile_entry(
354 selected,
355 AuthProfileSource::Stored,
356 Some(profile_file),
357 None,
358 ),
359 })
360}
361
362pub fn format_auth_status(
363 report: &AuthStatusReport,
364 json: bool,
365 include_token_claims: bool,
366) -> Result<String, AppError> {
367 if json {
368 let value = AuthStatusJson {
369 auth_file: &report.auth_file,
370 token_name: &report.token_name,
371 token_claims_included: include_token_claims,
372 summary: &report.summary,
373 header: include_token_claims.then_some(&report.header),
374 claims: include_token_claims.then_some(&report.claims),
375 };
376 return Ok(format!(
377 "{}\n",
378 to_pretty_json(&value).map_err(|error| AppError::new(error.to_string()))?
379 ));
380 }
381
382 let mut lines = vec!["Codex auth".to_string()];
383 append_optional_line(
384 &mut lines,
385 "Account ID",
386 report
387 .summary
388 .chatgpt_account_id
389 .as_deref()
390 .or(report.summary.token_account_id.as_deref()),
391 );
392 append_optional_line(&mut lines, "Key ID", report.summary.key_id.as_deref());
393 append_optional_line(&mut lines, "Name", report.summary.name.as_deref());
394 append_optional_line(&mut lines, "Email", report.summary.email.as_deref());
395 append_optional_line(
396 &mut lines,
397 "User ID",
398 report
399 .summary
400 .user_id
401 .as_deref()
402 .or(report.summary.chatgpt_user_id.as_deref()),
403 );
404 append_optional_line(&mut lines, "Plan", report.summary.plan_type.as_deref());
405
406 if !report.summary.organizations.is_empty() {
407 lines.push("Organizations:".to_string());
408 for organization in &report.summary.organizations {
409 lines.push(format!(" {}", format_organization(organization)));
410 }
411 }
412
413 Ok(format!("{}\n", lines.join("\n")))
414}
415
416pub fn format_auth_profile_list(report: &AuthProfileListReport) -> String {
417 let mut lines = vec![
418 "Codex auth profiles".to_string(),
419 format!("Store: {}", report.store_dir),
420 String::new(),
421 ];
422
423 match &report.current {
424 Some(current) => lines.push(format!("Current: {}", format_auth_profile_entry(current))),
425 None => lines.push("Current: (missing auth.json)".to_string()),
426 }
427
428 lines.push(String::new());
429
430 if report.stored.is_empty() {
431 lines.push("Persisted: none".to_string());
432 } else {
433 lines.push("Persisted:".to_string());
434 for (index, entry) in report.stored.iter().enumerate() {
435 let marker = if Some(&entry.account_id)
436 == report.current.as_ref().map(|entry| &entry.account_id)
437 {
438 " (current)"
439 } else {
440 ""
441 };
442 lines.push(format!(
443 " {}. {}{}",
444 index + 1,
445 format_auth_profile_entry(entry),
446 marker
447 ));
448 }
449 }
450
451 if !report.skipped_stored.is_empty() {
452 lines.push(String::new());
453 lines.push("Skipped persisted profiles:".to_string());
454 for (index, entry) in report.skipped_stored.iter().enumerate() {
455 lines.push(format!(
456 " {}. {} - {}",
457 index + 1,
458 entry.profile_file,
459 entry.reason
460 ));
461 }
462 }
463
464 format!("{}\n", lines.join("\n"))
465}
466
467pub fn format_auth_profile_entry(entry: &AuthProfileEntry) -> String {
468 let label = entry
469 .summary
470 .email
471 .as_deref()
472 .or(entry.summary.name.as_deref())
473 .or(entry.summary.user_id.as_deref())
474 .or(entry.summary.chatgpt_user_id.as_deref())
475 .unwrap_or("unknown");
476 let plan = entry.summary.plan_type.as_deref().unwrap_or("unknown");
477 format!("{label}({}) - {plan}", entry.account_id)
478}
479
480fn read_codex_auth_file(file_path: &Path, now: DateTime<Utc>) -> Result<ParsedAuthFile, AppError> {
481 let content = fs::read_to_string(file_path).map_err(|error| file_error(error, file_path))?;
482 let auth_json = parse_json_object(&content, file_path)?;
483 let report = build_codex_auth_status(&auth_json, &path_to_string(file_path), now)?;
484 let account_id = get_auth_account_id(&report)?;
485
486 Ok(ParsedAuthFile {
487 content,
488 report,
489 account_id,
490 })
491}
492
493fn build_codex_auth_status(
494 auth_json: &Map<String, Value>,
495 auth_file: &str,
496 now: DateTime<Utc>,
497) -> Result<AuthStatusReport, AppError> {
498 let tokens = auth_json
499 .get("tokens")
500 .and_then(Value::as_object)
501 .ok_or_else(|| {
502 AppError::new("No id_token found in auth.json. Expected auth.json tokens.id_token.")
503 })?;
504 let id_token = tokens
505 .get("id_token")
506 .and_then(Value::as_str)
507 .ok_or_else(|| {
508 AppError::new("No id_token found in auth.json. Expected auth.json tokens.id_token.")
509 })?;
510
511 if id_token.is_empty() {
512 return Err(AppError::new(
513 "No id_token found in auth.json. Expected auth.json tokens.id_token.",
514 ));
515 }
516
517 let (header, claims) = decode_jwt(id_token, "id_token")?;
518 let summary = summarize_auth_jwt(auth_json, &header, &claims, now);
519
520 Ok(AuthStatusReport {
521 auth_file: auth_file.to_string(),
522 token_name: "id_token".to_string(),
523 header: Value::Object(header),
524 claims: Value::Object(claims),
525 summary,
526 })
527}
528
529fn decode_jwt(token: &str, token_name: &str) -> Result<(JwtJsonObject, JwtJsonObject), AppError> {
530 let parts = token.split('.').collect::<Vec<_>>();
531 if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
532 return Err(AppError::new(format!(
533 "{token_name} is not a JWT with header, payload, and signature parts."
534 )));
535 }
536
537 let header = decode_jwt_json_part(parts[0], token_name, "header")?;
538 let claims = decode_jwt_json_part(parts[1], token_name, "payload")?;
539 Ok((header, claims))
540}
541
542fn decode_jwt_json_part(
543 segment: &str,
544 token_name: &str,
545 part_name: &str,
546) -> Result<Map<String, Value>, AppError> {
547 let decoded = base64url_decode(segment).map_err(|_| {
548 AppError::new(format!(
549 "{token_name} {part_name} is not valid base64url JSON."
550 ))
551 })?;
552 let value: Value = serde_json::from_slice(&decoded).map_err(|_| {
553 AppError::new(format!(
554 "{token_name} {part_name} is not valid base64url JSON."
555 ))
556 })?;
557 value
558 .as_object()
559 .cloned()
560 .ok_or_else(|| AppError::new(format!("{token_name} {part_name} must be a JSON object.")))
561}
562
563fn summarize_auth_jwt(
564 auth_json: &Map<String, Value>,
565 header: &Map<String, Value>,
566 claims: &Map<String, Value>,
567 now: DateTime<Utc>,
568) -> AuthStatusSummary {
569 let expires_at = read_numeric_date_claim(claims, "exp");
570 let openai_auth = claims.get(OPENAI_AUTH_CLAIM).and_then(Value::as_object);
571 let tokens = auth_json.get("tokens").and_then(Value::as_object);
572 let seconds_until_expiry = expires_at
573 .as_ref()
574 .map(|expires_at| expires_at.signed_duration_since(now).num_seconds());
575
576 AuthStatusSummary {
577 auth_mode: get_string_value(auth_json.get("auth_mode")),
578 token_account_id: tokens.and_then(|tokens| get_string_value(tokens.get("account_id"))),
579 last_refresh: read_date_value(auth_json.get("last_refresh")),
580 token_type: get_string_claim(header, "typ"),
581 algorithm: get_string_claim(header, "alg"),
582 key_id: get_string_claim(header, "kid"),
583 issuer: get_string_claim(claims, "iss"),
584 subject: get_string_claim(claims, "sub"),
585 audience: get_string_array_claim(claims, "aud"),
586 jwt_id: get_string_claim(claims, "jti"),
587 issued_at: read_numeric_date_claim(claims, "iat")
588 .map(account_history::format_account_history_iso),
589 expires_at: expires_at.map(account_history::format_account_history_iso),
590 not_before: read_numeric_date_claim(claims, "nbf")
591 .map(account_history::format_account_history_iso),
592 auth_time: read_numeric_date_claim(claims, "auth_time")
593 .map(account_history::format_account_history_iso),
594 requested_auth_time: read_numeric_date_claim(claims, "rat")
595 .map(account_history::format_account_history_iso),
596 is_expired: expires_at.map(|expires_at| expires_at <= now),
597 seconds_until_expiry,
598 name: get_string_claim(claims, "name"),
599 email: get_string_claim(claims, "email"),
600 email_verified: get_boolean_claim(claims, "email_verified"),
601 auth_provider: get_string_claim(claims, "auth_provider"),
602 auth_methods: get_string_array_claim(claims, "amr"),
603 chatgpt_account_id: openai_auth
604 .and_then(|object| get_string_claim(object, "chatgpt_account_id")),
605 chatgpt_user_id: openai_auth.and_then(|object| get_string_claim(object, "chatgpt_user_id")),
606 user_id: openai_auth.and_then(|object| get_string_claim(object, "user_id")),
607 plan_type: openai_auth.and_then(|object| get_string_claim(object, "chatgpt_plan_type")),
608 subscription_active_start: openai_auth
609 .and_then(|object| read_date_value(object.get("chatgpt_subscription_active_start"))),
610 subscription_active_until: openai_auth
611 .and_then(|object| read_date_value(object.get("chatgpt_subscription_active_until"))),
612 subscription_last_checked: openai_auth
613 .and_then(|object| read_date_value(object.get("chatgpt_subscription_last_checked"))),
614 organizations: get_organizations(openai_auth),
615 scopes: get_scope_claims(claims),
616 }
617}
618
619fn read_stored_codex_auth_profiles(
620 store_dir: &Path,
621 now: DateTime<Utc>,
622) -> Result<(Vec<AuthProfileEntry>, Vec<AuthProfileReadError>), AppError> {
623 let entries = match fs::read_dir(store_dir) {
624 Ok(entries) => entries,
625 Err(error) if error.kind() == io::ErrorKind::NotFound => {
626 return Ok((Vec::new(), Vec::new()))
627 }
628 Err(error) => return Err(AppError::new(error.to_string())),
629 };
630
631 let mut filenames = Vec::new();
632 for entry in entries {
633 let entry = entry.map_err(|error| AppError::new(error.to_string()))?;
634 let path = entry.path();
635 if path
636 .file_name()
637 .and_then(|name| name.to_str())
638 .is_some_and(|name| name.ends_with(".json"))
639 {
640 filenames.push(path);
641 }
642 }
643 filenames.sort();
644
645 let mut profiles = Vec::new();
646 let mut skipped = Vec::new();
647 for profile_file in filenames {
648 match read_codex_auth_file(&profile_file, now) {
649 Ok(parsed) => profiles.push(to_auth_profile_entry(
650 parsed,
651 AuthProfileSource::Stored,
652 Some(profile_file),
653 None,
654 )),
655 Err(error) => skipped.push(AuthProfileReadError {
656 profile_file: path_to_string(&profile_file),
657 reason: error.message().to_string(),
658 }),
659 }
660 }
661
662 profiles.sort_by(|left, right| left.account_id.cmp(&right.account_id));
663 Ok((profiles, skipped))
664}
665
666fn build_codex_auth_profile_switch_history(
667 account_history_file: &Path,
668 saved_current: &AuthProfileEntry,
669 activated: &AuthProfileEntry,
670 now: DateTime<Utc>,
671) -> Result<AccountHistoryStore, AppError> {
672 let current_store = account_history::read_account_history_store(account_history_file)?;
673 account_history::record_auth_select_switch(
674 current_store,
675 auth_profile_entry_to_history_account(saved_current, now),
676 &saved_current.account_id,
677 &activated.account_id,
678 now,
679 )
680}
681
682fn auth_profile_entry_to_history_account(
683 entry: &AuthProfileEntry,
684 now: DateTime<Utc>,
685) -> AccountHistoryAccount {
686 AccountHistoryAccount::auth_json(
687 entry.account_id.clone(),
688 now,
689 entry.summary.name.clone(),
690 entry.summary.email.clone(),
691 entry.summary.plan_type.clone(),
692 )
693}
694
695fn to_auth_profile_entry(
696 parsed: ParsedAuthFile,
697 source: AuthProfileSource,
698 profile_file: Option<PathBuf>,
699 auth_file: Option<PathBuf>,
700) -> AuthProfileEntry {
701 AuthProfileEntry {
702 source,
703 account_id: parsed.account_id,
704 profile_file: profile_file.as_ref().map(|path| path_to_string(path)),
705 auth_file: auth_file.as_ref().map(|path| path_to_string(path)),
706 summary: parsed.report.summary,
707 }
708}
709
710fn get_auth_account_id(report: &AuthStatusReport) -> Result<String, AppError> {
711 let account_id = report
712 .summary
713 .chatgpt_account_id
714 .as_deref()
715 .or(report.summary.token_account_id.as_deref())
716 .unwrap_or_default();
717
718 if account_id.is_empty() {
719 return Err(AppError::new("No account id found in auth.json."));
720 }
721
722 Ok(account_id.to_string())
723}
724
725fn auth_file_path(options: &AuthCommandOptions) -> PathBuf {
726 resolve_storage_paths(&storage_options(options)).auth_file
727}
728
729fn profile_store_dir(options: &AuthCommandOptions) -> PathBuf {
730 resolve_storage_paths(&storage_options(options)).profile_store_dir
731}
732
733fn account_history_file_path(options: &AuthCommandOptions) -> PathBuf {
734 resolve_storage_paths(&storage_options(options)).account_history_file
735}
736
737fn storage_options(options: &AuthCommandOptions) -> StorageOptions {
738 StorageOptions {
739 codex_home: options.codex_home.clone(),
740 auth_file: options.auth_file.clone(),
741 profile_store_dir: options.store_dir.clone(),
742 account_history_file: options.account_history_file.clone(),
743 sessions_dir: None,
744 }
745}
746
747fn resolve_profile_file(store_dir: &Path, account_id: &str) -> PathBuf {
748 store_dir.join(format!("{}.json", percent_encode(account_id)))
749}
750
751fn read_file_content(path: &Path) -> Result<String, AppError> {
752 fs::read_to_string(path).map_err(|error| file_error(error, path))
753}
754
755fn read_optional_file_content(path: &Path) -> Result<Option<String>, AppError> {
756 match fs::read_to_string(path) {
757 Ok(content) => Ok(Some(content)),
758 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
759 Err(error) => Err(AppError::new(error.to_string())),
760 }
761}
762
763fn restore_auth_account_history_file(path: &Path, content: Option<String>) -> Result<(), AppError> {
764 match content {
765 Some(content) => {
766 write_sensitive_file(path, &content).map_err(|error| AppError::new(error.to_string()))
767 }
768 None => match fs::remove_file(path) {
769 Ok(()) => Ok(()),
770 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
771 Err(error) => Err(AppError::new(error.to_string())),
772 },
773 }
774}
775
776fn parse_json_object(content: &str, file_path: &Path) -> Result<Map<String, Value>, AppError> {
777 let value: Value = serde_json::from_str(content).map_err(|error| {
778 AppError::new(format!(
779 "Failed to parse {}: {}",
780 path_to_string(file_path),
781 error
782 ))
783 })?;
784
785 value.as_object().cloned().ok_or_else(|| {
786 AppError::new(format!(
787 "Expected {} to contain a JSON object.",
788 path_to_string(file_path)
789 ))
790 })
791}
792
793fn get_organizations(openai_auth: Option<&Map<String, Value>>) -> Vec<AuthOrganization> {
794 let Some(Value::Array(organizations)) =
795 openai_auth.and_then(|object| object.get("organizations"))
796 else {
797 return Vec::new();
798 };
799
800 organizations
801 .iter()
802 .filter_map(Value::as_object)
803 .map(|organization| AuthOrganization {
804 id: get_string_claim(organization, "id"),
805 title: get_string_claim(organization, "title"),
806 role: get_string_claim(organization, "role"),
807 is_default: get_boolean_claim(organization, "is_default"),
808 })
809 .collect()
810}
811
812fn get_string_claim(object: &Map<String, Value>, key: &str) -> Option<String> {
813 get_string_value(object.get(key))
814}
815
816fn get_string_value(value: Option<&Value>) -> Option<String> {
817 match value {
818 Some(Value::String(value)) => Some(value.clone()),
819 Some(Value::Number(value)) => Some(value.to_string()),
820 Some(Value::Bool(value)) => Some(value.to_string()),
821 _ => None,
822 }
823}
824
825fn get_boolean_claim(object: &Map<String, Value>, key: &str) -> Option<bool> {
826 match object.get(key) {
827 Some(Value::Bool(value)) => Some(*value),
828 Some(Value::String(value)) if value.eq_ignore_ascii_case("true") => Some(true),
829 Some(Value::String(value)) if value.eq_ignore_ascii_case("false") => Some(false),
830 _ => None,
831 }
832}
833
834fn get_string_array_claim(object: &Map<String, Value>, key: &str) -> Vec<String> {
835 match object.get(key) {
836 Some(Value::String(value)) => vec![value.clone()],
837 Some(Value::Number(value)) => vec![value.to_string()],
838 Some(Value::Bool(value)) => vec![value.to_string()],
839 Some(Value::Array(values)) => values
840 .iter()
841 .filter_map(|value| get_string_value(Some(value)))
842 .collect(),
843 _ => Vec::new(),
844 }
845}
846
847fn get_scope_claims(object: &Map<String, Value>) -> Vec<String> {
848 let mut scopes = BTreeSet::new();
849 for value in get_space_separated_claim(object, "scope")
850 .into_iter()
851 .chain(get_space_separated_claim(object, "scp"))
852 .chain(get_string_array_claim(object, "scopes"))
853 {
854 scopes.insert(value);
855 }
856 scopes.into_iter().collect()
857}
858
859fn get_space_separated_claim(object: &Map<String, Value>, key: &str) -> Vec<String> {
860 match object.get(key) {
861 Some(Value::String(value)) => value
862 .split_whitespace()
863 .filter(|part| !part.is_empty())
864 .map(ToString::to_string)
865 .collect(),
866 _ => get_string_array_claim(object, key),
867 }
868}
869
870fn read_numeric_date_claim(object: &Map<String, Value>, key: &str) -> Option<DateTime<Utc>> {
871 let timestamp = match object.get(key) {
872 Some(Value::Number(value)) => value.as_f64()?,
873 Some(Value::String(value)) => value.parse::<f64>().ok()?,
874 _ => return None,
875 };
876
877 if !timestamp.is_finite() {
878 return None;
879 }
880
881 Utc.timestamp_millis_opt((timestamp * 1000.0) as i64)
882 .single()
883}
884
885fn read_date_value(value: Option<&Value>) -> Option<String> {
886 let text = value?.as_str()?;
887 if text.is_empty() {
888 return None;
889 }
890
891 DateTime::parse_from_rfc3339(text)
892 .ok()
893 .map(|date| account_history::format_account_history_iso(date.with_timezone(&Utc)))
894}
895
896fn append_optional_line(lines: &mut Vec<String>, label: &str, value: Option<&str>) {
897 if let Some(value) = value {
898 if !value.is_empty() {
899 lines.push(format!("{label}: {value}"));
900 }
901 }
902}
903
904fn format_organization(organization: &AuthOrganization) -> String {
905 let mut parts = Vec::new();
906 if let Some(title) = organization
907 .title
908 .as_deref()
909 .filter(|value| !value.is_empty())
910 {
911 parts.push(title.to_string());
912 }
913 if let Some(id) = organization.id.as_deref().filter(|value| !value.is_empty()) {
914 parts.push(id.to_string());
915 }
916 if let Some(role) = organization
917 .role
918 .as_deref()
919 .filter(|value| !value.is_empty())
920 {
921 parts.push(format!("role={role}"));
922 }
923 if organization.is_default == Some(true) {
924 parts.push("default".to_string());
925 }
926
927 if parts.is_empty() {
928 "(unknown organization)".to_string()
929 } else {
930 parts.join(", ")
931 }
932}
933
934fn base64url_decode(value: &str) -> Result<Vec<u8>, ()> {
935 let mut output = Vec::new();
936 let mut buffer = 0u32;
937 let mut bits = 0u8;
938
939 for byte in value.bytes() {
940 if byte == b'=' {
941 break;
942 }
943
944 let value = match byte {
945 b'A'..=b'Z' => byte - b'A',
946 b'a'..=b'z' => byte - b'a' + 26,
947 b'0'..=b'9' => byte - b'0' + 52,
948 b'-' => 62,
949 b'_' => 63,
950 _ => return Err(()),
951 } as u32;
952
953 buffer = (buffer << 6) | value;
954 bits += 6;
955
956 while bits >= 8 {
957 bits -= 8;
958 output.push(((buffer >> bits) & 0xff) as u8);
959 }
960 }
961
962 Ok(output)
963}
964
965fn path_to_string(path: &Path) -> String {
966 path.to_string_lossy().to_string()
967}
968
969fn file_error(error: io::Error, path: &Path) -> AppError {
970 if error.kind() == io::ErrorKind::NotFound {
971 return AppError::new(format!(
972 "ENOENT: no such file or directory, open '{}'",
973 path_to_string(path)
974 ));
975 }
976 AppError::new(error.to_string())
977}
978
979fn is_not_found_error(message: &str) -> bool {
980 message.starts_with("ENOENT:")
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986 use std::time::{SystemTime, UNIX_EPOCH};
987
988 #[test]
989 fn decodes_status_without_leaking_the_token() {
990 let token = jwt(
991 r#"{"alg":"RS256","typ":"JWT","kid":"key-1"}"#,
992 r#"{"sub":"user_123","exp":1778649000,"email":"user@example.test","https://api.openai.com/auth":{"chatgpt_account_id":"account_123","chatgpt_plan_type":"pro"}}"#,
993 );
994 let mut auth_json = Map::new();
995 auth_json.insert(
996 "tokens".to_string(),
997 serde_json::json!({ "id_token": token, "account_id": "account_123" }),
998 );
999 let report = build_codex_auth_status(
1000 &auth_json,
1001 "/tmp/auth.json",
1002 DateTime::parse_from_rfc3339("2026-05-12T00:00:00.000Z")
1003 .unwrap()
1004 .with_timezone(&Utc),
1005 )
1006 .unwrap();
1007
1008 assert_eq!(
1009 report.summary.chatgpt_account_id.as_deref(),
1010 Some("account_123")
1011 );
1012 let text = format_auth_status(&report, false, false).unwrap();
1013 assert!(text.contains("Account ID: account_123"));
1014 assert!(!text.contains("id_token"));
1015 }
1016
1017 #[test]
1018 fn json_claims_are_opt_in() {
1019 let token = jwt(r#"{"alg":"RS256","kid":"key-1"}"#, r#"{"sub":"user_123"}"#);
1020 let mut auth_json = Map::new();
1021 auth_json.insert(
1022 "tokens".to_string(),
1023 serde_json::json!({ "id_token": token }),
1024 );
1025 let report = build_codex_auth_status(&auth_json, "/tmp/auth.json", Utc::now()).unwrap();
1026 let default_json: Value =
1027 serde_json::from_str(&format_auth_status(&report, true, false).unwrap()).unwrap();
1028 let claims_json: Value =
1029 serde_json::from_str(&format_auth_status(&report, true, true).unwrap()).unwrap();
1030
1031 assert!(default_json.get("claims").is_none());
1032 assert_eq!(claims_json["claims"]["sub"], "user_123");
1033 }
1034
1035 #[test]
1036 fn malformed_jwt_errors_are_clear() {
1037 let mut auth_json = Map::new();
1038 auth_json.insert(
1039 "tokens".to_string(),
1040 serde_json::json!({ "id_token": "not-a-jwt" }),
1041 );
1042
1043 let error = build_codex_auth_status(&auth_json, "/tmp/auth.json", Utc::now()).unwrap_err();
1044
1045 assert!(error.message().contains("id_token is not a JWT"));
1046 }
1047
1048 #[test]
1049 fn profile_switch_preserves_files_and_writes_history() {
1050 let temp_dir = temp_dir("codex-ops-auth-switch");
1051 let auth_file = temp_dir.join("auth.json");
1052 let store_dir = temp_dir.join("auth-profiles");
1053 let history_file = temp_dir.join("auth-account-history.json");
1054 let now = DateTime::parse_from_rfc3339("2026-05-13T00:00:00.000Z")
1055 .unwrap()
1056 .with_timezone(&Utc);
1057 let current_content = auth_content("account-a", "a@example.test", "plus");
1058 let selected_content = auth_content("account-b", "b@example.test", "pro");
1059
1060 fs::create_dir_all(&temp_dir).unwrap();
1061 fs::write(&auth_file, &selected_content).unwrap();
1062 save_current_codex_auth_profile(
1063 &AuthCommandOptions {
1064 auth_file: Some(auth_file.clone()),
1065 store_dir: Some(store_dir.clone()),
1066 ..AuthCommandOptions::default()
1067 },
1068 now,
1069 )
1070 .unwrap();
1071 fs::write(&auth_file, ¤t_content).unwrap();
1072
1073 let report = switch_codex_auth_profile(
1074 "account-b",
1075 &AuthCommandOptions {
1076 auth_file: Some(auth_file.clone()),
1077 store_dir: Some(store_dir.clone()),
1078 account_history_file: Some(history_file.clone()),
1079 ..AuthCommandOptions::default()
1080 },
1081 now,
1082 )
1083 .unwrap();
1084
1085 assert_eq!(report.saved_current.account_id, "account-a");
1086 assert_eq!(report.activated.account_id, "account-b");
1087 assert_eq!(fs::read_to_string(&auth_file).unwrap(), selected_content);
1088 assert_eq!(
1089 fs::read_to_string(store_dir.join("account-a.json")).unwrap(),
1090 current_content
1091 );
1092 let history: Value =
1093 serde_json::from_str(&fs::read_to_string(&history_file).unwrap()).unwrap();
1094 assert_eq!(history["defaultAccount"]["accountId"], "account-a");
1095 assert_eq!(history["switches"][0]["fromAccountId"], "account-a");
1096 assert_eq!(history["switches"][0]["toAccountId"], "account-b");
1097
1098 let removed = remove_codex_auth_profile(
1099 "account-a",
1100 &AuthCommandOptions {
1101 store_dir: Some(store_dir.clone()),
1102 ..AuthCommandOptions::default()
1103 },
1104 now,
1105 )
1106 .unwrap();
1107 assert_eq!(removed.removed.account_id, "account-a");
1108 assert!(!store_dir.join("account-a.json").exists());
1109
1110 let _ = fs::remove_dir_all(&temp_dir);
1111 }
1112
1113 fn jwt(header: &str, payload: &str) -> String {
1114 format!(
1115 "{}.{}.signature",
1116 encode_base64url(header),
1117 encode_base64url(payload)
1118 )
1119 }
1120
1121 fn auth_content(account_id: &str, email: &str, plan: &str) -> String {
1122 let payload = serde_json::json!({
1123 "sub": format!("auth0|{account_id}"),
1124 "email": email,
1125 "https://api.openai.com/auth": {
1126 "chatgpt_account_id": account_id,
1127 "chatgpt_plan_type": plan,
1128 "chatgpt_user_id": format!("user-{account_id}"),
1129 "user_id": format!("user-{account_id}")
1130 }
1131 });
1132 let token = jwt(r#"{"alg":"RS256","kid":"key-1"}"#, &payload.to_string());
1133 serde_json::to_string_pretty(&serde_json::json!({
1134 "auth_mode": "chatgpt",
1135 "tokens": {
1136 "id_token": token,
1137 "refresh_token": "synthetic-refresh-token",
1138 "account_id": account_id
1139 },
1140 "last_refresh": "2026-05-12T05:32:41.917677755Z"
1141 }))
1142 .unwrap()
1143 }
1144
1145 fn temp_dir(prefix: &str) -> PathBuf {
1146 let millis = SystemTime::now()
1147 .duration_since(UNIX_EPOCH)
1148 .unwrap()
1149 .as_millis();
1150 std::env::temp_dir().join(format!("{prefix}-{millis}-{}", std::process::id()))
1151 }
1152
1153 fn encode_base64url(value: &str) -> String {
1154 const TABLE: &[u8; 64] =
1155 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1156 let bytes = value.as_bytes();
1157 let mut output = String::new();
1158 let mut index = 0;
1159
1160 while index < bytes.len() {
1161 let b0 = bytes[index];
1162 let b1 = *bytes.get(index + 1).unwrap_or(&0);
1163 let b2 = *bytes.get(index + 2).unwrap_or(&0);
1164 output.push(TABLE[(b0 >> 2) as usize] as char);
1165 output.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
1166 if index + 1 < bytes.len() {
1167 output.push(TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
1168 }
1169 if index + 2 < bytes.len() {
1170 output.push(TABLE[(b2 & 0x3f) as usize] as char);
1171 }
1172 index += 3;
1173 }
1174
1175 output
1176 }
1177}