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(¬_obj, "[]").unwrap();
697 let err = update_auth_tokens(
698 ¬_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}