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