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::{
9 AUTH_ERR_FILE_NOT_FOUND, AUTH_ERR_INCOMPLETE_ACCOUNT, AUTH_ERR_INCOMPLETE_EMAIL,
10 AUTH_ERR_INCOMPLETE_PLAN, AUTH_ERR_INVALID_JSON, AUTH_ERR_INVALID_JSON_OBJECT,
11 AUTH_ERR_INVALID_JSON_RELOGIN, AUTH_ERR_INVALID_REFRESH_RESPONSE,
12 AUTH_ERR_INVALID_TOKENS_OBJECT, AUTH_ERR_MISSING_TOKENS, AUTH_ERR_PROFILE_MISSING_ACCESS_TOKEN,
13 AUTH_ERR_PROFILE_MISSING_ACCOUNT, AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN,
14 AUTH_ERR_PROFILE_NO_REFRESH_TOKEN, AUTH_ERR_READ, AUTH_ERR_REFRESH_EXPIRED,
15 AUTH_ERR_REFRESH_FAILED_CODE, AUTH_ERR_REFRESH_FAILED_OTHER,
16 AUTH_ERR_REFRESH_MISSING_ACCESS_TOKEN, AUTH_ERR_REFRESH_REUSED, AUTH_ERR_REFRESH_REVOKED,
17 AUTH_ERR_REFRESH_UNKNOWN_401, AUTH_ERR_SERIALIZE_AUTH, AUTH_ERR_WRITE_AUTH,
18 AUTH_REFRESH_401_TITLE, AUTH_RELOGIN_AND_SAVE, UI_ERROR_TWO_LINE, write_atomic,
19};
20
21const API_KEY_PREFIX: &str = "api-key-";
22const API_KEY_LABEL: &str = "Key";
23const API_KEY_SEPARATOR: &str = "~";
24const API_KEY_PREFIX_LEN: usize = 12;
25const API_KEY_SUFFIX_LEN: usize = 16;
26const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
27const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE";
28const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
29
30#[derive(Debug, Deserialize)]
31pub struct AuthFile {
32 #[serde(rename = "OPENAI_API_KEY")]
33 pub openai_api_key: Option<String>,
34 pub tokens: Option<Tokens>,
35 #[serde(default)]
36 pub last_refresh: Option<String>,
37}
38
39#[serde_as]
40#[derive(Clone, Debug, Deserialize)]
41pub struct Tokens {
42 #[serde(default)]
43 #[serde_as(as = "NoneAsEmptyString")]
44 pub account_id: Option<String>,
45 #[serde(default)]
46 #[serde_as(as = "NoneAsEmptyString")]
47 pub id_token: Option<String>,
48 #[serde(default)]
49 #[serde_as(as = "NoneAsEmptyString")]
50 pub access_token: Option<String>,
51 #[serde(default)]
52 #[serde_as(as = "NoneAsEmptyString")]
53 pub refresh_token: Option<String>,
54}
55
56#[serde_as]
57#[derive(Deserialize)]
58struct IdTokenClaims {
59 #[serde(default)]
60 #[serde_as(as = "NoneAsEmptyString")]
61 sub: Option<String>,
62 #[serde(default)]
63 #[serde_as(as = "NoneAsEmptyString")]
64 email: Option<String>,
65 #[serde(default)]
66 #[serde_as(as = "NoneAsEmptyString")]
67 organization_id: Option<String>,
68 #[serde(default)]
69 #[serde_as(as = "NoneAsEmptyString")]
70 project_id: Option<String>,
71 #[serde(rename = "https://api.openai.com/auth")]
72 auth: Option<AuthClaims>,
73}
74
75#[serde_as]
76#[derive(Deserialize)]
77struct AuthClaims {
78 #[serde(default)]
79 #[serde_as(as = "NoneAsEmptyString")]
80 chatgpt_plan_type: Option<String>,
81 #[serde(default)]
82 #[serde_as(as = "NoneAsEmptyString")]
83 chatgpt_user_id: Option<String>,
84 #[serde(default)]
85 #[serde_as(as = "NoneAsEmptyString")]
86 user_id: Option<String>,
87 #[serde(default)]
88 #[serde_as(as = "NoneAsEmptyString")]
89 chatgpt_account_id: Option<String>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct ProfileIdentityKey {
94 pub principal_id: String,
95 pub workspace_or_org_id: String,
96 pub plan_type: String,
97}
98
99pub fn read_tokens(path: &Path) -> Result<Tokens, String> {
100 let auth = read_auth_file(path)?;
101 if let Some(tokens) = auth.tokens {
102 return Ok(tokens);
103 }
104 if let Some(api_key) = auth.openai_api_key.as_deref() {
105 return Ok(tokens_from_api_key(api_key));
106 }
107 Err(crate::msg1(AUTH_ERR_MISSING_TOKENS, path.display()))
108}
109
110pub fn read_auth_file(path: &Path) -> Result<AuthFile, String> {
111 let data = std::fs::read_to_string(path).map_err(|err| {
112 if err.kind() == std::io::ErrorKind::NotFound {
113 AUTH_ERR_FILE_NOT_FOUND.to_string()
114 } else {
115 crate::msg2(AUTH_ERR_READ, path.display(), err)
116 }
117 })?;
118 let auth: AuthFile = serde_json::from_str(&data)
119 .map_err(|err| crate::msg2(AUTH_ERR_INVALID_JSON_RELOGIN, path.display(), err))?;
120 Ok(auth)
121}
122
123pub fn read_tokens_opt(path: &Path) -> Option<Tokens> {
124 if !path.is_file() {
125 return None;
126 }
127 read_tokens(path).ok()
128}
129
130pub fn tokens_from_api_key(api_key: &str) -> Tokens {
131 Tokens {
132 account_id: Some(api_key_profile_id(api_key)),
133 id_token: None,
134 access_token: None,
135 refresh_token: None,
136 }
137}
138
139pub fn has_auth(path: &Path) -> bool {
140 read_tokens_opt(path).is_some_and(|tokens| is_profile_ready(&tokens))
141}
142
143pub fn is_profile_ready(tokens: &Tokens) -> bool {
144 if is_api_key_profile(tokens) {
145 return true;
146 }
147 if token_account_id(tokens).is_none() {
148 return false;
149 }
150 if !tokens
151 .access_token
152 .as_deref()
153 .map(|value| !value.is_empty())
154 .unwrap_or(false)
155 {
156 return false;
157 }
158 let (email, plan) = extract_email_and_plan(tokens);
159 email.is_some() && plan.is_some()
160}
161
162pub fn extract_email_and_plan(tokens: &Tokens) -> (Option<String>, Option<String>) {
163 if is_api_key_profile(tokens) {
164 let display = api_key_display_label(tokens).unwrap_or_else(|| API_KEY_LABEL.to_string());
165 return (Some(display), Some(API_KEY_LABEL.to_string()));
166 }
167 let claims = tokens.id_token.as_deref().and_then(decode_id_token_claims);
168 let email = claims.as_ref().and_then(|c| c.email.clone());
169 let plan = claims
170 .and_then(|c| c.auth)
171 .and_then(|auth| auth.chatgpt_plan_type)
172 .map(|plan| format_plan(&plan));
173 (email, plan)
174}
175
176pub fn extract_profile_identity(tokens: &Tokens) -> Option<ProfileIdentityKey> {
177 if is_api_key_profile(tokens) {
178 let principal_id = token_account_id(tokens)?.to_string();
179 return Some(ProfileIdentityKey {
180 workspace_or_org_id: principal_id.clone(),
181 principal_id,
182 plan_type: "key".to_string(),
183 });
184 }
185
186 let claims = tokens.id_token.as_deref().and_then(decode_id_token_claims);
187 let principal_id = claims
188 .as_ref()
189 .and_then(|claims| {
190 claims.auth.as_ref().and_then(|auth| {
191 auth.chatgpt_user_id
192 .clone()
193 .or_else(|| auth.user_id.clone())
194 })
195 })
196 .or_else(|| claims.as_ref().and_then(|claims| claims.sub.clone()))
197 .or_else(|| token_account_id(tokens).map(str::to_string))
198 .and_then(|value| normalize_identity_value(&value))?;
199
200 let workspace_or_org_id = token_account_id(tokens)
201 .map(str::to_string)
202 .or_else(|| {
203 claims.as_ref().and_then(|claims| {
204 claims
205 .auth
206 .as_ref()
207 .and_then(|auth| auth.chatgpt_account_id.clone())
208 })
209 })
210 .or_else(|| {
211 claims
212 .as_ref()
213 .and_then(|claims| claims.organization_id.clone())
214 })
215 .or_else(|| claims.as_ref().and_then(|claims| claims.project_id.clone()))
216 .and_then(|value| normalize_identity_value(&value))
217 .unwrap_or_else(|| "unknown".to_string());
218
219 let plan_type = claims
220 .as_ref()
221 .and_then(|claims| {
222 claims
223 .auth
224 .as_ref()
225 .and_then(|auth| auth.chatgpt_plan_type.clone())
226 })
227 .or_else(|| extract_email_and_plan(tokens).1)
228 .map(|value| normalize_plan_type(&value))
229 .unwrap_or_else(|| "unknown".to_string());
230
231 Some(ProfileIdentityKey {
232 principal_id,
233 workspace_or_org_id,
234 plan_type,
235 })
236}
237
238fn normalize_identity_value(value: &str) -> Option<String> {
239 let trimmed = value.trim();
240 if trimmed.is_empty() {
241 None
242 } else {
243 Some(trimmed.to_string())
244 }
245}
246
247fn normalize_plan_type(value: &str) -> String {
248 let trimmed = value.trim();
249 if trimmed.is_empty() {
250 "unknown".to_string()
251 } else {
252 trimmed.to_ascii_lowercase()
253 }
254}
255
256pub fn require_identity(tokens: &Tokens) -> Result<(String, String, String), String> {
257 let Some(account_id) = token_account_id(tokens) else {
258 return Err(AUTH_ERR_INCOMPLETE_ACCOUNT.to_string());
259 };
260 let (email, plan) = extract_email_and_plan(tokens);
261 let email = email.ok_or_else(|| AUTH_ERR_INCOMPLETE_EMAIL.to_string())?;
262 let plan = plan.ok_or_else(|| AUTH_ERR_INCOMPLETE_PLAN.to_string())?;
263 Ok((account_id.to_string(), email, plan))
264}
265
266pub fn profile_error(
267 tokens: &Tokens,
268 email: Option<&str>,
269 plan: Option<&str>,
270) -> Option<&'static str> {
271 if is_api_key_profile(tokens) {
272 return None;
273 }
274 if email.is_none() || plan.is_none() {
275 return Some(AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN);
276 }
277 if token_account_id(tokens).is_none() {
278 return Some(AUTH_ERR_PROFILE_MISSING_ACCOUNT);
279 }
280 if tokens.access_token.is_none() {
281 return Some(AUTH_ERR_PROFILE_MISSING_ACCESS_TOKEN);
282 }
283 None
284}
285
286pub fn token_account_id(tokens: &Tokens) -> Option<&str> {
287 tokens
288 .account_id
289 .as_deref()
290 .filter(|value| !value.is_empty())
291}
292
293pub fn is_api_key_profile(tokens: &Tokens) -> bool {
294 tokens
295 .account_id
296 .as_deref()
297 .map(|value| value.starts_with(API_KEY_PREFIX))
298 .unwrap_or(false)
299 && tokens.id_token.is_none()
300 && tokens.access_token.is_none()
301 && tokens.refresh_token.is_none()
302}
303
304pub fn format_plan(plan: &str) -> String {
305 let mut out = String::new();
306 for word in plan.split(['_', '-']) {
307 if word.is_empty() {
308 continue;
309 }
310 if !out.is_empty() {
311 out.push(' ');
312 }
313 out.push_str(&title_case(word));
314 }
315 if out.is_empty() {
316 "Unknown".to_string()
317 } else {
318 out
319 }
320}
321
322pub fn is_free_plan(plan: Option<&str>) -> bool {
323 plan.map(|value| value.eq_ignore_ascii_case("free"))
324 .unwrap_or(false)
325}
326
327fn title_case(word: &str) -> String {
328 let mut chars = word.chars();
329 let Some(first) = chars.next() else {
330 return String::new();
331 };
332 let mut out = String::new();
333 out.push(first.to_ascii_uppercase());
334 out.extend(chars.flat_map(|ch| ch.to_lowercase()));
335 out
336}
337
338fn decode_id_token_claims(token: &str) -> Option<IdTokenClaims> {
339 let mut parts = token.split('.');
340 let _header = parts.next()?;
341 let payload = parts.next()?;
342 let _sig = parts.next()?;
343 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
344 serde_json::from_slice(&decoded).ok()
345}
346
347fn api_key_profile_id(api_key: &str) -> String {
348 let prefix = api_key_prefix(api_key);
349 let mut hash: u64 = 0xcbf29ce484222325;
350 for byte in api_key.as_bytes() {
351 hash ^= u64::from(*byte);
352 hash = hash.wrapping_mul(0x100000001b3);
353 }
354 format!("{API_KEY_PREFIX}{prefix}{API_KEY_SEPARATOR}{hash:016x}")
355}
356
357fn api_key_display_label(tokens: &Tokens) -> Option<String> {
358 let account_id = tokens.account_id.as_deref()?;
359 let rest = account_id.strip_prefix(API_KEY_PREFIX)?;
360 let (prefix, hash) = rest.split_once(API_KEY_SEPARATOR)?;
361 if prefix.is_empty() {
362 return None;
363 }
364 let suffix: String = hash.chars().rev().take(API_KEY_SUFFIX_LEN).collect();
365 let suffix: String = suffix.chars().rev().collect();
366 if suffix.is_empty() {
367 return None;
368 }
369 Some(format!("{API_KEY_SEPARATOR}{suffix}"))
370}
371
372fn api_key_prefix(api_key: &str) -> String {
373 let mut out = String::new();
374 for ch in api_key.chars().take(API_KEY_PREFIX_LEN) {
375 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
376 out.push(ch);
377 } else {
378 out.push('-');
379 }
380 }
381 out
382}
383
384#[derive(Serialize)]
385struct RefreshRequest {
386 client_id: &'static str,
387 grant_type: &'static str,
388 refresh_token: String,
389 scope: &'static str,
390}
391
392#[derive(Clone, Debug, Deserialize)]
393struct RefreshResponse {
394 id_token: Option<String>,
395 access_token: Option<String>,
396 refresh_token: Option<String>,
397}
398
399pub fn refresh_profile_tokens(path: &Path, tokens: &mut Tokens) -> Result<(), String> {
400 let refresh_token = tokens
401 .refresh_token
402 .as_deref()
403 .filter(|value| !value.is_empty())
404 .ok_or_else(|| AUTH_ERR_PROFILE_NO_REFRESH_TOKEN.to_string())?;
405 let refreshed = refresh_access_token(refresh_token)?;
406 apply_refresh(tokens, &refreshed)?;
407 update_auth_tokens(path, &refreshed)?;
408 Ok(())
409}
410
411fn refresh_access_token(refresh_token: &str) -> Result<RefreshResponse, String> {
412 let request = RefreshRequest {
413 client_id: CLIENT_ID,
414 grant_type: "refresh_token",
415 refresh_token: refresh_token.to_string(),
416 scope: "openid profile email",
417 };
418 let endpoint = std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR)
419 .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string());
420 let config = ureq::Agent::config_builder()
421 .timeout_global(Some(Duration::from_secs(5)))
422 .http_status_as_error(false)
423 .build();
424 let agent: ureq::Agent = config.into();
425 let response = agent
426 .post(&endpoint)
427 .header("Content-Type", "application/json")
428 .send_json(&request)
429 .map_err(|other| crate::msg1(AUTH_ERR_REFRESH_FAILED_OTHER, other))?;
430
431 let status = response.status();
432 if status == 401 {
433 let body = response.into_body().read_to_string().unwrap_or_default();
434 return Err(classify_refresh_unauthorized_message(&body));
435 }
436 if !status.is_success() {
437 return Err(crate::msg1(AUTH_ERR_REFRESH_FAILED_CODE, status));
438 }
439
440 response
441 .into_body()
442 .read_json::<RefreshResponse>()
443 .map_err(|err| crate::msg1(AUTH_ERR_INVALID_REFRESH_RESPONSE, err))
444}
445
446fn classify_refresh_unauthorized_message(body: &str) -> String {
447 match extract_refresh_error_code(body).as_deref() {
448 Some("refresh_token_expired") => AUTH_ERR_REFRESH_EXPIRED.to_string(),
449 Some("refresh_token_reused") => AUTH_ERR_REFRESH_REUSED.to_string(),
450 Some("refresh_token_invalidated") => AUTH_ERR_REFRESH_REVOKED.to_string(),
451 _ => {
452 if body.trim().is_empty() {
453 crate::msg2(
454 UI_ERROR_TWO_LINE,
455 AUTH_REFRESH_401_TITLE,
456 AUTH_RELOGIN_AND_SAVE,
457 )
458 } else {
459 AUTH_ERR_REFRESH_UNKNOWN_401.to_string()
460 }
461 }
462 }
463}
464
465fn extract_refresh_error_code(body: &str) -> Option<String> {
466 let value: serde_json::Value = serde_json::from_str(body).ok()?;
467 if let Some(code) = value
468 .get("error")
469 .and_then(|error| error.get("code"))
470 .and_then(serde_json::Value::as_str)
471 {
472 return Some(code.to_ascii_lowercase());
473 }
474 if let Some(code) = value
475 .get("error")
476 .and_then(serde_json::Value::as_str)
477 .or_else(|| value.get("code").and_then(serde_json::Value::as_str))
478 {
479 return Some(code.to_ascii_lowercase());
480 }
481 None
482}
483
484fn apply_refresh(tokens: &mut Tokens, refreshed: &RefreshResponse) -> Result<(), String> {
485 let Some(access_token) = refreshed.access_token.as_ref() else {
486 return Err(AUTH_ERR_REFRESH_MISSING_ACCESS_TOKEN.to_string());
487 };
488 tokens.access_token = Some(access_token.clone());
489 if let Some(id_token) = refreshed.id_token.as_ref() {
490 tokens.id_token = Some(id_token.clone());
491 }
492 if let Some(refresh_token) = refreshed.refresh_token.as_ref() {
493 tokens.refresh_token = Some(refresh_token.clone());
494 }
495 Ok(())
496}
497
498fn update_auth_tokens(path: &Path, refreshed: &RefreshResponse) -> Result<(), String> {
499 let contents = std::fs::read_to_string(path)
500 .map_err(|err| crate::msg2(AUTH_ERR_READ, path.display(), err))?;
501 let mut value: serde_json::Value = serde_json::from_str(&contents)
502 .map_err(|err| crate::msg2(AUTH_ERR_INVALID_JSON, path.display(), err))?;
503 let Some(root) = value.as_object_mut() else {
504 return Err(crate::msg1(AUTH_ERR_INVALID_JSON_OBJECT, path.display()));
505 };
506 let tokens = root
507 .entry("tokens")
508 .or_insert_with(|| serde_json::json!({}));
509 let Some(tokens_map) = tokens.as_object_mut() else {
510 return Err(crate::msg1(AUTH_ERR_INVALID_TOKENS_OBJECT, path.display()));
511 };
512 if let Some(id_token) = refreshed.id_token.as_ref() {
513 tokens_map.insert(
514 "id_token".to_string(),
515 serde_json::Value::String(id_token.clone()),
516 );
517 }
518 if let Some(access_token) = refreshed.access_token.as_ref() {
519 tokens_map.insert(
520 "access_token".to_string(),
521 serde_json::Value::String(access_token.clone()),
522 );
523 }
524 if let Some(refresh_token) = refreshed.refresh_token.as_ref() {
525 tokens_map.insert(
526 "refresh_token".to_string(),
527 serde_json::Value::String(refresh_token.clone()),
528 );
529 }
530 let json = serde_json::to_string_pretty(&value)
531 .map_err(|err| crate::msg1(AUTH_ERR_SERIALIZE_AUTH, err))?;
532 write_atomic(path, format!("{json}\n").as_bytes())
533 .map_err(|err| crate::msg2(AUTH_ERR_WRITE_AUTH, path.display(), err))
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::test_utils::{
540 ENV_MUTEX, build_id_token, http_ok_response, set_env_guard, spawn_server,
541 };
542 use std::fs;
543
544 fn build_id_token_payload(payload: &str) -> String {
545 let header = r#"{"alg":"none","typ":"JWT"}"#;
546 let header = URL_SAFE_NO_PAD.encode(header);
547 let payload = URL_SAFE_NO_PAD.encode(payload);
548 format!("{header}.{payload}.")
549 }
550
551 #[test]
552 fn read_auth_file_errors() {
553 let dir = tempfile::tempdir().expect("tempdir");
554 let missing = dir.path().join("missing.json");
555 let err = read_auth_file(&missing).unwrap_err();
556 assert!(err.contains("Auth file not found"));
557
558 let bad = dir.path().join("bad.json");
559 fs::write(&bad, "{oops").expect("write");
560 let err = read_auth_file(&bad).unwrap_err();
561 assert!(err.contains("Invalid JSON"));
562 }
563
564 #[test]
565 fn read_tokens_paths() {
566 let dir = tempfile::tempdir().expect("tempdir");
567 let path = dir.path().join("auth.json");
568 let id_token = build_id_token("me@example.com", "pro");
569 let value = serde_json::json!({
570 "tokens": {"account_id": "acct", "id_token": id_token, "access_token": "acc"}
571 });
572 fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
573 let tokens = read_tokens(&path).unwrap();
574 assert_eq!(token_account_id(&tokens), Some("acct"));
575
576 let api_path = dir.path().join("auth_api.json");
577 let value = serde_json::json!({"OPENAI_API_KEY": "sk-test"});
578 fs::write(&api_path, serde_json::to_string(&value).unwrap()).unwrap();
579 let tokens = read_tokens(&api_path).unwrap();
580 assert!(is_api_key_profile(&tokens));
581
582 let empty_path = dir.path().join("empty.json");
583 fs::write(&empty_path, "{}").unwrap();
584 let err = read_tokens(&empty_path).unwrap_err();
585 assert!(err.contains("Missing tokens"));
586 }
587
588 #[test]
589 fn read_tokens_opt_handles_missing() {
590 let dir = tempfile::tempdir().expect("tempdir");
591 let path = dir.path().join("none.json");
592 assert!(read_tokens_opt(&path).is_none());
593 }
594
595 #[test]
596 fn api_key_helpers() {
597 let tokens = tokens_from_api_key("sk-test-1234");
598 assert!(is_api_key_profile(&tokens));
599 let display = api_key_display_label(&tokens).unwrap();
600 assert!(display.starts_with(API_KEY_SEPARATOR));
601 assert_eq!(api_key_prefix("abc$123"), "abc-123".to_string());
602 }
603
604 #[test]
605 fn format_plan_and_free() {
606 assert_eq!(format_plan("chatgpt_plus"), "Chatgpt Plus");
607 assert_eq!(format_plan(""), "Unknown");
608 assert!(is_free_plan(Some("free")));
609 assert!(!is_free_plan(Some("pro")));
610 }
611
612 #[test]
613 fn extract_email_and_plan_paths() {
614 let id_token = build_id_token("me@example.com", "pro");
615 let tokens = Tokens {
616 account_id: Some("acct".to_string()),
617 id_token: Some(id_token),
618 access_token: Some("acc".to_string()),
619 refresh_token: None,
620 };
621 let (email, plan) = extract_email_and_plan(&tokens);
622 assert_eq!(email.as_deref(), Some("me@example.com"));
623 assert_eq!(plan.as_deref(), Some("Pro"));
624
625 let api_tokens = tokens_from_api_key("sk-test");
626 let (email, plan) = extract_email_and_plan(&api_tokens);
627 assert_eq!(plan.as_deref(), Some(API_KEY_LABEL));
628 assert!(email.is_some());
629 }
630
631 #[test]
632 fn extract_profile_identity_prefers_user_and_workspace_claims() {
633 let id_token = build_id_token_payload(
634 "{\"email\":\"me@example.com\",\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"team\",\"chatgpt_user_id\":\"user-123\",\"chatgpt_account_id\":\"ws-123\"}}",
635 );
636 let tokens = Tokens {
637 account_id: Some("acct-fallback".to_string()),
638 id_token: Some(id_token),
639 access_token: Some("acc".to_string()),
640 refresh_token: Some("ref".to_string()),
641 };
642 let identity = extract_profile_identity(&tokens).unwrap();
643 assert_eq!(identity.principal_id, "user-123");
644 assert_eq!(identity.workspace_or_org_id, "acct-fallback");
645 assert_eq!(identity.plan_type, "team");
646 }
647
648 #[test]
649 fn extract_profile_identity_falls_back_to_sub_and_org() {
650 let id_token = build_id_token_payload(
651 "{\"sub\":\"sub-1\",\"organization_id\":\"org-1\",\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"Pro\"}}",
652 );
653 let tokens = Tokens {
654 account_id: None,
655 id_token: Some(id_token),
656 access_token: Some("acc".to_string()),
657 refresh_token: Some("ref".to_string()),
658 };
659 let identity = extract_profile_identity(&tokens).unwrap();
660 assert_eq!(identity.principal_id, "sub-1");
661 assert_eq!(identity.workspace_or_org_id, "org-1");
662 assert_eq!(identity.plan_type, "pro");
663 }
664
665 #[test]
666 fn extract_profile_identity_uses_account_fallback_when_claims_missing() {
667 let tokens = Tokens {
668 account_id: Some("acct-only".to_string()),
669 id_token: Some(build_id_token("me@example.com", "pro")),
670 access_token: Some("acc".to_string()),
671 refresh_token: Some("ref".to_string()),
672 };
673 let identity = extract_profile_identity(&tokens).unwrap();
674 assert_eq!(identity.principal_id, "acct-only");
675 assert_eq!(identity.workspace_or_org_id, "acct-only");
676 assert_eq!(identity.plan_type, "pro");
677 }
678
679 #[test]
680 fn require_identity_errors() {
681 let tokens = Tokens {
682 account_id: None,
683 id_token: None,
684 access_token: None,
685 refresh_token: None,
686 };
687 let err = require_identity(&tokens).unwrap_err();
688 assert!(err.contains("missing account"));
689 }
690
691 #[test]
692 fn profile_error_variants() {
693 let tokens = Tokens {
694 account_id: Some("acct".to_string()),
695 id_token: None,
696 access_token: None,
697 refresh_token: None,
698 };
699 assert_eq!(
700 profile_error(&tokens, Some("e"), Some("p")),
701 Some(crate::AUTH_ERR_PROFILE_MISSING_ACCESS_TOKEN)
702 );
703
704 let api_tokens = tokens_from_api_key("sk-test");
705 assert!(profile_error(&api_tokens, None, None).is_none());
706
707 let tokens = Tokens {
708 account_id: None,
709 id_token: Some(build_id_token("me@example.com", "pro")),
710 access_token: Some("acc".to_string()),
711 refresh_token: None,
712 };
713 assert_eq!(
714 profile_error(&tokens, Some("me@example.com"), Some("Pro")),
715 Some(crate::AUTH_ERR_PROFILE_MISSING_ACCOUNT)
716 );
717
718 let id_token = build_id_token_payload(
719 "{\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\"}}",
720 );
721 let tokens = Tokens {
722 account_id: Some("acct".to_string()),
723 id_token: Some(id_token),
724 access_token: Some("acc".to_string()),
725 refresh_token: None,
726 };
727 assert_eq!(
728 profile_error(&tokens, None, Some("Pro")),
729 Some(crate::AUTH_ERR_PROFILE_MISSING_EMAIL_PLAN)
730 );
731 }
732
733 #[test]
734 fn is_profile_ready_variants() {
735 let api_tokens = tokens_from_api_key("sk-test");
736 assert!(is_profile_ready(&api_tokens));
737
738 let tokens = Tokens {
739 account_id: None,
740 id_token: Some(build_id_token("me@example.com", "pro")),
741 access_token: Some("acc".to_string()),
742 refresh_token: None,
743 };
744 assert!(!is_profile_ready(&tokens));
745
746 let tokens = Tokens {
747 account_id: Some("acct".to_string()),
748 id_token: Some(build_id_token("me@example.com", "pro")),
749 access_token: None,
750 refresh_token: None,
751 };
752 assert!(!is_profile_ready(&tokens));
753
754 let id_token = build_id_token_payload("{\"email\":\"me@example.com\"}");
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 assert!(!is_profile_ready(&tokens));
762 }
763
764 #[test]
765 fn require_identity_missing_fields() {
766 let id_token = build_id_token_payload("{\"email\":\"me@example.com\"}");
767 let tokens = Tokens {
768 account_id: Some("acct".to_string()),
769 id_token: Some(id_token),
770 access_token: Some("acc".to_string()),
771 refresh_token: None,
772 };
773 let err = require_identity(&tokens).unwrap_err();
774 assert!(err.contains("missing plan"));
775
776 let id_token = build_id_token_payload(
777 "{\"https://api.openai.com/auth\":{\"chatgpt_plan_type\":\"pro\"}}",
778 );
779 let tokens = Tokens {
780 account_id: Some("acct".to_string()),
781 id_token: Some(id_token),
782 access_token: Some("acc".to_string()),
783 refresh_token: None,
784 };
785 let err = require_identity(&tokens).unwrap_err();
786 assert!(err.contains("missing email"));
787
788 let tokens = Tokens {
789 account_id: Some("acct".to_string()),
790 id_token: Some(build_id_token("me@example.com", "pro")),
791 access_token: Some("acc".to_string()),
792 refresh_token: None,
793 };
794 assert!(require_identity(&tokens).is_ok());
795 }
796
797 #[test]
798 fn refresh_profile_tokens_missing_refresh() {
799 let dir = tempfile::tempdir().expect("tempdir");
800 let path = dir.path().join("auth.json");
801 let value = serde_json::json!({
802 "tokens": {
803 "account_id": "acct",
804 "access_token": "acc"
805 }
806 });
807 fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
808 let mut tokens = read_tokens(&path).unwrap();
809 let err = refresh_profile_tokens(&path, &mut tokens).unwrap_err();
810 assert!(err.contains("refresh token"));
811 }
812
813 #[test]
814 fn set_env_clears_value() {
815 let _guard = ENV_MUTEX.lock().unwrap();
816 {
817 let _env = set_env_guard("CODEX_PROFILES_TEST_ENV", Some("value"));
818 }
819 {
820 let _env = set_env_guard("CODEX_PROFILES_TEST_ENV", None);
821 }
822 }
823
824 #[test]
825 fn decode_id_token_claims_handles_invalid() {
826 assert!(decode_id_token_claims("not-a-jwt").is_none());
827 let bad = "a.b.c";
828 assert!(decode_id_token_claims(bad).is_none());
829 let good = build_id_token("me@example.com", "pro");
830 assert!(decode_id_token_claims(&good).is_some());
831 }
832
833 #[test]
834 fn apply_refresh_requires_access_token() {
835 let mut tokens = Tokens {
836 account_id: Some("acct".to_string()),
837 id_token: None,
838 access_token: None,
839 refresh_token: None,
840 };
841 let refreshed = RefreshResponse {
842 id_token: None,
843 access_token: None,
844 refresh_token: None,
845 };
846 let err = apply_refresh(&mut tokens, &refreshed).unwrap_err();
847 assert!(err.contains("missing an access token"));
848 }
849
850 #[test]
851 fn update_auth_tokens_errors() {
852 let dir = tempfile::tempdir().expect("tempdir");
853 let missing = dir.path().join("missing.json");
854 let err = update_auth_tokens(
855 &missing,
856 &RefreshResponse {
857 id_token: None,
858 access_token: None,
859 refresh_token: None,
860 },
861 )
862 .unwrap_err();
863 assert!(err.contains("Could not read"));
864
865 let bad = dir.path().join("bad.json");
866 fs::write(&bad, "{oops").unwrap();
867 let err = update_auth_tokens(
868 &bad,
869 &RefreshResponse {
870 id_token: None,
871 access_token: None,
872 refresh_token: None,
873 },
874 )
875 .unwrap_err();
876 assert!(err.contains("Invalid JSON"));
877
878 let not_obj = dir.path().join("not_obj.json");
879 fs::write(¬_obj, "[]").unwrap();
880 let err = update_auth_tokens(
881 ¬_obj,
882 &RefreshResponse {
883 id_token: None,
884 access_token: None,
885 refresh_token: None,
886 },
887 )
888 .unwrap_err();
889 assert!(err.contains("expected object"));
890
891 let tokens_not_obj = dir.path().join("tokens_not_obj.json");
892 fs::write(&tokens_not_obj, "{\"tokens\": []}").unwrap();
893 let err = update_auth_tokens(
894 &tokens_not_obj,
895 &RefreshResponse {
896 id_token: None,
897 access_token: None,
898 refresh_token: None,
899 },
900 )
901 .unwrap_err();
902 assert!(err.contains("Invalid tokens"));
903 }
904
905 #[test]
906 fn refresh_access_token_success_and_status() {
907 let _guard = ENV_MUTEX.lock().unwrap();
908 let ok_body = "{\"access_token\":\"acc\",\"id_token\":\"id\",\"refresh_token\":\"ref\"}";
909 let ok_resp = http_ok_response(ok_body, "application/json");
910 let ok_url = spawn_server(ok_resp);
911 {
912 let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&ok_url));
913 let refreshed = refresh_access_token("token").unwrap();
914 assert_eq!(refreshed.access_token.as_deref(), Some("acc"));
915 }
916
917 let err_resp = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n".to_string();
918 let err_url = spawn_server(err_resp);
919 {
920 let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&err_url));
921 let err = refresh_access_token("token").unwrap_err();
922 assert!(err.contains("unauthorized"));
923 }
924
925 let expired_body = r#"{"error":{"code":"refresh_token_expired"}}"#;
926 let expired_resp = format!(
927 "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
928 expired_body.len(),
929 expired_body
930 );
931 let expired_url = spawn_server(expired_resp);
932 {
933 let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&expired_url));
934 let err = refresh_access_token("token").unwrap_err();
935 assert!(err.contains("expired"));
936 }
937
938 let reused_body = r#"{"error":{"code":"refresh_token_reused"}}"#;
939 let reused_resp = format!(
940 "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
941 reused_body.len(),
942 reused_body
943 );
944 let reused_url = spawn_server(reused_resp);
945 {
946 let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&reused_url));
947 let err = refresh_access_token("token").unwrap_err();
948 assert!(err.contains("Token refresh unauthorized (401)"));
949 assert!(err.contains("Authenticate again with `codex login`"));
950 }
951
952 let revoked_body = r#"{"error":{"code":"refresh_token_invalidated"}}"#;
953 let revoked_resp = format!(
954 "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
955 revoked_body.len(),
956 revoked_body
957 );
958 let revoked_url = spawn_server(revoked_resp);
959 {
960 let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&revoked_url));
961 let err = refresh_access_token("token").unwrap_err();
962 assert!(err.contains("revoked"));
963 }
964 }
965
966 #[test]
967 fn refresh_profile_tokens_updates_file() {
968 let _guard = ENV_MUTEX.lock().unwrap();
969 let ok_body = "{\"access_token\":\"acc\",\"id_token\":\"id\",\"refresh_token\":\"ref\"}";
970 let ok_resp = http_ok_response(ok_body, "application/json");
971 let ok_url = spawn_server(ok_resp);
972 let _env = set_env_guard(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, Some(&ok_url));
973
974 let dir = tempfile::tempdir().expect("tempdir");
975 let path = dir.path().join("auth.json");
976 let value = serde_json::json!({
977 "tokens": {
978 "account_id": "acct",
979 "access_token": "old",
980 "refresh_token": "rt"
981 }
982 });
983 fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap();
984 let mut tokens = read_tokens(&path).unwrap();
985 refresh_profile_tokens(&path, &mut tokens).unwrap();
986 let updated = fs::read_to_string(&path).unwrap();
987 assert!(updated.contains("acc"));
988 }
989}