Skip to main content

codex_profiles/
auth.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use serde::{Deserialize, Serialize};
4use serde_with::{NoneAsEmptyString, serde_as};
5use std::path::Path;
6use std::time::Duration;
7
8use crate::write_atomic;
9
10const API_KEY_PREFIX: &str = "api-key-";
11const API_KEY_LABEL: &str = "Key";
12const API_KEY_SEPARATOR: &str = "~";
13const API_KEY_PREFIX_LEN: usize = 12;
14const API_KEY_SUFFIX_LEN: usize = 16;
15const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
16const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE";
17const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
18
19#[derive(Debug, Deserialize)]
20pub struct AuthFile {
21    #[serde(rename = "OPENAI_API_KEY")]
22    pub openai_api_key: Option<String>,
23    pub tokens: Option<Tokens>,
24    #[serde(default)]
25    pub last_refresh: Option<String>,
26}
27
28#[serde_as]
29#[derive(Clone, Debug, Deserialize)]
30pub struct Tokens {
31    #[serde(default)]
32    #[serde_as(as = "NoneAsEmptyString")]
33    pub account_id: Option<String>,
34    #[serde(default)]
35    #[serde_as(as = "NoneAsEmptyString")]
36    pub id_token: Option<String>,
37    #[serde(default)]
38    #[serde_as(as = "NoneAsEmptyString")]
39    pub access_token: Option<String>,
40    #[serde(default)]
41    #[serde_as(as = "NoneAsEmptyString")]
42    pub refresh_token: Option<String>,
43}
44
45#[serde_as]
46#[derive(Deserialize)]
47struct IdTokenClaims {
48    #[serde(default)]
49    #[serde_as(as = "NoneAsEmptyString")]
50    email: Option<String>,
51    #[serde(rename = "https://api.openai.com/auth")]
52    auth: Option<AuthClaims>,
53}
54
55#[serde_as]
56#[derive(Deserialize)]
57struct AuthClaims {
58    #[serde(default)]
59    #[serde_as(as = "NoneAsEmptyString")]
60    chatgpt_plan_type: Option<String>,
61}
62
63pub fn read_tokens(path: &Path) -> Result<Tokens, String> {
64    let auth = read_auth_file(path)?;
65    if let Some(tokens) = auth.tokens {
66        return Ok(tokens);
67    }
68    if let Some(api_key) = auth.openai_api_key.as_deref() {
69        return Ok(tokens_from_api_key(api_key));
70    }
71    Err(format!(
72        "Error: missing tokens in {}. Run `codex login` to authenticate.",
73        path.display()
74    ))
75}
76
77pub fn read_auth_file(path: &Path) -> Result<AuthFile, String> {
78    let data = std::fs::read_to_string(path).map_err(|err| {
79        if err.kind() == std::io::ErrorKind::NotFound {
80            "Error: Codex auth file not found. Run `codex login` first.".to_string()
81        } else {
82            format!("Error: failed to read {}: {err}", path.display())
83        }
84    })?;
85    let auth: AuthFile = serde_json::from_str(&data).map_err(|err| {
86        format!(
87            "Error: invalid JSON in {}: {err}. Run `codex login` to regenerate it.",
88            path.display()
89        )
90    })?;
91    Ok(auth)
92}
93
94pub fn read_tokens_opt(path: &Path) -> Option<Tokens> {
95    if !path.is_file() {
96        return None;
97    }
98    read_tokens(path).ok()
99}
100
101pub fn tokens_from_api_key(api_key: &str) -> Tokens {
102    Tokens {
103        account_id: Some(api_key_profile_id(api_key)),
104        id_token: None,
105        access_token: None,
106        refresh_token: None,
107    }
108}
109
110pub fn has_auth(path: &Path) -> bool {
111    read_tokens_opt(path).is_some_and(|tokens| is_profile_ready(&tokens))
112}
113
114pub fn is_profile_ready(tokens: &Tokens) -> bool {
115    if is_api_key_profile(tokens) {
116        return true;
117    }
118    if token_account_id(tokens).is_none() {
119        return false;
120    }
121    if !tokens
122        .access_token
123        .as_deref()
124        .map(|value| !value.is_empty())
125        .unwrap_or(false)
126    {
127        return false;
128    }
129    let (email, plan) = extract_email_and_plan(tokens);
130    email.is_some() && plan.is_some()
131}
132
133pub fn extract_email_and_plan(tokens: &Tokens) -> (Option<String>, Option<String>) {
134    if is_api_key_profile(tokens) {
135        let display = api_key_display_label(tokens).unwrap_or_else(|| API_KEY_LABEL.to_string());
136        return (Some(display), Some(API_KEY_LABEL.to_string()));
137    }
138    let claims = tokens.id_token.as_deref().and_then(decode_id_token_claims);
139    let email = claims.as_ref().and_then(|c| c.email.clone());
140    let plan = claims
141        .and_then(|c| c.auth)
142        .and_then(|auth| auth.chatgpt_plan_type)
143        .map(|plan| format_plan(&plan));
144    (email, plan)
145}
146
147pub fn require_identity(tokens: &Tokens) -> Result<(String, String, String), String> {
148    let Some(account_id) = token_account_id(tokens) else {
149        return Err(
150            "Error: auth.json is missing tokens.account_id. Run `codex login` to reauthenticate."
151                .to_string(),
152        );
153    };
154    let (email, plan) = extract_email_and_plan(tokens);
155    let email = email.ok_or_else(|| {
156        "Error: auth.json is missing id_token email. Run `codex login` to reauthenticate."
157            .to_string()
158    })?;
159    let plan = plan.ok_or_else(|| {
160        "Error: auth.json is missing id_token plan. Run `codex login` to reauthenticate."
161            .to_string()
162    })?;
163    Ok((account_id.to_string(), email, plan))
164}
165
166pub fn profile_error(
167    tokens: &Tokens,
168    email: Option<&str>,
169    plan: Option<&str>,
170) -> Option<&'static str> {
171    if is_api_key_profile(tokens) {
172        return None;
173    }
174    if email.is_none() || plan.is_none() {
175        return Some("profile missing id_token email/plan");
176    }
177    if token_account_id(tokens).is_none() {
178        return Some("profile missing tokens.account_id");
179    }
180    if tokens.access_token.is_none() {
181        return Some("profile missing tokens.access_token");
182    }
183    None
184}
185
186pub fn token_account_id(tokens: &Tokens) -> Option<&str> {
187    tokens
188        .account_id
189        .as_deref()
190        .filter(|value| !value.is_empty())
191}
192
193pub fn is_api_key_profile(tokens: &Tokens) -> bool {
194    tokens
195        .account_id
196        .as_deref()
197        .map(|value| value.starts_with(API_KEY_PREFIX))
198        .unwrap_or(false)
199        && tokens.id_token.is_none()
200        && tokens.access_token.is_none()
201        && tokens.refresh_token.is_none()
202}
203
204pub fn format_plan(plan: &str) -> String {
205    let mut out = String::new();
206    for word in plan.split(['_', '-']) {
207        if word.is_empty() {
208            continue;
209        }
210        if !out.is_empty() {
211            out.push(' ');
212        }
213        out.push_str(&title_case(word));
214    }
215    if out.is_empty() {
216        "Unknown".to_string()
217    } else {
218        out
219    }
220}
221
222pub fn is_free_plan(plan: Option<&str>) -> bool {
223    plan.map(|value| value.eq_ignore_ascii_case("free"))
224        .unwrap_or(false)
225}
226
227fn title_case(word: &str) -> String {
228    let mut chars = word.chars();
229    let Some(first) = chars.next() else {
230        return String::new();
231    };
232    let mut out = String::new();
233    out.push(first.to_ascii_uppercase());
234    out.extend(chars.flat_map(|ch| ch.to_lowercase()));
235    out
236}
237
238fn decode_id_token_claims(token: &str) -> Option<IdTokenClaims> {
239    let mut parts = token.split('.');
240    let _header = parts.next()?;
241    let payload = parts.next()?;
242    let _sig = parts.next()?;
243    let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
244    serde_json::from_slice(&decoded).ok()
245}
246
247fn api_key_profile_id(api_key: &str) -> String {
248    let prefix = api_key_prefix(api_key);
249    let mut hash: u64 = 0xcbf29ce484222325;
250    for byte in api_key.as_bytes() {
251        hash ^= u64::from(*byte);
252        hash = hash.wrapping_mul(0x100000001b3);
253    }
254    format!("{API_KEY_PREFIX}{prefix}{API_KEY_SEPARATOR}{hash:016x}")
255}
256
257fn api_key_display_label(tokens: &Tokens) -> Option<String> {
258    let account_id = tokens.account_id.as_deref()?;
259    let rest = account_id.strip_prefix(API_KEY_PREFIX)?;
260    let (prefix, hash) = rest.split_once(API_KEY_SEPARATOR)?;
261    if prefix.is_empty() {
262        return None;
263    }
264    let suffix: String = hash.chars().rev().take(API_KEY_SUFFIX_LEN).collect();
265    let suffix: String = suffix.chars().rev().collect();
266    if suffix.is_empty() {
267        return None;
268    }
269    Some(format!("{API_KEY_SEPARATOR}{suffix}"))
270}
271
272fn api_key_prefix(api_key: &str) -> String {
273    let mut out = String::new();
274    for ch in api_key.chars().take(API_KEY_PREFIX_LEN) {
275        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
276            out.push(ch);
277        } else {
278            out.push('-');
279        }
280    }
281    out
282}
283
284#[derive(Serialize)]
285struct RefreshRequest {
286    client_id: &'static str,
287    grant_type: &'static str,
288    refresh_token: String,
289    scope: &'static str,
290}
291
292#[derive(Clone, Debug, Deserialize)]
293struct RefreshResponse {
294    id_token: Option<String>,
295    access_token: Option<String>,
296    refresh_token: Option<String>,
297}
298
299pub fn refresh_profile_tokens(path: &Path, tokens: &mut Tokens) -> Result<(), String> {
300    let refresh_token = tokens
301        .refresh_token
302        .as_deref()
303        .filter(|value| !value.is_empty())
304        .ok_or_else(|| {
305            "Error: profile is missing refresh_token; run `codex login` and save it again."
306                .to_string()
307        })?;
308    let refreshed = refresh_access_token(refresh_token)?;
309    apply_refresh(tokens, &refreshed)?;
310    update_auth_tokens(path, &refreshed)?;
311    Ok(())
312}
313
314fn refresh_access_token(refresh_token: &str) -> Result<RefreshResponse, String> {
315    let request = RefreshRequest {
316        client_id: CLIENT_ID,
317        grant_type: "refresh_token",
318        refresh_token: refresh_token.to_string(),
319        scope: "openid profile email",
320    };
321    let endpoint = std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR)
322        .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string());
323    let config = ureq::Agent::config_builder()
324        .timeout_global(Some(Duration::from_secs(5)))
325        .build();
326    let agent: ureq::Agent = config.into();
327    let response = agent
328        .post(&endpoint)
329        .header("Content-Type", "application/json")
330        .send_json(&request)
331        .map_err(|err| match err {
332            ureq::Error::StatusCode(code) => {
333                format!("Error: failed to refresh access token: http status: {code}")
334            }
335            other => format!("Error: failed to refresh access token: {other}"),
336        })?;
337    response
338        .into_body()
339        .read_json::<RefreshResponse>()
340        .map_err(|err| format!("Error: failed to parse refresh response: {err}"))
341}
342
343fn apply_refresh(tokens: &mut Tokens, refreshed: &RefreshResponse) -> Result<(), String> {
344    let Some(access_token) = refreshed.access_token.as_ref() else {
345        return Err("Error: refresh response missing access_token.".to_string());
346    };
347    tokens.access_token = Some(access_token.clone());
348    if let Some(id_token) = refreshed.id_token.as_ref() {
349        tokens.id_token = Some(id_token.clone());
350    }
351    if let Some(refresh_token) = refreshed.refresh_token.as_ref() {
352        tokens.refresh_token = Some(refresh_token.clone());
353    }
354    Ok(())
355}
356
357fn update_auth_tokens(path: &Path, refreshed: &RefreshResponse) -> Result<(), String> {
358    let contents = std::fs::read_to_string(path)
359        .map_err(|err| format!("Error: failed to read {}: {err}", path.display()))?;
360    let mut value: serde_json::Value = serde_json::from_str(&contents)
361        .map_err(|err| format!("Error: invalid JSON in {}: {err}", path.display()))?;
362    let Some(root) = value.as_object_mut() else {
363        return Err(format!(
364            "Error: invalid JSON in {} (expected object)",
365            path.display()
366        ));
367    };
368    let tokens = root
369        .entry("tokens")
370        .or_insert_with(|| serde_json::json!({}));
371    let Some(tokens_map) = tokens.as_object_mut() else {
372        return Err(format!(
373            "Error: invalid tokens in {} (expected object)",
374            path.display()
375        ));
376    };
377    if let Some(id_token) = refreshed.id_token.as_ref() {
378        tokens_map.insert(
379            "id_token".to_string(),
380            serde_json::Value::String(id_token.clone()),
381        );
382    }
383    if let Some(access_token) = refreshed.access_token.as_ref() {
384        tokens_map.insert(
385            "access_token".to_string(),
386            serde_json::Value::String(access_token.clone()),
387        );
388    }
389    if let Some(refresh_token) = refreshed.refresh_token.as_ref() {
390        tokens_map.insert(
391            "refresh_token".to_string(),
392            serde_json::Value::String(refresh_token.clone()),
393        );
394    }
395    let json = serde_json::to_string_pretty(&value)
396        .map_err(|err| format!("Error: failed to serialize auth file: {err}"))?;
397    write_atomic(path, format!("{json}\n").as_bytes())
398        .map_err(|err| format!("Error: failed to write {}: {err}", path.display()))
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::test_utils::{
405        ENV_MUTEX, build_id_token, http_ok_response, set_env_guard, spawn_server,
406    };
407    use std::fs;
408
409    fn build_id_token_payload(payload: &str) -> String {
410        let header = r#"{"alg":"none","typ":"JWT"}"#;
411        let header = URL_SAFE_NO_PAD.encode(header);
412        let payload = URL_SAFE_NO_PAD.encode(payload);
413        format!("{header}.{payload}.")
414    }
415
416    #[test]
417    fn read_auth_file_errors() {
418        let dir = tempfile::tempdir().expect("tempdir");
419        let missing = dir.path().join("missing.json");
420        let err = read_auth_file(&missing).unwrap_err();
421        assert!(err.contains("auth file not found"));
422
423        let bad = dir.path().join("bad.json");
424        fs::write(&bad, "{oops").expect("write");
425        let err = read_auth_file(&bad).unwrap_err();
426        assert!(err.contains("invalid JSON"));
427    }
428
429    #[test]
430    fn read_tokens_paths() {
431        let dir = tempfile::tempdir().expect("tempdir");
432        let path = dir.path().join("auth.json");
433        let id_token = build_id_token("me@example.com", "pro");
434        let value = serde_json::json!({
435            "tokens": {"account_id": "acct", "id_token": id_token, "access_token": "acc"}
436        });
437        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
438        let tokens = read_tokens(&path).unwrap();
439        assert_eq!(token_account_id(&tokens), Some("acct"));
440
441        let api_path = dir.path().join("auth_api.json");
442        let value = serde_json::json!({"OPENAI_API_KEY": "sk-test"});
443        fs::write(&api_path, serde_json::to_string(&value).unwrap()).unwrap();
444        let tokens = read_tokens(&api_path).unwrap();
445        assert!(is_api_key_profile(&tokens));
446
447        let empty_path = dir.path().join("empty.json");
448        fs::write(&empty_path, "{}").unwrap();
449        let err = read_tokens(&empty_path).unwrap_err();
450        assert!(err.contains("missing tokens"));
451    }
452
453    #[test]
454    fn read_tokens_opt_handles_missing() {
455        let dir = tempfile::tempdir().expect("tempdir");
456        let path = dir.path().join("none.json");
457        assert!(read_tokens_opt(&path).is_none());
458    }
459
460    #[test]
461    fn api_key_helpers() {
462        let tokens = tokens_from_api_key("sk-test-1234");
463        assert!(is_api_key_profile(&tokens));
464        let display = api_key_display_label(&tokens).unwrap();
465        assert!(display.starts_with(API_KEY_SEPARATOR));
466        assert_eq!(api_key_prefix("abc$123"), "abc-123".to_string());
467    }
468
469    #[test]
470    fn format_plan_and_free() {
471        assert_eq!(format_plan("chatgpt_plus"), "Chatgpt Plus");
472        assert_eq!(format_plan(""), "Unknown");
473        assert!(is_free_plan(Some("free")));
474        assert!(!is_free_plan(Some("pro")));
475    }
476
477    #[test]
478    fn extract_email_and_plan_paths() {
479        let id_token = build_id_token("me@example.com", "pro");
480        let tokens = Tokens {
481            account_id: Some("acct".to_string()),
482            id_token: Some(id_token),
483            access_token: Some("acc".to_string()),
484            refresh_token: None,
485        };
486        let (email, plan) = extract_email_and_plan(&tokens);
487        assert_eq!(email.as_deref(), Some("me@example.com"));
488        assert_eq!(plan.as_deref(), Some("Pro"));
489
490        let api_tokens = tokens_from_api_key("sk-test");
491        let (email, plan) = extract_email_and_plan(&api_tokens);
492        assert_eq!(plan.as_deref(), Some(API_KEY_LABEL));
493        assert!(email.is_some());
494    }
495
496    #[test]
497    fn require_identity_errors() {
498        let tokens = Tokens {
499            account_id: None,
500            id_token: None,
501            access_token: None,
502            refresh_token: None,
503        };
504        let err = require_identity(&tokens).unwrap_err();
505        assert!(err.contains("missing tokens.account_id"));
506    }
507
508    #[test]
509    fn profile_error_variants() {
510        let tokens = Tokens {
511            account_id: Some("acct".to_string()),
512            id_token: None,
513            access_token: None,
514            refresh_token: None,
515        };
516        assert_eq!(
517            profile_error(&tokens, Some("e"), Some("p")),
518            Some("profile missing tokens.access_token")
519        );
520
521        let api_tokens = tokens_from_api_key("sk-test");
522        assert!(profile_error(&api_tokens, None, None).is_none());
523
524        let tokens = Tokens {
525            account_id: None,
526            id_token: Some(build_id_token("me@example.com", "pro")),
527            access_token: Some("acc".to_string()),
528            refresh_token: None,
529        };
530        assert_eq!(
531            profile_error(&tokens, Some("me@example.com"), Some("Pro")),
532            Some("profile missing tokens.account_id")
533        );
534
535        let id_token = build_id_token_payload(
536            "{\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\"}}",
537        );
538        let tokens = Tokens {
539            account_id: Some("acct".to_string()),
540            id_token: Some(id_token),
541            access_token: Some("acc".to_string()),
542            refresh_token: None,
543        };
544        assert_eq!(
545            profile_error(&tokens, None, Some("Pro")),
546            Some("profile missing id_token email/plan")
547        );
548    }
549
550    #[test]
551    fn is_profile_ready_variants() {
552        let api_tokens = tokens_from_api_key("sk-test");
553        assert!(is_profile_ready(&api_tokens));
554
555        let tokens = Tokens {
556            account_id: None,
557            id_token: Some(build_id_token("me@example.com", "pro")),
558            access_token: Some("acc".to_string()),
559            refresh_token: None,
560        };
561        assert!(!is_profile_ready(&tokens));
562
563        let tokens = Tokens {
564            account_id: Some("acct".to_string()),
565            id_token: Some(build_id_token("me@example.com", "pro")),
566            access_token: None,
567            refresh_token: None,
568        };
569        assert!(!is_profile_ready(&tokens));
570
571        let id_token = build_id_token_payload("{\"email\":\"me@example.com\"}");
572        let tokens = Tokens {
573            account_id: Some("acct".to_string()),
574            id_token: Some(id_token),
575            access_token: Some("acc".to_string()),
576            refresh_token: None,
577        };
578        assert!(!is_profile_ready(&tokens));
579    }
580
581    #[test]
582    fn require_identity_missing_fields() {
583        let id_token = build_id_token_payload("{\"email\":\"me@example.com\"}");
584        let tokens = Tokens {
585            account_id: Some("acct".to_string()),
586            id_token: Some(id_token),
587            access_token: Some("acc".to_string()),
588            refresh_token: None,
589        };
590        let err = require_identity(&tokens).unwrap_err();
591        assert!(err.contains("missing id_token plan"));
592
593        let id_token = build_id_token_payload(
594            "{\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\"}}",
595        );
596        let tokens = Tokens {
597            account_id: Some("acct".to_string()),
598            id_token: Some(id_token),
599            access_token: Some("acc".to_string()),
600            refresh_token: None,
601        };
602        let err = require_identity(&tokens).unwrap_err();
603        assert!(err.contains("missing id_token email"));
604
605        let tokens = Tokens {
606            account_id: Some("acct".to_string()),
607            id_token: Some(build_id_token("me@example.com", "pro")),
608            access_token: Some("acc".to_string()),
609            refresh_token: None,
610        };
611        assert!(require_identity(&tokens).is_ok());
612    }
613
614    #[test]
615    fn refresh_profile_tokens_missing_refresh() {
616        let dir = tempfile::tempdir().expect("tempdir");
617        let path = dir.path().join("auth.json");
618        let value = serde_json::json!({
619            "tokens": {
620                "account_id": "acct",
621                "access_token": "acc"
622            }
623        });
624        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
625        let mut tokens = read_tokens(&path).unwrap();
626        let err = refresh_profile_tokens(&path, &mut tokens).unwrap_err();
627        assert!(err.contains("missing refresh_token"));
628    }
629
630    #[test]
631    fn set_env_clears_value() {
632        let _guard = ENV_MUTEX.lock().unwrap();
633        {
634            let _env = set_env_guard("CODEX_PROFILES_TEST_ENV", Some("value"));
635        }
636        {
637            let _env = set_env_guard("CODEX_PROFILES_TEST_ENV", None);
638        }
639    }
640
641    #[test]
642    fn decode_id_token_claims_handles_invalid() {
643        assert!(decode_id_token_claims("not-a-jwt").is_none());
644        let bad = "a.b.c";
645        assert!(decode_id_token_claims(bad).is_none());
646        let good = build_id_token("me@example.com", "pro");
647        assert!(decode_id_token_claims(&good).is_some());
648    }
649
650    #[test]
651    fn apply_refresh_requires_access_token() {
652        let mut tokens = Tokens {
653            account_id: Some("acct".to_string()),
654            id_token: None,
655            access_token: None,
656            refresh_token: None,
657        };
658        let refreshed = RefreshResponse {
659            id_token: None,
660            access_token: None,
661            refresh_token: None,
662        };
663        let err = apply_refresh(&mut tokens, &refreshed).unwrap_err();
664        assert!(err.contains("missing access_token"));
665    }
666
667    #[test]
668    fn update_auth_tokens_errors() {
669        let dir = tempfile::tempdir().expect("tempdir");
670        let missing = dir.path().join("missing.json");
671        let err = update_auth_tokens(
672            &missing,
673            &RefreshResponse {
674                id_token: None,
675                access_token: None,
676                refresh_token: None,
677            },
678        )
679        .unwrap_err();
680        assert!(err.contains("failed to read"));
681
682        let bad = dir.path().join("bad.json");
683        fs::write(&bad, "{oops").unwrap();
684        let err = update_auth_tokens(
685            &bad,
686            &RefreshResponse {
687                id_token: None,
688                access_token: None,
689                refresh_token: None,
690            },
691        )
692        .unwrap_err();
693        assert!(err.contains("invalid JSON"));
694
695        let not_obj = dir.path().join("not_obj.json");
696        fs::write(&not_obj, "[]").unwrap();
697        let err = update_auth_tokens(
698            &not_obj,
699            &RefreshResponse {
700                id_token: None,
701                access_token: None,
702                refresh_token: None,
703            },
704        )
705        .unwrap_err();
706        assert!(err.contains("expected object"));
707
708        let tokens_not_obj = dir.path().join("tokens_not_obj.json");
709        fs::write(&tokens_not_obj, "{\"tokens\": []}").unwrap();
710        let err = update_auth_tokens(
711            &tokens_not_obj,
712            &RefreshResponse {
713                id_token: None,
714                access_token: None,
715                refresh_token: None,
716            },
717        )
718        .unwrap_err();
719        assert!(err.contains("invalid tokens"));
720    }
721
722    #[test]
723    fn refresh_access_token_success_and_status() {
724        let _guard = ENV_MUTEX.lock().unwrap();
725        let ok_body = "{\"access_token\":\"acc\",\"id_token\":\"id\",\"refresh_token\":\"ref\"}";
726        let ok_resp = http_ok_response(ok_body, "application/json");
727        let ok_url = spawn_server(ok_resp);
728        {
729            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&ok_url));
730            let refreshed = refresh_access_token("token").unwrap();
731            assert_eq!(refreshed.access_token.as_deref(), Some("acc"));
732        }
733
734        let err_resp = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n".to_string();
735        let err_url = spawn_server(err_resp);
736        {
737            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&err_url));
738            let err = refresh_access_token("token").unwrap_err();
739            assert!(err.contains("http status"));
740        }
741    }
742
743    #[test]
744    fn refresh_profile_tokens_updates_file() {
745        let _guard = ENV_MUTEX.lock().unwrap();
746        let ok_body = "{\"access_token\":\"acc\",\"id_token\":\"id\",\"refresh_token\":\"ref\"}";
747        let ok_resp = http_ok_response(ok_body, "application/json");
748        let ok_url = spawn_server(ok_resp);
749        let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&ok_url));
750
751        let dir = tempfile::tempdir().expect("tempdir");
752        let path = dir.path().join("auth.json");
753        let value = serde_json::json!({
754            "tokens": {
755                "account_id": "acct",
756                "access_token": "old",
757                "refresh_token": "rt"
758            }
759        });
760        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
761        let mut tokens = read_tokens(&path).unwrap();
762        refresh_profile_tokens(&path, &mut tokens).unwrap();
763        let updated = fs::read_to_string(&path).unwrap();
764        assert!(updated.contains("acc"));
765    }
766}