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