Skip to main content

codex_ops/
auth.rs

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, &current.account_id);
229
230    write_sensitive_file(&profile_file, &current.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, &current.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, &current_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}