Skip to main content

codex_ops/
auth.rs

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