Skip to main content

codex_profiles/
auth.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use serde::{Deserialize, Serialize};
4use serde_with::{NoneAsEmptyString, serde_as};
5use std::path::Path;
6use std::time::Duration;
7
8use crate::{
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(&not_obj, "[]").unwrap();
880        let err = update_auth_tokens(
881            &not_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}