Skip to main content

codex_profiles/
auth.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use serde::Deserialize;
4use serde::Serialize;
5use serde_with::{NoneAsEmptyString, serde_as};
6use std::path::Path;
7use std::time::Duration;
8
9use crate::{
10    AUTH_ERR_FILE_NOT_FOUND, AUTH_ERR_INCOMPLETE_ACCOUNT, AUTH_ERR_INCOMPLETE_EMAIL,
11    AUTH_ERR_INCOMPLETE_PLAN, AUTH_ERR_INVALID_JSON, AUTH_ERR_INVALID_JSON_OBJECT,
12    AUTH_ERR_INVALID_JSON_RELOGIN, AUTH_ERR_INVALID_REFRESH_RESPONSE,
13    AUTH_ERR_INVALID_TOKENS_OBJECT, AUTH_ERR_MISSING_TOKENS, AUTH_ERR_PROFILE_MISSING_ACCESS_TOKEN,
14    AUTH_ERR_PROFILE_MISSING_ACCOUNT, AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN,
15    AUTH_ERR_PROFILE_NO_REFRESH_TOKEN, AUTH_ERR_READ, AUTH_ERR_REFRESH_FAILED_OTHER,
16    AUTH_ERR_REFRESH_MISSING_ACCESS_TOKEN, AUTH_ERR_REFRESH_STATE_CHANGED, AUTH_ERR_SERIALIZE_AUTH,
17    AUTH_ERR_UNSUPPORTED_STORE_MODE, AUTH_ERR_WRITE_AUTH, write_atomic,
18};
19
20const API_KEY_PREFIX: &str = "api-key-";
21const API_KEY_LABEL: &str = "Key";
22const API_KEY_SEPARATOR: &str = "~";
23const API_KEY_PREFIX_LEN: usize = 12;
24const API_KEY_SUFFIX_LEN: usize = 16;
25const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
26const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE";
27const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30enum AuthStoreMode {
31    File,
32    Keyring,
33    Auto,
34    Ephemeral,
35}
36
37impl AuthStoreMode {
38    fn as_str(self) -> &'static str {
39        match self {
40            AuthStoreMode::File => "file",
41            AuthStoreMode::Keyring => "keyring",
42            AuthStoreMode::Auto => "auto",
43            AuthStoreMode::Ephemeral => "ephemeral",
44        }
45    }
46}
47
48#[derive(Debug, Deserialize)]
49pub struct AuthFile {
50    #[serde(rename = "OPENAI_API_KEY")]
51    pub openai_api_key: Option<String>,
52    pub tokens: Option<Tokens>,
53    #[serde(default)]
54    pub last_refresh: Option<String>,
55}
56
57#[serde_as]
58#[derive(Clone, Debug, Deserialize)]
59pub struct Tokens {
60    #[serde(default)]
61    #[serde_as(as = "NoneAsEmptyString")]
62    pub account_id: Option<String>,
63    #[serde(default)]
64    #[serde_as(as = "NoneAsEmptyString")]
65    pub id_token: Option<String>,
66    #[serde(default)]
67    #[serde_as(as = "NoneAsEmptyString")]
68    pub access_token: Option<String>,
69    #[serde(default)]
70    #[serde_as(as = "NoneAsEmptyString")]
71    pub refresh_token: Option<String>,
72}
73
74#[serde_as]
75#[derive(Deserialize)]
76struct IdTokenClaims {
77    #[serde(default)]
78    #[serde_as(as = "NoneAsEmptyString")]
79    sub: Option<String>,
80    #[serde(default)]
81    #[serde_as(as = "NoneAsEmptyString")]
82    email: Option<String>,
83    #[serde(default)]
84    #[serde_as(as = "NoneAsEmptyString")]
85    organization_id: Option<String>,
86    #[serde(default)]
87    #[serde_as(as = "NoneAsEmptyString")]
88    project_id: Option<String>,
89    #[serde(rename = "https://api.openai.com/auth")]
90    auth: Option<AuthClaims>,
91}
92
93#[serde_as]
94#[derive(Deserialize)]
95struct AuthClaims {
96    #[serde(default)]
97    #[serde_as(as = "NoneAsEmptyString")]
98    chatgpt_plan_type: Option<String>,
99    #[serde(default)]
100    #[serde_as(as = "NoneAsEmptyString")]
101    chatgpt_user_id: Option<String>,
102    #[serde(default)]
103    #[serde_as(as = "NoneAsEmptyString")]
104    user_id: Option<String>,
105    #[serde(default)]
106    #[serde_as(as = "NoneAsEmptyString")]
107    chatgpt_account_id: Option<String>,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct ProfileIdentityKey {
112    pub principal_id: String,
113    pub workspace_or_org_id: String,
114    pub plan_type: String,
115}
116
117pub fn read_tokens(path: &Path) -> Result<Tokens, String> {
118    let auth = read_auth_file(path)?;
119    if let Some(tokens) = auth.tokens {
120        return Ok(tokens);
121    }
122    if let Some(api_key) = auth.openai_api_key.as_deref() {
123        return Ok(tokens_from_api_key(api_key));
124    }
125    Err(crate::msg1(AUTH_ERR_MISSING_TOKENS, path.display()))
126}
127
128pub fn read_auth_file(path: &Path) -> Result<AuthFile, String> {
129    let store_mode = read_auth_store_mode_for_path(path)?;
130    if store_mode != AuthStoreMode::File {
131        return Err(crate::msg1(
132            AUTH_ERR_UNSUPPORTED_STORE_MODE,
133            store_mode.as_str(),
134        ));
135    }
136
137    let data = std::fs::read_to_string(path).map_err(|err| {
138        if err.kind() == std::io::ErrorKind::NotFound {
139            AUTH_ERR_FILE_NOT_FOUND.to_string()
140        } else {
141            crate::msg2(AUTH_ERR_READ, path.display(), err)
142        }
143    })?;
144    let auth: AuthFile = serde_json::from_str(&data)
145        .map_err(|err| crate::msg2(AUTH_ERR_INVALID_JSON_RELOGIN, path.display(), err))?;
146    Ok(auth)
147}
148
149pub fn read_tokens_opt(path: &Path) -> Option<Tokens> {
150    if !path.is_file() {
151        return None;
152    }
153    read_tokens(path).ok()
154}
155
156pub fn tokens_from_api_key(api_key: &str) -> Tokens {
157    Tokens {
158        account_id: Some(api_key_profile_id(api_key)),
159        id_token: None,
160        access_token: None,
161        refresh_token: None,
162    }
163}
164
165pub fn has_auth(path: &Path) -> bool {
166    read_tokens_opt(path).is_some_and(|tokens| is_profile_ready(&tokens))
167}
168
169pub fn is_profile_ready(tokens: &Tokens) -> bool {
170    if is_api_key_profile(tokens) {
171        return true;
172    }
173    if token_account_id(tokens).is_none() {
174        return false;
175    }
176    if tokens.access_token.as_deref().is_none_or(str::is_empty) {
177        return false;
178    }
179    let (email, plan) = extract_email_and_plan(tokens);
180    email.is_some() && plan.is_some()
181}
182
183pub fn extract_email_and_plan(tokens: &Tokens) -> (Option<String>, Option<String>) {
184    if is_api_key_profile(tokens) {
185        let display = api_key_display_label(tokens).unwrap_or_else(|| API_KEY_LABEL.to_string());
186        return (Some(display), Some(API_KEY_LABEL.to_string()));
187    }
188    let claims = tokens.id_token.as_deref().and_then(decode_id_token_claims);
189    let email = claims.as_ref().and_then(|c| c.email.clone());
190    let plan = claims
191        .and_then(|c| c.auth)
192        .and_then(|auth| auth.chatgpt_plan_type)
193        .map(|plan| format_plan(&plan));
194    (email, plan)
195}
196
197pub fn extract_profile_identity(tokens: &Tokens) -> Option<ProfileIdentityKey> {
198    if is_api_key_profile(tokens) {
199        let principal_id = token_account_id(tokens)?.to_string();
200        return Some(ProfileIdentityKey {
201            workspace_or_org_id: principal_id.clone(),
202            principal_id,
203            plan_type: "key".to_string(),
204        });
205    }
206
207    let claims = tokens.id_token.as_deref().and_then(decode_id_token_claims);
208    let principal_id = claims
209        .as_ref()
210        .and_then(|claims| {
211            claims.auth.as_ref().and_then(|auth| {
212                auth.chatgpt_user_id
213                    .clone()
214                    .or_else(|| auth.user_id.clone())
215            })
216        })
217        .or_else(|| claims.as_ref().and_then(|claims| claims.sub.clone()))
218        .or_else(|| token_account_id(tokens).map(str::to_string))
219        .and_then(|value| normalize_identity_value(&value))?;
220
221    let workspace_or_org_id = claims
222        .as_ref()
223        .and_then(|claims| {
224            claims
225                .auth
226                .as_ref()
227                .and_then(|auth| auth.chatgpt_account_id.clone())
228        })
229        .or_else(|| {
230            claims
231                .as_ref()
232                .and_then(|claims| claims.organization_id.clone())
233        })
234        .or_else(|| claims.as_ref().and_then(|claims| claims.project_id.clone()))
235        .or_else(|| token_account_id(tokens).map(str::to_string))
236        .and_then(|value| normalize_identity_value(&value))
237        .unwrap_or_else(|| "unknown".to_string());
238
239    let plan_type = claims
240        .as_ref()
241        .and_then(|claims| {
242            claims
243                .auth
244                .as_ref()
245                .and_then(|auth| auth.chatgpt_plan_type.clone())
246        })
247        .or_else(|| extract_email_and_plan(tokens).1)
248        .map(|value| normalize_plan_type(&value))
249        .unwrap_or_else(|| "unknown".to_string());
250
251    Some(ProfileIdentityKey {
252        principal_id,
253        workspace_or_org_id,
254        plan_type,
255    })
256}
257
258fn account_id_from_id_token(id_token: &str) -> Option<String> {
259    let claims = decode_id_token_claims(id_token)?;
260    let workspace = claims
261        .auth
262        .and_then(|auth| auth.chatgpt_account_id)
263        .or(claims.organization_id)
264        .or(claims.project_id)?;
265    normalize_identity_value(&workspace)
266}
267
268fn normalize_identity_value(value: &str) -> Option<String> {
269    let trimmed = value.trim();
270    if trimmed.is_empty() {
271        None
272    } else {
273        Some(trimmed.to_string())
274    }
275}
276
277fn normalize_plan_type(value: &str) -> String {
278    let trimmed = value.trim();
279    if trimmed.is_empty() {
280        "unknown".to_string()
281    } else {
282        trimmed.to_ascii_lowercase()
283    }
284}
285
286pub fn require_identity(tokens: &Tokens) -> Result<(String, String, String), String> {
287    let Some(account_id) = token_account_id(tokens) else {
288        return Err(AUTH_ERR_INCOMPLETE_ACCOUNT.to_string());
289    };
290    let (email, plan) = extract_email_and_plan(tokens);
291    let email = email.ok_or_else(|| AUTH_ERR_INCOMPLETE_EMAIL.to_string())?;
292    let plan = plan.ok_or_else(|| AUTH_ERR_INCOMPLETE_PLAN.to_string())?;
293    Ok((account_id.to_string(), email, plan))
294}
295
296pub fn profile_error(
297    tokens: &Tokens,
298    email: Option<&str>,
299    plan: Option<&str>,
300) -> Option<&'static str> {
301    if is_api_key_profile(tokens) {
302        return None;
303    }
304    if email.is_none() || plan.is_none() {
305        return Some(AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN);
306    }
307    if token_account_id(tokens).is_none() {
308        return Some(AUTH_ERR_PROFILE_MISSING_ACCOUNT);
309    }
310    if tokens.access_token.is_none() {
311        return Some(AUTH_ERR_PROFILE_MISSING_ACCESS_TOKEN);
312    }
313    None
314}
315
316pub fn token_account_id(tokens: &Tokens) -> Option<&str> {
317    tokens
318        .account_id
319        .as_deref()
320        .filter(|value| !value.is_empty())
321}
322
323pub fn is_api_key_profile(tokens: &Tokens) -> bool {
324    tokens
325        .account_id
326        .as_deref()
327        .map(|value| value.starts_with(API_KEY_PREFIX))
328        .unwrap_or(false)
329        && tokens.id_token.is_none()
330        && tokens.access_token.is_none()
331        && tokens.refresh_token.is_none()
332}
333
334pub fn format_plan(plan: &str) -> String {
335    let mut out = String::new();
336    for word in plan.split(['_', '-']) {
337        if word.is_empty() {
338            continue;
339        }
340        if !out.is_empty() {
341            out.push(' ');
342        }
343        out.push_str(&title_case(word));
344    }
345    if out.is_empty() {
346        "Unknown".to_string()
347    } else {
348        out
349    }
350}
351
352pub fn is_free_plan(plan: Option<&str>) -> bool {
353    plan.map(|value| value.eq_ignore_ascii_case("free"))
354        .unwrap_or(false)
355}
356
357fn title_case(word: &str) -> String {
358    let mut chars = word.chars();
359    let Some(first) = chars.next() else {
360        return String::new();
361    };
362    let mut out = String::new();
363    out.push(first.to_ascii_uppercase());
364    out.extend(chars.flat_map(|ch| ch.to_lowercase()));
365    out
366}
367
368fn decode_id_token_claims(token: &str) -> Option<IdTokenClaims> {
369    let mut parts = token.split('.');
370    let _header = parts.next()?;
371    let payload = parts.next()?;
372    let _sig = parts.next()?;
373    let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
374    serde_json::from_slice(&decoded).ok()
375}
376
377fn api_key_profile_id(api_key: &str) -> String {
378    let prefix = api_key_prefix(api_key);
379    let mut hash: u64 = 0xcbf29ce484222325;
380    for byte in api_key.as_bytes() {
381        hash ^= u64::from(*byte);
382        hash = hash.wrapping_mul(0x100000001b3);
383    }
384    format!("{API_KEY_PREFIX}{prefix}{API_KEY_SEPARATOR}{hash:016x}")
385}
386
387fn api_key_display_label(tokens: &Tokens) -> Option<String> {
388    let account_id = tokens.account_id.as_deref()?;
389    let rest = account_id.strip_prefix(API_KEY_PREFIX)?;
390    let (prefix, hash) = rest.split_once(API_KEY_SEPARATOR)?;
391    if prefix.is_empty() {
392        return None;
393    }
394    let suffix: String = hash.chars().rev().take(API_KEY_SUFFIX_LEN).collect();
395    let suffix: String = suffix.chars().rev().collect();
396    if suffix.is_empty() {
397        return None;
398    }
399    Some(format!("{API_KEY_SEPARATOR}{suffix}"))
400}
401
402fn api_key_prefix(api_key: &str) -> String {
403    let mut out = String::new();
404    for ch in api_key.chars().take(API_KEY_PREFIX_LEN) {
405        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
406            out.push(ch);
407        } else {
408            out.push('-');
409        }
410    }
411    out
412}
413
414#[derive(Serialize)]
415struct RefreshRequest {
416    client_id: &'static str,
417    grant_type: &'static str,
418    refresh_token: String,
419    scope: &'static str,
420}
421
422#[derive(Clone, Debug, Deserialize)]
423struct RefreshResponse {
424    id_token: Option<String>,
425    access_token: Option<String>,
426    refresh_token: Option<String>,
427}
428
429pub fn refresh_profile_tokens(path: &Path, tokens: &mut Tokens) -> Result<(), String> {
430    let disk_tokens = read_tokens(path)?;
431    if !same_refresh_state(&disk_tokens, tokens) {
432        if same_profile_refresh_target(&disk_tokens, tokens) {
433            *tokens = disk_tokens;
434            return Ok(());
435        }
436        return Err(AUTH_ERR_REFRESH_STATE_CHANGED.to_string());
437    }
438
439    let refresh_token = tokens
440        .refresh_token
441        .as_deref()
442        .filter(|value| !value.is_empty())
443        .ok_or_else(|| AUTH_ERR_PROFILE_NO_REFRESH_TOKEN.to_string())?;
444    let refreshed = refresh_access_token(refresh_token)?;
445    apply_refresh(tokens, &refreshed)?;
446    update_auth_tokens(path, &refreshed)?;
447    Ok(())
448}
449
450fn same_refresh_state(left: &Tokens, right: &Tokens) -> bool {
451    left.account_id == right.account_id
452        && left.id_token == right.id_token
453        && left.access_token == right.access_token
454        && left.refresh_token == right.refresh_token
455}
456
457fn same_profile_refresh_target(left: &Tokens, right: &Tokens) -> bool {
458    if left.account_id != right.account_id {
459        return false;
460    }
461
462    match (
463        extract_profile_identity(left),
464        extract_profile_identity(right),
465    ) {
466        (Some(left), Some(right)) => left == right,
467        _ => false,
468    }
469}
470
471fn read_auth_store_mode_for_path(path: &Path) -> Result<AuthStoreMode, String> {
472    if path.file_name().and_then(|name| name.to_str()) != Some("auth.json") {
473        return Ok(AuthStoreMode::File);
474    }
475    let Some(config_path) = path.parent().map(|dir| dir.join("config.toml")) else {
476        return Ok(AuthStoreMode::File);
477    };
478    let Ok(contents) = std::fs::read_to_string(config_path) else {
479        return Ok(AuthStoreMode::File);
480    };
481    for line in contents.lines() {
482        if let Some(value) = parse_config_value(line, "cli_auth_credentials_store_mode") {
483            return parse_auth_store_mode(&value);
484        }
485    }
486    Ok(AuthStoreMode::File)
487}
488
489fn parse_auth_store_mode(value: &str) -> Result<AuthStoreMode, String> {
490    match value.trim().to_ascii_lowercase().as_str() {
491        "file" => Ok(AuthStoreMode::File),
492        "keyring" => Ok(AuthStoreMode::Keyring),
493        "auto" => Ok(AuthStoreMode::Auto),
494        "ephemeral" => Ok(AuthStoreMode::Ephemeral),
495        other => Err(crate::msg1(AUTH_ERR_UNSUPPORTED_STORE_MODE, other)),
496    }
497}
498
499fn parse_config_value(line: &str, key: &str) -> Option<String> {
500    let line = line.trim();
501    if line.is_empty() || line.starts_with('#') {
502        return None;
503    }
504    let (config_key, raw_value) = line.split_once('=')?;
505    if config_key.trim() != key {
506        return None;
507    }
508    let value = strip_inline_comment(raw_value).trim();
509    if value.is_empty() {
510        return None;
511    }
512    let value = value.trim_matches('"').trim_matches('\'').trim();
513    if value.is_empty() {
514        return None;
515    }
516    Some(value.to_string())
517}
518
519fn strip_inline_comment(value: &str) -> &str {
520    let mut in_single = false;
521    let mut in_double = false;
522    let mut escape = false;
523    for (idx, ch) in value.char_indices() {
524        match ch {
525            '"' if !in_single && !escape => in_double = !in_double,
526            '\'' if !in_double => in_single = !in_single,
527            '#' if !in_single && !in_double => return value[..idx].trim_end(),
528            _ => {}
529        }
530        escape = in_double && ch == '\\' && !escape;
531        if ch != '\\' {
532            escape = false;
533        }
534    }
535    value.trim_end()
536}
537
538fn refresh_access_token(refresh_token: &str) -> Result<RefreshResponse, String> {
539    let request = RefreshRequest {
540        client_id: CLIENT_ID,
541        grant_type: "refresh_token",
542        refresh_token: refresh_token.to_string(),
543        scope: "openid profile email",
544    };
545    let endpoint = refresh_token_url();
546    let config = ureq::Agent::config_builder()
547        .timeout_global(Some(Duration::from_secs(5)))
548        .http_status_as_error(false)
549        .build();
550    let agent: ureq::Agent = config.into();
551    let response = agent
552        .post(&endpoint)
553        .header("Content-Type", "application/json")
554        .send_json(&request)
555        .map_err(|other| crate::msg1(AUTH_ERR_REFRESH_FAILED_OTHER, other))?;
556
557    if !response.status().is_success() {
558        return Err(
559            crate::UnexpectedHttpError::from_ureq_response(response, Some(&endpoint))
560                .plain_message(),
561        );
562    }
563
564    response
565        .into_body()
566        .read_json::<RefreshResponse>()
567        .map_err(|err| crate::msg1(AUTH_ERR_INVALID_REFRESH_RESPONSE, err))
568}
569
570fn apply_refresh(tokens: &mut Tokens, refreshed: &RefreshResponse) -> Result<(), String> {
571    let Some(access_token) = refreshed.access_token.as_ref() else {
572        return Err(AUTH_ERR_REFRESH_MISSING_ACCESS_TOKEN.to_string());
573    };
574    tokens.access_token = Some(access_token.clone());
575    if let Some(id_token) = refreshed.id_token.as_ref() {
576        tokens.id_token = Some(id_token.clone());
577        if let Some(account_id) = account_id_from_id_token(id_token) {
578            tokens.account_id = Some(account_id);
579        }
580    }
581    if let Some(refresh_token) = refreshed.refresh_token.as_ref() {
582        tokens.refresh_token = Some(refresh_token.clone());
583    }
584    Ok(())
585}
586
587fn update_auth_tokens(path: &Path, refreshed: &RefreshResponse) -> Result<(), String> {
588    let contents = std::fs::read_to_string(path)
589        .map_err(|err| crate::msg2(AUTH_ERR_READ, path.display(), err))?;
590    let mut value: serde_json::Value = serde_json::from_str(&contents)
591        .map_err(|err| crate::msg2(AUTH_ERR_INVALID_JSON, path.display(), err))?;
592    let Some(root) = value.as_object_mut() else {
593        return Err(crate::msg1(AUTH_ERR_INVALID_JSON_OBJECT, path.display()));
594    };
595    let tokens = root
596        .entry("tokens")
597        .or_insert_with(|| serde_json::json!({}));
598    let Some(tokens_map) = tokens.as_object_mut() else {
599        return Err(crate::msg1(AUTH_ERR_INVALID_TOKENS_OBJECT, path.display()));
600    };
601    if let Some(id_token) = refreshed.id_token.as_ref() {
602        tokens_map.insert(
603            "id_token".to_string(),
604            serde_json::Value::String(id_token.clone()),
605        );
606        if let Some(account_id) = account_id_from_id_token(id_token) {
607            tokens_map.insert(
608                "account_id".to_string(),
609                serde_json::Value::String(account_id),
610            );
611        }
612    }
613    if let Some(access_token) = refreshed.access_token.as_ref() {
614        tokens_map.insert(
615            "access_token".to_string(),
616            serde_json::Value::String(access_token.clone()),
617        );
618    }
619    if let Some(refresh_token) = refreshed.refresh_token.as_ref() {
620        tokens_map.insert(
621            "refresh_token".to_string(),
622            serde_json::Value::String(refresh_token.clone()),
623        );
624    }
625    let json = serde_json::to_string_pretty(&value)
626        .map_err(|err| crate::msg1(AUTH_ERR_SERIALIZE_AUTH, err))?;
627    write_atomic(path, format!("{json}\n").as_bytes())
628        .map_err(|err| crate::msg2(AUTH_ERR_WRITE_AUTH, path.display(), err))
629}
630
631fn refresh_token_url() -> String {
632    std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR)
633        .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string())
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::test_utils::{
640        ENV_MUTEX, build_id_token, http_ok_response, set_env_guard, spawn_server,
641    };
642    use std::fs;
643
644    fn build_id_token_payload(payload: &str) -> String {
645        let header = r#"{"alg":"none","typ":"JWT"}"#;
646        let header = URL_SAFE_NO_PAD.encode(header);
647        let payload = URL_SAFE_NO_PAD.encode(payload);
648        format!("{header}.{payload}.")
649    }
650
651    #[test]
652    fn read_auth_file_errors() {
653        let dir = tempfile::tempdir().expect("tempdir");
654        let missing = dir.path().join("missing.json");
655        let err = read_auth_file(&missing).unwrap_err();
656        assert!(err.contains("Auth file not found"));
657
658        let bad = dir.path().join("bad.json");
659        fs::write(&bad, "{oops").expect("write");
660        let err = read_auth_file(&bad).unwrap_err();
661        assert!(err.contains("Invalid JSON"));
662    }
663
664    #[test]
665    fn read_tokens_paths() {
666        let dir = tempfile::tempdir().expect("tempdir");
667        let path = dir.path().join("auth.json");
668        let id_token = build_id_token("me@example.com", "pro");
669        let value = serde_json::json!({
670            "tokens": {"account_id": "acct", "id_token": id_token, "access_token": "acc"}
671        });
672        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
673        let tokens = read_tokens(&path).unwrap();
674        assert_eq!(token_account_id(&tokens), Some("acct"));
675
676        let api_path = dir.path().join("auth_api.json");
677        let value = serde_json::json!({"OPENAI_API_KEY": "sk-test"});
678        fs::write(&api_path, serde_json::to_string(&value).unwrap()).unwrap();
679        let tokens = read_tokens(&api_path).unwrap();
680        assert!(is_api_key_profile(&tokens));
681
682        let empty_path = dir.path().join("empty.json");
683        fs::write(&empty_path, "{}").unwrap();
684        let err = read_tokens(&empty_path).unwrap_err();
685        assert!(err.contains("Missing tokens"));
686    }
687
688    #[test]
689    fn read_tokens_refuses_non_file_store_modes() {
690        let dir = tempfile::tempdir().expect("tempdir");
691        let auth_path = dir.path().join("auth.json");
692        let auth = serde_json::json!({
693            "tokens": {"account_id": "acct", "access_token": "acc"}
694        });
695        fs::write(&auth_path, serde_json::to_string(&auth).unwrap()).unwrap();
696
697        for mode in ["keyring", "auto", "ephemeral"] {
698            fs::write(
699                dir.path().join("config.toml"),
700                format!("cli_auth_credentials_store_mode = \"{mode}\"\n"),
701            )
702            .unwrap();
703            let err = read_tokens(&auth_path).unwrap_err();
704            assert!(err.contains(mode));
705            assert!(err.contains("file-backed auth"));
706        }
707    }
708
709    #[test]
710    fn read_tokens_allows_file_store_mode() {
711        let dir = tempfile::tempdir().expect("tempdir");
712        let auth_path = dir.path().join("auth.json");
713        let auth = serde_json::json!({
714            "tokens": {"account_id": "acct", "access_token": "acc"}
715        });
716        fs::write(&auth_path, serde_json::to_string(&auth).unwrap()).unwrap();
717        fs::write(
718            dir.path().join("config.toml"),
719            "cli_auth_credentials_store_mode = \"file\"\n",
720        )
721        .unwrap();
722
723        let tokens = read_tokens(&auth_path).unwrap();
724        assert_eq!(tokens.account_id.as_deref(), Some("acct"));
725        assert_eq!(tokens.access_token.as_deref(), Some("acc"));
726    }
727
728    #[test]
729    fn read_tokens_opt_handles_missing() {
730        let dir = tempfile::tempdir().expect("tempdir");
731        let path = dir.path().join("none.json");
732        assert!(read_tokens_opt(&path).is_none());
733    }
734
735    #[test]
736    fn api_key_helpers() {
737        let tokens = tokens_from_api_key("sk-test-1234");
738        assert!(is_api_key_profile(&tokens));
739        let display = api_key_display_label(&tokens).unwrap();
740        assert!(display.starts_with(API_KEY_SEPARATOR));
741        assert_eq!(api_key_prefix("abc$123"), "abc-123".to_string());
742    }
743
744    #[test]
745    fn format_plan_and_free() {
746        assert_eq!(format_plan("chatgpt_plus"), "Chatgpt Plus");
747        assert_eq!(format_plan(""), "Unknown");
748        assert!(is_free_plan(Some("free")));
749        assert!(!is_free_plan(Some("pro")));
750    }
751
752    #[test]
753    fn extract_email_and_plan_paths() {
754        let id_token = build_id_token("me@example.com", "pro");
755        let tokens = Tokens {
756            account_id: Some("acct".to_string()),
757            id_token: Some(id_token),
758            access_token: Some("acc".to_string()),
759            refresh_token: None,
760        };
761        let (email, plan) = extract_email_and_plan(&tokens);
762        assert_eq!(email.as_deref(), Some("me@example.com"));
763        assert_eq!(plan.as_deref(), Some("Pro"));
764
765        let api_tokens = tokens_from_api_key("sk-test");
766        let (email, plan) = extract_email_and_plan(&api_tokens);
767        assert_eq!(plan.as_deref(), Some(API_KEY_LABEL));
768        assert!(email.is_some());
769    }
770
771    #[test]
772    fn extract_profile_identity_prefers_user_and_workspace_claims() {
773        let id_token = build_id_token_payload(
774            "{\"email\":\"me@example.com\",\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"team\",\"chatgpt_user_id\":\"user-123\",\"chatgpt_account_id\":\"ws-123\"}}",
775        );
776        let tokens = Tokens {
777            account_id: Some("acct-fallback".to_string()),
778            id_token: Some(id_token),
779            access_token: Some("acc".to_string()),
780            refresh_token: Some("ref".to_string()),
781        };
782        let identity = extract_profile_identity(&tokens).unwrap();
783        assert_eq!(identity.principal_id, "user-123");
784        assert_eq!(identity.workspace_or_org_id, "ws-123");
785        assert_eq!(identity.plan_type, "team");
786    }
787
788    #[test]
789    fn extract_profile_identity_falls_back_to_sub_and_org() {
790        let id_token = build_id_token_payload(
791            "{\"sub\":\"sub-1\",\"organization_id\":\"org-1\",\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"Pro\"}}",
792        );
793        let tokens = Tokens {
794            account_id: None,
795            id_token: Some(id_token),
796            access_token: Some("acc".to_string()),
797            refresh_token: Some("ref".to_string()),
798        };
799        let identity = extract_profile_identity(&tokens).unwrap();
800        assert_eq!(identity.principal_id, "sub-1");
801        assert_eq!(identity.workspace_or_org_id, "org-1");
802        assert_eq!(identity.plan_type, "pro");
803    }
804
805    #[test]
806    fn extract_profile_identity_uses_account_fallback_when_claims_missing() {
807        let tokens = Tokens {
808            account_id: Some("acct-only".to_string()),
809            id_token: Some(build_id_token("me@example.com", "pro")),
810            access_token: Some("acc".to_string()),
811            refresh_token: Some("ref".to_string()),
812        };
813        let identity = extract_profile_identity(&tokens).unwrap();
814        assert_eq!(identity.principal_id, "acct-only");
815        assert_eq!(identity.workspace_or_org_id, "acct-only");
816        assert_eq!(identity.plan_type, "pro");
817    }
818
819    #[test]
820    fn require_identity_errors() {
821        let tokens = Tokens {
822            account_id: None,
823            id_token: None,
824            access_token: None,
825            refresh_token: None,
826        };
827        let err = require_identity(&tokens).unwrap_err();
828        assert!(err.contains("missing account"));
829    }
830
831    #[test]
832    fn profile_error_variants() {
833        let tokens = Tokens {
834            account_id: Some("acct".to_string()),
835            id_token: None,
836            access_token: None,
837            refresh_token: None,
838        };
839        assert_eq!(
840            profile_error(&tokens, Some("e"), Some("p")),
841            Some(crate::AUTH_ERR_PROFILE_MISSING_ACCESS_TOKEN)
842        );
843
844        let api_tokens = tokens_from_api_key("sk-test");
845        assert!(profile_error(&api_tokens, None, None).is_none());
846
847        let tokens = Tokens {
848            account_id: None,
849            id_token: Some(build_id_token("me@example.com", "pro")),
850            access_token: Some("acc".to_string()),
851            refresh_token: None,
852        };
853        assert_eq!(
854            profile_error(&tokens, Some("me@example.com"), Some("Pro")),
855            Some(crate::AUTH_ERR_PROFILE_MISSING_ACCOUNT)
856        );
857
858        let id_token = build_id_token_payload(
859            "{\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\"}}",
860        );
861        let tokens = Tokens {
862            account_id: Some("acct".to_string()),
863            id_token: Some(id_token),
864            access_token: Some("acc".to_string()),
865            refresh_token: None,
866        };
867        assert_eq!(
868            profile_error(&tokens, None, Some("Pro")),
869            Some(crate::AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN)
870        );
871    }
872
873    #[test]
874    fn is_profile_ready_variants() {
875        let api_tokens = tokens_from_api_key("sk-test");
876        assert!(is_profile_ready(&api_tokens));
877
878        let tokens = Tokens {
879            account_id: None,
880            id_token: Some(build_id_token("me@example.com", "pro")),
881            access_token: Some("acc".to_string()),
882            refresh_token: None,
883        };
884        assert!(!is_profile_ready(&tokens));
885
886        let tokens = Tokens {
887            account_id: Some("acct".to_string()),
888            id_token: Some(build_id_token("me@example.com", "pro")),
889            access_token: None,
890            refresh_token: None,
891        };
892        assert!(!is_profile_ready(&tokens));
893
894        let id_token = build_id_token_payload("{\"email\":\"me@example.com\"}");
895        let tokens = Tokens {
896            account_id: Some("acct".to_string()),
897            id_token: Some(id_token),
898            access_token: Some("acc".to_string()),
899            refresh_token: None,
900        };
901        assert!(!is_profile_ready(&tokens));
902    }
903
904    #[test]
905    fn require_identity_missing_fields() {
906        let id_token = build_id_token_payload("{\"email\":\"me@example.com\"}");
907        let tokens = Tokens {
908            account_id: Some("acct".to_string()),
909            id_token: Some(id_token),
910            access_token: Some("acc".to_string()),
911            refresh_token: None,
912        };
913        let err = require_identity(&tokens).unwrap_err();
914        assert!(err.contains("missing plan"));
915
916        let id_token = build_id_token_payload(
917            "{\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\"}}",
918        );
919        let tokens = Tokens {
920            account_id: Some("acct".to_string()),
921            id_token: Some(id_token),
922            access_token: Some("acc".to_string()),
923            refresh_token: None,
924        };
925        let err = require_identity(&tokens).unwrap_err();
926        assert!(err.contains("missing email"));
927
928        let tokens = Tokens {
929            account_id: Some("acct".to_string()),
930            id_token: Some(build_id_token("me@example.com", "pro")),
931            access_token: Some("acc".to_string()),
932            refresh_token: None,
933        };
934        assert!(require_identity(&tokens).is_ok());
935    }
936
937    #[test]
938    fn refresh_profile_tokens_missing_refresh() {
939        let dir = tempfile::tempdir().expect("tempdir");
940        let path = dir.path().join("auth.json");
941        let value = serde_json::json!({
942            "tokens": {
943                "account_id": "acct",
944                "access_token": "acc"
945            }
946        });
947        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
948        let mut tokens = read_tokens(&path).unwrap();
949        let err = refresh_profile_tokens(&path, &mut tokens).unwrap_err();
950        assert!(err.contains("refresh token"));
951    }
952
953    #[test]
954    fn refresh_profile_tokens_refuses_disk_mismatch() {
955        let _guard = ENV_MUTEX.lock().unwrap();
956        let _env = set_env_guard(
957            REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
958            Some("http://127.0.0.1:9"),
959        );
960
961        let dir = tempfile::tempdir().expect("tempdir");
962        let path = dir.path().join("auth.json");
963        let initial = serde_json::json!({
964            "tokens": {
965                "account_id": "acct",
966                "access_token": "old-access",
967                "refresh_token": "rt"
968            }
969        });
970        fs::write(&path, serde_json::to_string(&initial).unwrap()).unwrap();
971        let mut tokens = read_tokens(&path).unwrap();
972
973        let drifted = serde_json::json!({
974            "tokens": {
975                "account_id": "other-account",
976                "access_token": "disk-access",
977                "refresh_token": "disk-refresh"
978            }
979        });
980        fs::write(&path, serde_json::to_string(&drifted).unwrap()).unwrap();
981
982        let err = refresh_profile_tokens(&path, &mut tokens).unwrap_err();
983        assert!(err.contains("changed on disk"));
984        assert_eq!(tokens.account_id.as_deref(), Some("acct"));
985        assert_eq!(tokens.access_token.as_deref(), Some("old-access"));
986        assert_eq!(tokens.refresh_token.as_deref(), Some("rt"));
987
988        let stored = fs::read_to_string(&path).unwrap();
989        assert!(stored.contains("other-account"));
990        assert!(stored.contains("disk-access"));
991        assert!(stored.contains("disk-refresh"));
992    }
993
994    #[test]
995    fn refresh_profile_tokens_reuses_rotated_disk_tokens_for_same_profile() {
996        let dir = tempfile::tempdir().expect("tempdir");
997        let path = dir.path().join("auth.json");
998        let initial = serde_json::json!({
999            "tokens": {
1000                "account_id": "acct",
1001                "access_token": "old-access",
1002                "refresh_token": "old-refresh",
1003                "id_token": build_id_token_payload(
1004                    "{\"sub\":\"user-1\",\"email\":\"same@example.com\",\"organization_id\":\"org-1\",\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\",\"chatgpt_account_id\":\"acct\"}}"
1005                )
1006            }
1007        });
1008        fs::write(&path, serde_json::to_string(&initial).unwrap()).unwrap();
1009        let mut tokens = read_tokens(&path).unwrap();
1010
1011        let rotated = serde_json::json!({
1012            "tokens": {
1013                "account_id": "acct",
1014                "access_token": "new-access",
1015                "refresh_token": "new-refresh",
1016                "id_token": build_id_token_payload(
1017                    "{\"sub\":\"user-1\",\"email\":\"same@example.com\",\"organization_id\":\"org-1\",\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\",\"chatgpt_account_id\":\"acct\"}}"
1018                )
1019            }
1020        });
1021        fs::write(&path, serde_json::to_string(&rotated).unwrap()).unwrap();
1022
1023        refresh_profile_tokens(&path, &mut tokens).unwrap();
1024        assert_eq!(tokens.account_id.as_deref(), Some("acct"));
1025        assert_eq!(tokens.access_token.as_deref(), Some("new-access"));
1026        assert_eq!(tokens.refresh_token.as_deref(), Some("new-refresh"));
1027    }
1028
1029    #[test]
1030    fn refresh_profile_tokens_rejects_rotated_disk_tokens_when_identity_is_missing() {
1031        let dir = tempfile::tempdir().expect("tempdir");
1032        let path = dir.path().join("auth.json");
1033        let initial = serde_json::json!({
1034            "tokens": {
1035                "account_id": "   ",
1036                "access_token": "old-access",
1037                "refresh_token": "old-refresh"
1038            }
1039        });
1040        fs::write(&path, serde_json::to_string(&initial).unwrap()).unwrap();
1041        let mut tokens = read_tokens(&path).unwrap();
1042
1043        let rotated = serde_json::json!({
1044            "tokens": {
1045                "account_id": "   ",
1046                "access_token": "new-access",
1047                "refresh_token": "new-refresh"
1048            }
1049        });
1050        fs::write(&path, serde_json::to_string(&rotated).unwrap()).unwrap();
1051
1052        let err = refresh_profile_tokens(&path, &mut tokens).unwrap_err();
1053        assert!(err.contains("changed on disk"));
1054        assert_eq!(tokens.account_id.as_deref(), Some("   "));
1055        assert_eq!(tokens.access_token.as_deref(), Some("old-access"));
1056        assert_eq!(tokens.refresh_token.as_deref(), Some("old-refresh"));
1057    }
1058
1059    #[test]
1060    fn set_env_clears_value() {
1061        let _guard = ENV_MUTEX.lock().unwrap();
1062        {
1063            let _env = set_env_guard("CODEX_PROFILES_TEST_ENV", Some("value"));
1064        }
1065        {
1066            let _env = set_env_guard("CODEX_PROFILES_TEST_ENV", None);
1067        }
1068    }
1069
1070    #[test]
1071    fn decode_id_token_claims_handles_invalid() {
1072        assert!(decode_id_token_claims("not-a-jwt").is_none());
1073        let bad = "a.b.c";
1074        assert!(decode_id_token_claims(bad).is_none());
1075        let good = build_id_token("me@example.com", "pro");
1076        assert!(decode_id_token_claims(&good).is_some());
1077    }
1078
1079    #[test]
1080    fn apply_refresh_requires_access_token() {
1081        let mut tokens = Tokens {
1082            account_id: Some("acct".to_string()),
1083            id_token: None,
1084            access_token: None,
1085            refresh_token: None,
1086        };
1087        let refreshed = RefreshResponse {
1088            id_token: None,
1089            access_token: None,
1090            refresh_token: None,
1091        };
1092        let err = apply_refresh(&mut tokens, &refreshed).unwrap_err();
1093        assert!(err.contains("missing an access token"));
1094    }
1095
1096    #[test]
1097    fn account_id_from_id_token_prefers_workspace_claim() {
1098        let id_token = build_id_token_payload(
1099            "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"ws-123\"},\"organization_id\":\"org-123\"}",
1100        );
1101        assert_eq!(
1102            account_id_from_id_token(&id_token).as_deref(),
1103            Some("ws-123")
1104        );
1105    }
1106
1107    #[test]
1108    fn apply_refresh_updates_account_id_from_refreshed_id_token() {
1109        let mut tokens = Tokens {
1110            account_id: Some("acct-old".to_string()),
1111            id_token: Some(build_id_token("me@example.com", "pro")),
1112            access_token: Some("old-access".to_string()),
1113            refresh_token: Some("old-refresh".to_string()),
1114        };
1115        let refreshed_id_token = build_id_token_payload(
1116            "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"ws-new\",\"chatgpt_plan_type\":\"pro\"}}",
1117        );
1118        let refreshed = RefreshResponse {
1119            id_token: Some(refreshed_id_token),
1120            access_token: Some("new-access".to_string()),
1121            refresh_token: Some("new-refresh".to_string()),
1122        };
1123        apply_refresh(&mut tokens, &refreshed).unwrap();
1124        assert_eq!(tokens.account_id.as_deref(), Some("ws-new"));
1125        assert_eq!(tokens.access_token.as_deref(), Some("new-access"));
1126        assert_eq!(tokens.refresh_token.as_deref(), Some("new-refresh"));
1127    }
1128
1129    #[test]
1130    fn update_auth_tokens_errors() {
1131        let dir = tempfile::tempdir().expect("tempdir");
1132        let missing = dir.path().join("missing.json");
1133        let err = update_auth_tokens(
1134            &missing,
1135            &RefreshResponse {
1136                id_token: None,
1137                access_token: None,
1138                refresh_token: None,
1139            },
1140        )
1141        .unwrap_err();
1142        assert!(err.contains("Could not read"));
1143
1144        let bad = dir.path().join("bad.json");
1145        fs::write(&bad, "{oops").unwrap();
1146        let err = update_auth_tokens(
1147            &bad,
1148            &RefreshResponse {
1149                id_token: None,
1150                access_token: None,
1151                refresh_token: None,
1152            },
1153        )
1154        .unwrap_err();
1155        assert!(err.contains("Invalid JSON"));
1156
1157        let not_obj = dir.path().join("not_obj.json");
1158        fs::write(&not_obj, "[]").unwrap();
1159        let err = update_auth_tokens(
1160            &not_obj,
1161            &RefreshResponse {
1162                id_token: None,
1163                access_token: None,
1164                refresh_token: None,
1165            },
1166        )
1167        .unwrap_err();
1168        assert!(err.contains("expected object"));
1169
1170        let tokens_not_obj = dir.path().join("tokens_not_obj.json");
1171        fs::write(&tokens_not_obj, "{\"tokens\": []}").unwrap();
1172        let err = update_auth_tokens(
1173            &tokens_not_obj,
1174            &RefreshResponse {
1175                id_token: None,
1176                access_token: None,
1177                refresh_token: None,
1178            },
1179        )
1180        .unwrap_err();
1181        assert!(err.contains("Invalid tokens"));
1182    }
1183
1184    #[test]
1185    fn update_auth_tokens_writes_account_id_from_refreshed_id_token() {
1186        let dir = tempfile::tempdir().expect("tempdir");
1187        let path = dir.path().join("auth.json");
1188        let value = serde_json::json!({
1189            "tokens": {
1190                "account_id": "acct-old",
1191                "access_token": "old-access",
1192            }
1193        });
1194        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
1195        let refreshed_id_token = build_id_token_payload(
1196            "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"ws-fresh\",\"chatgpt_plan_type\":\"pro\"}}",
1197        );
1198        update_auth_tokens(
1199            &path,
1200            &RefreshResponse {
1201                id_token: Some(refreshed_id_token),
1202                access_token: Some("new-access".to_string()),
1203                refresh_token: Some("new-refresh".to_string()),
1204            },
1205        )
1206        .unwrap();
1207        let updated = fs::read_to_string(&path).unwrap();
1208        assert!(updated.contains("\"account_id\": \"ws-fresh\""));
1209        assert!(updated.contains("\"access_token\": \"new-access\""));
1210    }
1211
1212    #[test]
1213    fn refresh_access_token_success_and_status() {
1214        let _guard = ENV_MUTEX.lock().unwrap();
1215        let ok_body = "{\"access_token\":\"acc\",\"id_token\":\"id\",\"refresh_token\":\"ref\"}";
1216        let ok_resp = http_ok_response(ok_body, "application/json");
1217        let ok_url = spawn_server(ok_resp);
1218        {
1219            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&ok_url));
1220            let refreshed = refresh_access_token("token").unwrap();
1221            assert_eq!(refreshed.access_token.as_deref(), Some("acc"));
1222        }
1223
1224        let err_resp = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n".to_string();
1225        let err_url = spawn_server(err_resp);
1226        {
1227            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&err_url));
1228            let err = refresh_access_token("token").unwrap_err();
1229            assert!(err.contains("Unknown error\nunexpected status 401 Unauthorized"));
1230            assert!(err.contains("\nURL: http://"));
1231        }
1232
1233        let expired_body = r#"{"error":{"code":"refresh_token_expired"}}"#;
1234        let expired_resp = format!(
1235            "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1236            expired_body.len(),
1237            expired_body
1238        );
1239        let expired_url = spawn_server(expired_resp);
1240        {
1241            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&expired_url));
1242            let err = refresh_access_token("token").unwrap_err();
1243            assert!(err.contains("unexpected status 401 Unauthorized"));
1244            assert!(err.contains("refresh_token_expired"));
1245        }
1246
1247        let reused_body = r#"{"error":{"code":"refresh_token_reused"}}"#;
1248        let reused_resp = format!(
1249            "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1250            reused_body.len(),
1251            reused_body
1252        );
1253        let reused_url = spawn_server(reused_resp);
1254        {
1255            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&reused_url));
1256            let err = refresh_access_token("token").unwrap_err();
1257            assert!(err.contains("unexpected status 401 Unauthorized"));
1258            assert!(err.contains("refresh_token_reused"));
1259        }
1260
1261        let revoked_body = r#"{"error":{"code":"refresh_token_invalidated"}}"#;
1262        let revoked_resp = format!(
1263            "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1264            revoked_body.len(),
1265            revoked_body
1266        );
1267        let revoked_url = spawn_server(revoked_resp);
1268        {
1269            let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&revoked_url));
1270            let err = refresh_access_token("token").unwrap_err();
1271            assert!(err.contains("unexpected status 401 Unauthorized"));
1272            assert!(err.contains("refresh_token_invalidated"));
1273        }
1274    }
1275
1276    #[test]
1277    fn refresh_profile_tokens_updates_file() {
1278        let _guard = ENV_MUTEX.lock().unwrap();
1279        let ok_body = "{\"access_token\":\"acc\",\"id_token\":\"id\",\"refresh_token\":\"ref\"}";
1280        let ok_resp = http_ok_response(ok_body, "application/json");
1281        let ok_url = spawn_server(ok_resp);
1282        let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&ok_url));
1283
1284        let dir = tempfile::tempdir().expect("tempdir");
1285        let path = dir.path().join("auth.json");
1286        let value = serde_json::json!({
1287            "tokens": {
1288                "account_id": "acct",
1289                "access_token": "old",
1290                "refresh_token": "rt"
1291            }
1292        });
1293        fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
1294        let mut tokens = read_tokens(&path).unwrap();
1295        refresh_profile_tokens(&path, &mut tokens).unwrap();
1296        let updated = fs::read_to_string(&path).unwrap();
1297        assert!(updated.contains("acc"));
1298    }
1299}