From 8287818db484c29cff754b140b2e7fb0aeccdaec Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 18 Mar 2026 14:31:58 -0700 Subject: [PATCH] auth --- codex-rs/Cargo.lock | 23 ++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/config.schema.json | 31 +- codex-rs/core/src/auth.rs | 81 +---- codex-rs/core/src/auth/storage.rs | 336 ------------------- codex-rs/core/src/auth/storage_tests.rs | 416 ------------------------ codex-rs/core/src/auth_tests.rs | 4 +- 8 files changed, 66 insertions(+), 828 deletions(-) delete mode 100644 codex-rs/core/src/auth/storage.rs delete mode 100644 codex-rs/core/src/auth/storage_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cea35cf0a9..1bd6c11d6a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1861,6 +1861,7 @@ dependencies = [ "codex-client", "codex-config", "codex-connectors", + "codex-core-auth", "codex-environment", "codex-execpolicy", "codex-file-search", @@ -1953,6 +1954,28 @@ dependencies = [ "zstd", ] +[[package]] +name = "codex-core-auth" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "codex-app-server-protocol", + "codex-auth", + "codex-keyring-store", + "keyring", + "once_cell", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-debug-client" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f705c41ceb..8754188733 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -23,6 +23,7 @@ members = [ "shell-escalation", "skills", "core", + "core/auth", "environment", "hooks", "secrets", @@ -106,6 +107,7 @@ codex-cloud-requirements = { path = "cloud-requirements" } codex-connectors = { path = "connectors" } codex-config = { path = "config" } codex-core = { path = "core" } +codex-core-auth = { path = "core/auth" } codex-environment = { path = "environment" } codex-exec = { path = "exec" } codex-execpolicy = { path = "execpolicy" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 7a2f6d61ba..82b44fd72f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -35,6 +35,7 @@ codex-async-utils = { workspace = true } codex-client = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } +codex-core-auth = { workspace = true } codex-environment = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index edd4136d09..b2f88d3344 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -806,61 +806,75 @@ }, "ModelProviderInfo": { "additionalProperties": false, + "description": "Serializable representation of a provider definition.", "properties": { "base_url": { + "description": "Base URL for the provider's OpenAI-compatible API.", "type": "string" }, "env_http_headers": { "additionalProperties": { "type": "string" }, + "description": "Optional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and _environment variable_ whose value should be used. If the environment variable is not set, or the value is empty, the header will not be included in the request.", "type": "object" }, "env_key": { + "description": "Environment variable that stores the user's API key for this provider.", "type": "string" }, "env_key_instructions": { + "description": "Optional instructions to help the user get a valid value for the variable and set it.", "type": "string" }, "experimental_bearer_token": { + "description": "Value to use with `Authorization: Bearer ` header. Use of this config is discouraged in favor of `env_key` for security reasons, but this may be necessary when using this programmatically.", "type": "string" }, "http_headers": { "additionalProperties": { "type": "string" }, + "description": "Additional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and value.", "type": "object" }, "name": { + "description": "Friendly display name.", "type": "string" }, "query_params": { "additionalProperties": { "type": "string" }, + "description": "Optional query parameters to append to the base URL.", "type": "object" }, "request_max_retries": { + "description": "Maximum number of times to retry a failed HTTP request to this provider.", "format": "uint64", "minimum": 0.0, "type": "integer" }, "requires_openai_auth": { "default": false, + "description": "Does this provider require an OpenAI API Key or ChatGPT login token? If true, user is presented with login screen on first run, and login preference and token/key are stored in auth.json. If false (which is the default), login screen is skipped, and API key (if needed) comes from the \"env_key\" environment variable.", "type": "boolean" }, "stream_idle_timeout_ms": { + "description": "Idle timeout (in milliseconds) to wait for activity on a streaming response before treating the connection as lost.", "format": "uint64", "minimum": 0.0, "type": "integer" }, "stream_max_retries": { + "description": "Number of times to retry reconnecting a dropped streaming response before failing.", "format": "uint64", "minimum": 0.0, "type": "integer" }, "supports_websockets": { "default": false, + "description": "Whether this provider supports the Responses API WebSocket transport.", "type": "boolean" }, "websocket_connect_timeout_ms": { @@ -875,7 +889,8 @@ "$ref": "#/definitions/WireApi" } ], - "default": "responses" + "default": "responses", + "description": "Which wire protocol this provider expects." } }, "required": [ @@ -1765,10 +1780,16 @@ "type": "object" }, "WireApi": { - "enum": [ - "responses" - ], - "type": "string" + "description": "Wire protocol that the provider speaks.", + "oneOf": [ + { + "description": "The Responses API exposed by OpenAI at `/v1/responses`.", + "enum": [ + "responses" + ], + "type": "string" + } + ] } }, "description": "Base config deserialized from ~/.codex/config.toml.", diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index ea2f50cff7..fa5f29f425 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1,5 +1,3 @@ -mod storage; - use async_trait::async_trait; use chrono::Utc; use reqwest::StatusCode; @@ -19,13 +17,15 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_otel::TelemetryAuthMode; use codex_protocol::config_types::ForcedLoginMethod; +use codex_core_auth::AuthStorageBackend; +use codex_core_auth::create_auth_storage; + +pub use codex_core_auth::AuthCredentialsStoreMode; +pub use codex_core_auth::AuthDotJson; + use crate::KnownPlan as InternalKnownPlan; use crate::PlanType as InternalPlanType; use crate::TokenData; -pub use crate::auth::storage::AuthCredentialsStoreMode; -pub use crate::auth::storage::AuthDotJson; -use crate::auth::storage::AuthStorageBackend; -use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; @@ -752,67 +752,6 @@ fn refresh_token_endpoint() -> String { .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) } -impl AuthDotJson { - fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result { - let mut token_info = - parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?; - token_info.chatgpt_account_id = Some(external.chatgpt_account_id.clone()); - token_info.chatgpt_plan_type = external - .chatgpt_plan_type - .as_deref() - .map(InternalPlanType::from_raw_value) - .or(token_info.chatgpt_plan_type) - .or(Some(InternalPlanType::Unknown("unknown".to_string()))); - let tokens = TokenData { - id_token: token_info, - access_token: external.access_token.clone(), - refresh_token: String::new(), - account_id: Some(external.chatgpt_account_id.clone()), - }; - - Ok(Self { - auth_mode: Some(ApiAuthMode::ChatgptAuthTokens), - openai_api_key: None, - tokens: Some(tokens), - last_refresh: Some(Utc::now()), - }) - } - - fn from_external_access_token( - access_token: &str, - chatgpt_account_id: &str, - chatgpt_plan_type: Option<&str>, - ) -> std::io::Result { - let external = ExternalAuthTokens { - access_token: access_token.to_string(), - chatgpt_account_id: chatgpt_account_id.to_string(), - chatgpt_plan_type: chatgpt_plan_type.map(str::to_string), - }; - Self::from_external_tokens(&external) - } - - fn resolved_mode(&self) -> ApiAuthMode { - if let Some(mode) = self.auth_mode { - return mode; - } - if self.openai_api_key.is_some() { - return ApiAuthMode::ApiKey; - } - ApiAuthMode::Chatgpt - } - - fn storage_mode( - &self, - auth_credentials_store_mode: AuthCredentialsStoreMode, - ) -> AuthCredentialsStoreMode { - if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens { - AuthCredentialsStoreMode::Ephemeral - } else { - auth_credentials_store_mode - } - } -} - /// Internal cached auth state. #[derive(Clone)] struct CachedAuth { @@ -1412,8 +1351,12 @@ impl AuthManager { ), ))); } - let auth_dot_json = - AuthDotJson::from_external_tokens(&refreshed).map_err(RefreshTokenError::Transient)?; + let auth_dot_json = AuthDotJson::from_external_access_token( + &refreshed.access_token, + &refreshed.chatgpt_account_id, + refreshed.chatgpt_plan_type.as_deref(), + ) + .map_err(RefreshTokenError::Transient)?; save_auth( &self.codex_home, &auth_dot_json, diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs deleted file mode 100644 index 51af76c480..0000000000 --- a/codex-rs/core/src/auth/storage.rs +++ /dev/null @@ -1,336 +0,0 @@ -use chrono::DateTime; -use chrono::Utc; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use sha2::Digest; -use sha2::Sha256; -use std::collections::HashMap; -use std::fmt::Debug; -use std::fs::File; -use std::fs::OpenOptions; -use std::io::Read; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use tracing::warn; - -use crate::TokenData; -use codex_app_server_protocol::AuthMode; -use codex_keyring_store::DefaultKeyringStore; -use codex_keyring_store::KeyringStore; -use once_cell::sync::Lazy; - -/// Determine where Codex should store CLI auth credentials. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum AuthCredentialsStoreMode { - #[default] - /// Persist credentials in CODEX_HOME/auth.json. - File, - /// Persist credentials in the keyring. Fail if unavailable. - Keyring, - /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. - Auto, - /// Store credentials in memory only for the current process. - Ephemeral, -} - -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_mode: Option, - - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - -pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") -} - -pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - match std::fs::remove_file(&auth_file) { - Ok(()) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err), - } -} - -pub(super) trait AuthStorageBackend: Debug + Send + Sync { - fn load(&self) -> std::io::Result>; - fn save(&self, auth: &AuthDotJson) -> std::io::Result<()>; - fn delete(&self) -> std::io::Result; -} - -#[derive(Clone, Debug)] -pub(super) struct FileAuthStorage { - codex_home: PathBuf, -} - -impl FileAuthStorage { - pub(super) fn new(codex_home: PathBuf) -> Self { - Self { codex_home } - } - - /// Attempt to read and parse the `auth.json` file in the given `CODEX_HOME` directory. - /// Returns the full AuthDotJson structure. - pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) - } -} - -impl AuthStorageBackend for FileAuthStorage { - fn load(&self) -> std::io::Result> { - let auth_file = get_auth_file(&self.codex_home); - let auth_dot_json = match self.try_read_auth_json(&auth_file) { - Ok(auth) => auth, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err), - }; - Ok(Some(auth_dot_json)) - } - - fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - let auth_file = get_auth_file(&self.codex_home); - - if let Some(parent) = auth_file.parent() { - std::fs::create_dir_all(parent)?; - } - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) - } - - fn delete(&self) -> std::io::Result { - delete_file_if_exists(&self.codex_home) - } -} - -const KEYRING_SERVICE: &str = "Codex Auth"; - -// turns codex_home path into a stable, short key string -fn compute_store_key(codex_home: &Path) -> std::io::Result { - let canonical = codex_home - .canonicalize() - .unwrap_or_else(|_| codex_home.to_path_buf()); - let path_str = canonical.to_string_lossy(); - let mut hasher = Sha256::new(); - hasher.update(path_str.as_bytes()); - let digest = hasher.finalize(); - let hex = format!("{digest:x}"); - let truncated = hex.get(..16).unwrap_or(&hex); - Ok(format!("cli|{truncated}")) -} - -#[derive(Clone, Debug)] -struct KeyringAuthStorage { - codex_home: PathBuf, - keyring_store: Arc, -} - -impl KeyringAuthStorage { - fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { - Self { - codex_home, - keyring_store, - } - } - - fn load_from_keyring(&self, key: &str) -> std::io::Result> { - match self.keyring_store.load(KEYRING_SERVICE, key) { - Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| { - std::io::Error::other(format!( - "failed to deserialize CLI auth from keyring: {err}" - )) - }), - Ok(None) => Ok(None), - Err(error) => Err(std::io::Error::other(format!( - "failed to load CLI auth from keyring: {}", - error.message() - ))), - } - } - - fn save_to_keyring(&self, key: &str, value: &str) -> std::io::Result<()> { - match self.keyring_store.save(KEYRING_SERVICE, key, value) { - Ok(()) => Ok(()), - Err(error) => { - let message = format!( - "failed to write OAuth tokens to keyring: {}", - error.message() - ); - warn!("{message}"); - Err(std::io::Error::other(message)) - } - } - } -} - -impl AuthStorageBackend for KeyringAuthStorage { - fn load(&self) -> std::io::Result> { - let key = compute_store_key(&self.codex_home)?; - self.load_from_keyring(&key) - } - - fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { - let key = compute_store_key(&self.codex_home)?; - // Simpler error mapping per style: prefer method reference over closure - let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?; - self.save_to_keyring(&key, &serialized)?; - if let Err(err) = delete_file_if_exists(&self.codex_home) { - warn!("failed to remove CLI auth fallback file: {err}"); - } - Ok(()) - } - - fn delete(&self) -> std::io::Result { - let key = compute_store_key(&self.codex_home)?; - let keyring_removed = self - .keyring_store - .delete(KEYRING_SERVICE, &key) - .map_err(|err| { - std::io::Error::other(format!("failed to delete auth from keyring: {err}")) - })?; - let file_removed = delete_file_if_exists(&self.codex_home)?; - Ok(keyring_removed || file_removed) - } -} - -#[derive(Clone, Debug)] -struct AutoAuthStorage { - keyring_storage: Arc, - file_storage: Arc, -} - -impl AutoAuthStorage { - fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { - Self { - keyring_storage: Arc::new(KeyringAuthStorage::new(codex_home.clone(), keyring_store)), - file_storage: Arc::new(FileAuthStorage::new(codex_home)), - } - } -} - -impl AuthStorageBackend for AutoAuthStorage { - fn load(&self) -> std::io::Result> { - match self.keyring_storage.load() { - Ok(Some(auth)) => Ok(Some(auth)), - Ok(None) => self.file_storage.load(), - Err(err) => { - warn!("failed to load CLI auth from keyring, falling back to file storage: {err}"); - self.file_storage.load() - } - } - } - - fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { - match self.keyring_storage.save(auth) { - Ok(()) => Ok(()), - Err(err) => { - warn!("failed to save auth to keyring, falling back to file storage: {err}"); - self.file_storage.save(auth) - } - } - } - - fn delete(&self) -> std::io::Result { - // Keyring storage will delete from disk as well - self.keyring_storage.delete() - } -} - -// A global in-memory store for mapping codex_home -> AuthDotJson. -static EPHEMERAL_AUTH_STORE: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -#[derive(Clone, Debug)] -struct EphemeralAuthStorage { - codex_home: PathBuf, -} - -impl EphemeralAuthStorage { - fn new(codex_home: PathBuf) -> Self { - Self { codex_home } - } - - fn with_store(&self, action: F) -> std::io::Result - where - F: FnOnce(&mut HashMap, String) -> std::io::Result, - { - let key = compute_store_key(&self.codex_home)?; - let mut store = EPHEMERAL_AUTH_STORE - .lock() - .map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?; - action(&mut store, key) - } -} - -impl AuthStorageBackend for EphemeralAuthStorage { - fn load(&self) -> std::io::Result> { - self.with_store(|store, key| Ok(store.get(&key).cloned())) - } - - fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { - self.with_store(|store, key| { - store.insert(key, auth.clone()); - Ok(()) - }) - } - - fn delete(&self) -> std::io::Result { - self.with_store(|store, key| Ok(store.remove(&key).is_some())) - } -} - -pub(super) fn create_auth_storage( - codex_home: PathBuf, - mode: AuthCredentialsStoreMode, -) -> Arc { - let keyring_store: Arc = Arc::new(DefaultKeyringStore); - create_auth_storage_with_keyring_store(codex_home, mode, keyring_store) -} - -fn create_auth_storage_with_keyring_store( - codex_home: PathBuf, - mode: AuthCredentialsStoreMode, - keyring_store: Arc, -) -> Arc { - match mode { - AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)), - AuthCredentialsStoreMode::Keyring => { - Arc::new(KeyringAuthStorage::new(codex_home, keyring_store)) - } - AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)), - AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)), - } -} - -#[cfg(test)] -#[path = "storage_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/auth/storage_tests.rs b/codex-rs/core/src/auth/storage_tests.rs deleted file mode 100644 index ff989f689b..0000000000 --- a/codex-rs/core/src/auth/storage_tests.rs +++ /dev/null @@ -1,416 +0,0 @@ -use super::*; -use crate::IdTokenInfo; -use crate::TokenData; -use anyhow::Context; -use base64::Engine; -use pretty_assertions::assert_eq; -use serde_json::json; -use tempfile::tempdir; - -use codex_keyring_store::tests::MockKeyringStore; -use keyring::Error as KeyringError; - -#[tokio::test] -async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("test-key".to_string()), - tokens: None, - last_refresh: Some(Utc::now()), - }; - - storage - .save(&auth_dot_json) - .context("failed to save auth file")?; - - let loaded = storage.load().context("failed to load auth file")?; - assert_eq!(Some(auth_dot_json), loaded); - Ok(()) -} - -#[tokio::test] -async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("test-key".to_string()), - tokens: None, - last_refresh: Some(Utc::now()), - }; - - let file = get_auth_file(codex_home.path()); - storage - .save(&auth_dot_json) - .context("failed to save auth file")?; - - let same_auth_dot_json = storage - .try_read_auth_json(&file) - .context("failed to read auth file after save")?; - assert_eq!(auth_dot_json, same_auth_dot_json); - Ok(()) -} - -#[test] -fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { - let dir = tempdir()?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("sk-test-key".to_string()), - tokens: None, - last_refresh: None, - }; - let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); - storage.save(&auth_dot_json)?; - assert!(dir.path().join("auth.json").exists()); - let storage = FileAuthStorage::new(dir.path().to_path_buf()); - let removed = storage.delete()?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - Ok(()) -} - -#[test] -fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> { - let dir = tempdir()?; - let storage = create_auth_storage( - dir.path().to_path_buf(), - AuthCredentialsStoreMode::Ephemeral, - ); - let auth_dot_json = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("sk-ephemeral".to_string()), - tokens: None, - last_refresh: Some(Utc::now()), - }; - - storage.save(&auth_dot_json)?; - let loaded = storage.load()?; - assert_eq!(Some(auth_dot_json), loaded); - - let removed = storage.delete()?; - assert!(removed); - let loaded = storage.load()?; - assert_eq!(None, loaded); - assert!(!get_auth_file(dir.path()).exists()); - Ok(()) -} - -fn seed_keyring_and_fallback_auth_file_for_delete( - mock_keyring: &MockKeyringStore, - codex_home: &Path, - compute_key: F, -) -> anyhow::Result<(String, PathBuf)> -where - F: FnOnce() -> std::io::Result, -{ - let key = compute_key()?; - mock_keyring.save(KEYRING_SERVICE, &key, "{}")?; - let auth_file = get_auth_file(codex_home); - std::fs::write(&auth_file, "stale")?; - Ok((key, auth_file)) -} - -fn seed_keyring_with_auth( - mock_keyring: &MockKeyringStore, - compute_key: F, - auth: &AuthDotJson, -) -> anyhow::Result<()> -where - F: FnOnce() -> std::io::Result, -{ - let key = compute_key()?; - let serialized = serde_json::to_string(auth)?; - mock_keyring.save(KEYRING_SERVICE, &key, &serialized)?; - Ok(()) -} - -fn assert_keyring_saved_auth_and_removed_fallback( - mock_keyring: &MockKeyringStore, - key: &str, - codex_home: &Path, - expected: &AuthDotJson, -) { - let saved_value = mock_keyring - .saved_value(key) - .expect("keyring entry should exist"); - let expected_serialized = serde_json::to_string(expected).expect("serialize expected auth"); - assert_eq!(saved_value, expected_serialized); - let auth_file = get_auth_file(codex_home); - assert!( - !auth_file.exists(), - "fallback auth.json should be removed after keyring save" - ); -} - -fn id_token_with_prefix(prefix: &str) -> IdTokenInfo { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = json!({ - "email": format!("{prefix}@example.com"), - "https://api.openai.com/auth": { - "chatgpt_account_id": format!("{prefix}-account"), - }, - }); - 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"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - crate::parse_chatgpt_jwt_claims(&fake_jwt).expect("fake JWT should parse") -} - -fn auth_with_prefix(prefix: &str) -> AuthDotJson { - AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some(format!("{prefix}-api-key")), - tokens: Some(TokenData { - id_token: id_token_with_prefix(prefix), - access_token: format!("{prefix}-access"), - refresh_token: format!("{prefix}-refresh"), - account_id: Some(format!("{prefix}-account-id")), - }), - last_refresh: None, - } -} - -#[test] -fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let expected = AuthDotJson { - auth_mode: Some(AuthMode::ApiKey), - openai_api_key: Some("sk-test".to_string()), - tokens: None, - last_refresh: None, - }; - seed_keyring_with_auth( - &mock_keyring, - || compute_store_key(codex_home.path()), - &expected, - )?; - - let loaded = storage.load()?; - assert_eq!(Some(expected), loaded); - Ok(()) -} - -#[test] -fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result<()> { - let codex_home = PathBuf::from("~/.codex"); - - let key = compute_store_key(codex_home.as_path())?; - - assert_eq!(key, "cli|940db7b1d0e4eb40"); - Ok(()) -} - -#[test] -fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let auth_file = get_auth_file(codex_home.path()); - std::fs::write(&auth_file, "stale")?; - let auth = AuthDotJson { - auth_mode: Some(AuthMode::Chatgpt), - openai_api_key: None, - tokens: Some(TokenData { - id_token: Default::default(), - access_token: "access".to_string(), - refresh_token: "refresh".to_string(), - account_id: Some("account".to_string()), - }), - last_refresh: Some(Utc::now()), - }; - - storage.save(&auth)?; - - let key = compute_store_key(codex_home.path())?; - assert_keyring_saved_auth_and_removed_fallback(&mock_keyring, &key, codex_home.path(), &auth); - Ok(()) -} - -#[test] -fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let (key, auth_file) = - seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || { - compute_store_key(codex_home.path()) - })?; - - let removed = storage.delete()?; - - assert!(removed, "delete should report removal"); - assert!( - !mock_keyring.contains(&key), - "keyring entry should be removed" - ); - assert!( - !auth_file.exists(), - "fallback auth.json should be removed after keyring delete" - ); - Ok(()) -} - -#[test] -fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let keyring_auth = auth_with_prefix("keyring"); - seed_keyring_with_auth( - &mock_keyring, - || compute_store_key(codex_home.path()), - &keyring_auth, - )?; - - let file_auth = auth_with_prefix("file"); - storage.file_storage.save(&file_auth)?; - - let loaded = storage.load()?; - assert_eq!(loaded, Some(keyring_auth)); - Ok(()) -} - -#[test] -fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring)); - - let expected = auth_with_prefix("file-only"); - storage.file_storage.save(&expected)?; - - let loaded = storage.load()?; - assert_eq!(loaded, Some(expected)); - Ok(()) -} - -#[test] -fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let key = compute_store_key(codex_home.path())?; - mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into())); - - let expected = auth_with_prefix("fallback"); - storage.file_storage.save(&expected)?; - - let loaded = storage.load()?; - assert_eq!(loaded, Some(expected)); - Ok(()) -} - -#[test] -fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let key = compute_store_key(codex_home.path())?; - - let stale = auth_with_prefix("stale"); - storage.file_storage.save(&stale)?; - - let expected = auth_with_prefix("to-save"); - storage.save(&expected)?; - - assert_keyring_saved_auth_and_removed_fallback( - &mock_keyring, - &key, - codex_home.path(), - &expected, - ); - Ok(()) -} - -#[test] -fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let key = compute_store_key(codex_home.path())?; - mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into())); - - let auth = auth_with_prefix("fallback"); - storage.save(&auth)?; - - let auth_file = get_auth_file(codex_home.path()); - assert!( - auth_file.exists(), - "fallback auth.json should be created when keyring save fails" - ); - let saved = storage - .file_storage - .load()? - .context("fallback auth should exist")?; - assert_eq!(saved, auth); - assert!( - mock_keyring.saved_value(&key).is_none(), - "keyring should not contain value when save fails" - ); - Ok(()) -} - -#[test] -fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { - let codex_home = tempdir()?; - let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new( - codex_home.path().to_path_buf(), - Arc::new(mock_keyring.clone()), - ); - let (key, auth_file) = - seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), || { - compute_store_key(codex_home.path()) - })?; - - let removed = storage.delete()?; - - assert!(removed, "delete should report removal"); - assert!( - !mock_keyring.contains(&key), - "keyring entry should be removed" - ); - assert!( - !auth_file.exists(), - "fallback auth.json should be removed after delete" - ); - Ok(()) -} diff --git a/codex-rs/core/src/auth_tests.rs b/codex-rs/core/src/auth_tests.rs index 44a8fc06ce..1bde3ea7b6 100644 --- a/codex-rs/core/src/auth_tests.rs +++ b/codex-rs/core/src/auth_tests.rs @@ -3,10 +3,10 @@ use crate::IdTokenInfo; use crate::KnownPlan as InternalKnownPlan; use crate::PlanType as InternalPlanType; use crate::TokenData; -use crate::auth::storage::FileAuthStorage; -use crate::auth::storage::get_auth_file; use crate::config::Config; use crate::config::ConfigBuilder; +use codex_core_auth::FileAuthStorage; +use codex_core_auth::get_auth_file; use codex_protocol::account::PlanType as AccountPlanType; use base64::Engine;