Codex Handbook
tui/src/local_chatgpt_auth.rs 229 lines
#![cfg(test)]use std::path::Path;use codex_app_server_protocol::AuthMode;use codex_config::types::AuthCredentialsStoreMode;use codex_login::AuthKeyringBackendKind;use codex_login::load_auth_dot_json;#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct LocalChatgptAuth {    pub(crate) access_token: String,    pub(crate) chatgpt_account_id: String,    pub(crate) chatgpt_plan_type: Option<String>,}pub(crate) fn load_local_chatgpt_auth(    codex_home: &Path,    auth_credentials_store_mode: AuthCredentialsStoreMode,    forced_chatgpt_workspace_id: Option<&[String]>,) -> Result<LocalChatgptAuth, String> {    let auth = load_auth_dot_json(        codex_home,        auth_credentials_store_mode,        AuthKeyringBackendKind::default(),    )    .map_err(|err| format!("failed to load local auth: {err}"))?    .ok_or_else(|| "no local auth available".to_string())?;    if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() {        return Err("local auth is not a ChatGPT login".to_string());    }    let tokens = auth        .tokens        .ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?;    let access_token = tokens.access_token;    let chatgpt_account_id = tokens        .account_id        .or(tokens.id_token.chatgpt_account_id.clone())        .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?;    if let Some(expected_workspaces) = forced_chatgpt_workspace_id        && !expected_workspaces.contains(&chatgpt_account_id)    {        return Err(format!(            "local ChatGPT auth must use one of workspace(s) {expected_workspaces:?}, but found {chatgpt_account_id:?}",        ));    }    let chatgpt_plan_type = tokens        .id_token        .get_chatgpt_plan_type_raw()        .map(|plan_type| plan_type.to_ascii_lowercase());    Ok(LocalChatgptAuth {        access_token,        chatgpt_account_id,        chatgpt_plan_type,    })}#[cfg(test)]mod tests {    use super::*;    use base64::Engine;    use chrono::Utc;    use codex_app_server_protocol::AuthMode;    use codex_login::AuthDotJson;    use codex_login::auth::login_with_chatgpt_auth_tokens;    use codex_login::save_auth;    use codex_login::token_data::TokenData;    use pretty_assertions::assert_eq;    use serde::Serialize;    use serde_json::json;    use tempfile::TempDir;    fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String {        #[derive(Serialize)]        struct Header {            alg: &'static str,            typ: &'static str,        }        let header = Header {            alg: "none",            typ: "JWT",        };        let payload = json!({            "email": email,            "https://api.openai.com/auth": {                "chatgpt_account_id": account_id,                "chatgpt_plan_type": plan_type,            },        });        let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);        let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));        let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));        let signature_b64 = encode(b"sig");        format!("{header_b64}.{payload_b64}.{signature_b64}")    }    fn write_chatgpt_auth(codex_home: &Path, plan_type: &str) {        let id_token = fake_jwt("user@example.com", "workspace-1", plan_type);        let access_token = fake_jwt("user@example.com", "workspace-1", plan_type);        let auth = AuthDotJson {            auth_mode: Some(AuthMode::Chatgpt),            openai_api_key: None,            tokens: Some(TokenData {                id_token: codex_login::token_data::parse_chatgpt_jwt_claims(&id_token)                    .expect("id token should parse"),                access_token,                refresh_token: "refresh-token".to_string(),                account_id: Some("workspace-1".to_string()),            }),            last_refresh: Some(Utc::now()),            agent_identity: None,            personal_access_token: None,            bedrock_api_key: None,        };        save_auth(            codex_home,            &auth,            AuthCredentialsStoreMode::File,            AuthKeyringBackendKind::default(),        )        .expect("chatgpt auth should save");    }    #[test]    fn loads_local_chatgpt_auth_from_managed_auth() {        let codex_home = TempDir::new().expect("tempdir");        write_chatgpt_auth(codex_home.path(), "business");        let auth = load_local_chatgpt_auth(            codex_home.path(),            AuthCredentialsStoreMode::File,            Some(&["workspace-1".to_string()]),        )        .expect("chatgpt auth should load");        assert_eq!(auth.chatgpt_account_id, "workspace-1");        assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));        assert!(!auth.access_token.is_empty());    }    #[test]    fn rejects_missing_local_auth() {        let codex_home = TempDir::new().expect("tempdir");        let err = load_local_chatgpt_auth(            codex_home.path(),            AuthCredentialsStoreMode::File,            /*forced_chatgpt_workspace_id*/ None,        )        .expect_err("missing auth should fail");        assert_eq!(err, "no local auth available");    }    #[test]    fn rejects_api_key_auth() {        let codex_home = TempDir::new().expect("tempdir");        save_auth(            codex_home.path(),            &AuthDotJson {                auth_mode: Some(AuthMode::ApiKey),                openai_api_key: Some("sk-test".to_string()),                tokens: None,                last_refresh: None,                agent_identity: None,                personal_access_token: None,                bedrock_api_key: None,            },            AuthCredentialsStoreMode::File,            AuthKeyringBackendKind::default(),        )        .expect("api key auth should save");        let err = load_local_chatgpt_auth(            codex_home.path(),            AuthCredentialsStoreMode::File,            /*forced_chatgpt_workspace_id*/ None,        )        .expect_err("api key auth should fail");        assert_eq!(err, "local auth is not a ChatGPT login");    }    #[test]    fn prefers_managed_auth_over_external_ephemeral_tokens() {        let codex_home = TempDir::new().expect("tempdir");        write_chatgpt_auth(codex_home.path(), "business");        login_with_chatgpt_auth_tokens(            codex_home.path(),            &fake_jwt("user@example.com", "workspace-2", "enterprise"),            "workspace-2",            Some("enterprise"),        )        .expect("external auth should save");        let auth = load_local_chatgpt_auth(            codex_home.path(),            AuthCredentialsStoreMode::File,            Some(&["workspace-1".to_string(), "workspace-2".to_string()]),        )        .expect("managed auth should win");        assert_eq!(auth.chatgpt_account_id, "workspace-1");        assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));    }    #[test]    fn preserves_usage_based_plan_type_wire_name() {        let codex_home = TempDir::new().expect("tempdir");        write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based");        let auth = load_local_chatgpt_auth(            codex_home.path(),            AuthCredentialsStoreMode::File,            Some(&["workspace-1".to_string()]),        )        .expect("chatgpt auth should load");        assert_eq!(            auth.chatgpt_plan_type.as_deref(),            Some("self_serve_business_usage_based")        );    }}