mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
56c97e3b5c
## Why Windows Credential Manager limits generic credential blobs to 2,560 bytes. Large serialized ChatGPT auth payloads can exceed that limit, so keyring-mode CLI auth needs a backend that keeps only the encryption key in the OS keyring and stores the payload in Codex's encrypted local-secrets file. This is the third PR in the encrypted-auth stack: 1. #27504 — feature and config selection 2. #27535 — auth-specific local-secrets namespaces 3. This PR — CLI auth implementation and activation 4. MCP OAuth implementation and activation ## What Changed - Added encrypted CLI-auth storage using the `CliAuth` secrets namespace. - Preserved direct keyring storage for platforms/configurations where it remains selected. - Selected the backend consistently for login, logout, refresh, device-code login, auth loading, and login restrictions. - Threaded resolved bootstrap/full config through CLI, exec, TUI, app-server account handling, cloud config, and cloud tasks. - Removed stale `auth.json` fallback data after successful encrypted saves and removed encrypted, direct-keyring, and fallback data during logout. - Added storage and integration coverage for both direct and encrypted keyring modes. MCP OAuth persistence is intentionally left to the next PR. ## Validation - `just test -p codex-login` — 131 passed - `just test -p codex-cli` — 280 passed - `just test -p codex-app-server v2::account` — 25 passed - `just test -p codex-cloud-config service` — 21 passed, 7 skipped - `just fix -p codex-login` - `just fix -p codex-cli` - `just fmt`
230 lines
7.6 KiB
Rust
230 lines
7.6 KiB
Rust
#![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")
|
|
);
|
|
}
|
|
}
|