mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## What changed - Select fresh authentication for each HTTP operation on eligible OpenAI MCP connections while preserving the connection's credential source and account network policy. Stop redirects when requests carry runtime assertions. - Pass the owning thread's authentication and runtime context through app discovery, connector metadata, and trusted-access requests. Include the runtime authentication mode in MCP connection and tool-cache identities. - Give resumed actors analytics clients bound to their own authentication context, and serialize stored-credential reloads with token refresh while preserving host-provided credentials. ## Testing Add coverage for fresh request assertions, revoked account policies, redirect handling, resumed-actor isolation, and MCP uploads in dual-header and assertion-only modes. GitOrigin-RevId: f06900a6db61cd6cf53c3aa8075afeec4d5706d9
3459 lines
118 KiB
Rust
3459 lines
118 KiB
Rust
use super::*;
|
|
use crate::auth::storage::FileAuthStorage;
|
|
use crate::auth::storage::get_auth_file;
|
|
use crate::token_data::IdTokenInfo;
|
|
use codex_protocol::account::PlanType as AccountPlanType;
|
|
use codex_protocol::auth::AuthMode;
|
|
use codex_protocol::auth::KnownPlan as InternalKnownPlan;
|
|
use codex_protocol::auth::PlanType as InternalPlanType;
|
|
use codex_protocol::protocol::SessionSource;
|
|
|
|
use anyhow::Context;
|
|
use base64::Engine;
|
|
use codex_protocol::config_types::ForcedLoginMethod;
|
|
use codex_protocol::config_types::ModelProviderAuthInfo;
|
|
use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR;
|
|
use codex_protocol::shell_environment::OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR;
|
|
use pretty_assertions::assert_eq;
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use tempfile::TempDir;
|
|
use tempfile::tempdir;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::body_partial_json;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";
|
|
const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001";
|
|
const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";
|
|
|
|
#[test]
|
|
fn header_auth_exposes_a_valid_chatgpt_account_id() {
|
|
for (account_id_header, expected_account_id) in [
|
|
(
|
|
Some(WORKSPACE_ID_ALLOWED.as_bytes()),
|
|
Some(WORKSPACE_ID_ALLOWED),
|
|
),
|
|
(None, None),
|
|
(Some(b""), None),
|
|
(Some(b" account "), None),
|
|
(Some(&[0xff]), None),
|
|
] {
|
|
let mut headers = http::HeaderMap::new();
|
|
if let Some(account_id_header) = account_id_header {
|
|
headers.insert(
|
|
"chatgpt-account-id",
|
|
http::HeaderValue::from_bytes(account_id_header).expect("valid header bytes"),
|
|
);
|
|
}
|
|
|
|
let auth = CodexAuth::Headers(AuthHeaders::new(headers));
|
|
assert_eq!(auth.get_account_id().as_deref(), expected_account_id);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_refresh_marks_only_started_exchanges_as_unsettled() -> anyhow::Result<()> {
|
|
let home = tempdir()?;
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".into()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.into()),
|
|
},
|
|
home.path(),
|
|
)?;
|
|
let server = MockServer::start().await;
|
|
let storage = FileAuthStorage::new(home.path().to_path_buf());
|
|
let mut stored = storage.load()?.context("stored credentials")?;
|
|
let tokens = stored.tokens.as_mut().context("stored tokens")?;
|
|
tokens.oauth_provenance = Some(AccountOAuthProvenance {
|
|
issuer: server.uri(),
|
|
client_id: "codex-client".into(),
|
|
});
|
|
// Workspace switching retains the original ID and refresh tokens.
|
|
tokens.account_id = Some(WORKSPACE_ID_SECOND_ALLOWED.into());
|
|
tokens.access_token = fake_jwt_for_auth_file_params(&AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("business".into()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_SECOND_ALLOWED.into()),
|
|
})?;
|
|
storage.save(&stored)?;
|
|
let manager = Arc::new(
|
|
AuthManager::new_from_auth_config(
|
|
build_config(
|
|
home.path(),
|
|
/*forced_login_method*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await,
|
|
/*enable_codex_api_key_env*/ false,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await,
|
|
);
|
|
let initial = manager.auth_cached().context("initial auth")?;
|
|
|
|
// Poll directly while the guard is held, then cancel before any OAuth request.
|
|
let guard = manager.refresh_lock.acquire().await?;
|
|
let mut waiting = Box::pin(manager.refresh_token_if_unchanged(&initial));
|
|
assert!(
|
|
waiting
|
|
.as_mut()
|
|
.poll(&mut std::task::Context::from_waker(
|
|
std::task::Waker::noop(),
|
|
))
|
|
.is_pending()
|
|
);
|
|
drop(waiting);
|
|
assert!(!manager.is_chatgpt_refresh_unsettled(&initial)?);
|
|
assert!(server.received_requests().await.unwrap().is_empty());
|
|
drop(guard);
|
|
|
|
let received = Arc::new(tokio::sync::Notify::new());
|
|
let notify = Arc::clone(&received);
|
|
Mock::given(path("/oauth/token"))
|
|
.respond_with(move |_: &wiremock::Request| {
|
|
notify.notify_one();
|
|
ResponseTemplate::new(200)
|
|
.set_delay(Duration::from_secs(60))
|
|
.set_body_json(json!({"access_token": "late", "refresh_token": "late-refresh"}))
|
|
})
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let refreshing = Arc::clone(&manager);
|
|
let task = tokio::spawn(async move { refreshing.refresh_token_from_authority().await });
|
|
received.notified().await;
|
|
assert!(manager.is_chatgpt_refresh_unsettled(&initial)?);
|
|
task.abort();
|
|
assert!(task.await.unwrap_err().is_cancelled());
|
|
assert!(manager.is_chatgpt_refresh_unsettled(&initial)?);
|
|
|
|
// A later known failure cannot resolve an earlier cancelled exchange.
|
|
server.reset().await;
|
|
Mock::given(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(500))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
assert!(manager.refresh_token_from_authority().await.is_err());
|
|
assert!(manager.is_chatgpt_refresh_unsettled(&initial)?);
|
|
|
|
// A replacement is usable, and a completed refresh settles its own generation.
|
|
server.reset().await;
|
|
Mock::given(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "completed", "refresh_token": "completed-refresh",
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let tokens = stored.tokens.as_mut().context("replacement tokens")?;
|
|
tokens.access_token = "replacement".into();
|
|
tokens.refresh_token = "replacement-refresh".into();
|
|
storage.save(&stored)?;
|
|
manager.reload().await;
|
|
let replacement = manager.auth_cached().context("replacement auth")?;
|
|
assert!(!manager.is_chatgpt_refresh_unsettled(&replacement)?);
|
|
manager.refresh_token_from_authority().await?;
|
|
assert!(!manager.is_chatgpt_refresh_unsettled(&replacement)?);
|
|
assert_eq!(
|
|
manager
|
|
.auth_cached()
|
|
.context("refreshed auth")?
|
|
.get_token()?,
|
|
"completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_without_id_token() -> anyhow::Result<()> {
|
|
let codex_home = tempdir()?;
|
|
let fake_jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)?;
|
|
|
|
let storage = create_auth_storage(
|
|
codex_home.path().to_path_buf(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
);
|
|
let provenance = AccountOAuthProvenance {
|
|
issuer: "https://idp.example".to_string(),
|
|
client_id: "codex-client".to_string(),
|
|
};
|
|
let initial = storage
|
|
.load()?
|
|
.context("stored auth")?
|
|
.tokens
|
|
.context("stored tokens")?;
|
|
let updated = super::persist_tokens(
|
|
&storage,
|
|
&initial,
|
|
Some(provenance.clone()),
|
|
/*id_token*/ None,
|
|
Some("new-access-token".to_string()),
|
|
Some("new-refresh-token".to_string()),
|
|
)?;
|
|
|
|
let tokens = updated.tokens.context("persisted auth is missing tokens")?;
|
|
assert_eq!(tokens.id_token.raw_jwt, fake_jwt);
|
|
assert_eq!(tokens.access_token, "new-access-token");
|
|
assert_eq!(tokens.refresh_token, "new-refresh-token");
|
|
assert_eq!(tokens.oauth_provenance, Some(provenance.clone()));
|
|
|
|
let updated = super::persist_tokens(
|
|
&storage,
|
|
&tokens,
|
|
/*provenance*/ None,
|
|
/*id_token*/ None,
|
|
Some("later-access-token".to_string()),
|
|
/*refresh_token*/ None,
|
|
)?;
|
|
let previous = tokens;
|
|
let tokens = updated.tokens.context("persisted auth is missing tokens")?;
|
|
assert_eq!(tokens.refresh_token, "new-refresh-token");
|
|
assert_eq!(tokens.oauth_provenance, None);
|
|
|
|
// Reject this stale refresh response: newer tokens have already been saved
|
|
// for the same account.
|
|
let before = storage.load()?;
|
|
assert!(
|
|
super::persist_tokens(
|
|
&storage,
|
|
&previous,
|
|
Some(provenance),
|
|
/*id_token*/ None,
|
|
Some("stale-access-token".into()),
|
|
Some("stale-refresh-token".into()),
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(storage.load()?, before);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn login_with_api_key_overwrites_existing_auth_json() {
|
|
let dir = tempdir().unwrap();
|
|
let auth_path = dir.path().join("auth.json");
|
|
let stale_auth = json!({
|
|
"OPENAI_API_KEY": "sk-old",
|
|
"tokens": {
|
|
"id_token": "stale.header.payload",
|
|
"access_token": "stale-access",
|
|
"refresh_token": "stale-refresh",
|
|
"account_id": "stale-acc"
|
|
}
|
|
});
|
|
for api_key in [
|
|
"sk-new",
|
|
"sk-proj-fedramp-synthetic-test-key",
|
|
"sk-svcacct-fedramp-synthetic-test-key",
|
|
] {
|
|
std::fs::write(
|
|
&auth_path,
|
|
serde_json::to_string_pretty(&stale_auth).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
super::login_with_api_key(
|
|
dir.path(),
|
|
api_key,
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.expect("login_with_api_key should succeed");
|
|
|
|
let storage = FileAuthStorage::new(dir.path().to_path_buf());
|
|
let auth = storage
|
|
.try_read_auth_json(&auth_path)
|
|
.expect("auth.json should parse");
|
|
assert_eq!(auth.openai_api_key.as_deref(), Some(api_key));
|
|
assert!(auth.tokens.is_none(), "tokens should be cleared");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn login_with_access_token_writes_agent_identity_jwt() {
|
|
let dir = tempdir().unwrap();
|
|
let auth_path = dir.path().join("auth.json");
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
|
|
super::login_with_access_token(
|
|
dir.path(),
|
|
&agent_identity,
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
Some(&chatgpt_base_url),
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("login_with_access_token should succeed");
|
|
|
|
let storage = FileAuthStorage::new(dir.path().to_path_buf());
|
|
let auth = storage
|
|
.try_read_auth_json(&auth_path)
|
|
.expect("auth.json should parse");
|
|
assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity));
|
|
assert_eq!(
|
|
auth.agent_identity,
|
|
Some(AgentIdentityStorage::Jwt(agent_identity))
|
|
);
|
|
assert!(auth.tokens.is_none(), "tokens should be cleared");
|
|
assert!(auth.openai_api_key.is_none(), "API key should be cleared");
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_with_access_token_rejects_agent_identity_workspace_mismatch() {
|
|
let dir = tempdir().unwrap();
|
|
let record = agent_identity_record(WORKSPACE_ID_DISALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let server = MockServer::start().await;
|
|
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
|
let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()];
|
|
|
|
let err = super::login_with_access_token(
|
|
dir.path(),
|
|
&agent_identity,
|
|
AuthCredentialsStoreMode::File,
|
|
Some(&allowed_workspaces),
|
|
Some(&chatgpt_base_url),
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("agent identity workspace mismatch should fail");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
|
assert!(!get_auth_file(dir.path()).exists());
|
|
assert!(server.received_requests().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn agent_identity_jwt_uses_explicit_staging_endpoint_overrides() -> anyhow::Result<()> {
|
|
let jwks_server = MockServer::start().await;
|
|
let authapi_server = MockServer::start().await;
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let jwt = signed_agent_identity_jwt(&record, json!(record.plan_type))?;
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/codex/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&jwks_server)
|
|
.await;
|
|
mock_agent_task_registration(
|
|
&authapi_server,
|
|
"/api/accounts",
|
|
&record.agent_runtime_id,
|
|
"task-id",
|
|
)
|
|
.await;
|
|
let authapi_base_url = format!("{}/api/accounts/", authapi_server.uri());
|
|
let _authapi_guard =
|
|
EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url);
|
|
let jwks_base_url = format!("{}/api/codex/", jwks_server.uri());
|
|
let _jwks_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_JWKS_BASE_URL", &jwks_base_url);
|
|
|
|
let auth = CodexAuth::from_agent_identity_jwt(
|
|
&jwt,
|
|
Some(ChatGptEnvironment::Staging.chatgpt_base_url()),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await?;
|
|
|
|
let CodexAuth::AgentIdentity(agent_identity) = auth else {
|
|
panic!("JWT should load as agent identity auth");
|
|
};
|
|
assert_eq!(agent_identity.run_task_id(), "task-id");
|
|
jwks_server.verify().await;
|
|
authapi_server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn agent_identity_jwt_supports_existing_staging_launcher() -> anyhow::Result<()> {
|
|
let jwks_server = MockServer::start().await;
|
|
let authapi_server = MockServer::start().await;
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let jwt = signed_agent_identity_jwt(&record, json!(record.plan_type))?;
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/codex/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&jwks_server)
|
|
.await;
|
|
mock_agent_task_registration(
|
|
&authapi_server,
|
|
"/api/accounts",
|
|
&record.agent_runtime_id,
|
|
"task-id",
|
|
)
|
|
.await;
|
|
let authapi_base_url = format!("{}/api/accounts", authapi_server.uri());
|
|
let _authapi_guard =
|
|
EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url);
|
|
let jwks_base_url = format!("{}/api/codex", jwks_server.uri());
|
|
let _jwks_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_JWKS_BASE_URL", &jwks_base_url);
|
|
|
|
let auth = CodexAuth::from_agent_identity_jwt(
|
|
&jwt,
|
|
Some(&jwks_base_url),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await?;
|
|
|
|
let CodexAuth::AgentIdentity(agent_identity) = auth else {
|
|
panic!("JWT should load as agent identity auth");
|
|
};
|
|
assert_eq!(agent_identity.run_task_id(), "task-id");
|
|
jwks_server.verify().await;
|
|
authapi_server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial(codex_auth_env)]
|
|
fn agent_identity_authapi_override_preserves_chatgpt_environment_validation() {
|
|
let _authapi_guard = EnvVarGuard::set(
|
|
"CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL",
|
|
"https://authapi.example/api/accounts",
|
|
);
|
|
let _jwks_guard = EnvVarGuard::set(
|
|
"CODEX_AGENT_IDENTITY_JWKS_BASE_URL",
|
|
"https://jwks.example/api/codex",
|
|
);
|
|
|
|
let error = agent_identity_authapi_base_url(Some("https://attacker.example/backend-api"))
|
|
.expect_err("AuthAPI overrides must not bypass ChatGPT environment validation");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"Agent Identity only supports production and staging ChatGPT environments"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial(codex_auth_env)]
|
|
fn agent_identity_custom_jwks_base_requires_explicit_authapi_override() {
|
|
let _authapi_guard = EnvVarGuard::remove("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL");
|
|
let jwks_base_url = "https://jwks.example/api/codex";
|
|
let _jwks_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_JWKS_BASE_URL", jwks_base_url);
|
|
|
|
let error = agent_identity_authapi_base_url(Some(jwks_base_url))
|
|
.expect_err("custom JWKS bases must also explicitly configure AuthAPI");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"Agent Identity only supports production and staging ChatGPT environments"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn agent_identity_jwks_override_preserves_chatgpt_environment_validation() {
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let jwt =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let _jwks_guard = EnvVarGuard::set(
|
|
"CODEX_AGENT_IDENTITY_JWKS_BASE_URL",
|
|
"https://jwks.example/api/codex",
|
|
);
|
|
|
|
let error = verified_record_from_jwt(
|
|
&jwt,
|
|
"https://attacker.example/backend-api",
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("JWKS overrides must not bypass ChatGPT environment validation");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"Agent Identity only supports production and staging ChatGPT environments"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> {
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let codex_home = tempdir()?;
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
mock_agent_task_registration(&server, "", &record.agent_runtime_id, "task-id").await;
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
save_auth(
|
|
codex_home.path(),
|
|
&AuthDotJson {
|
|
auth_mode: Some(AuthMode::AgentIdentity),
|
|
openai_api_key: None,
|
|
tokens: None,
|
|
last_refresh: None,
|
|
agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity.clone())),
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
},
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::Direct,
|
|
)?;
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
Some(&chatgpt_base_url),
|
|
AuthKeyringBackendKind::Direct,
|
|
Some(&authapi_base_url),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should load");
|
|
|
|
let CodexAuth::AgentIdentity(agent_identity_auth) = auth else {
|
|
panic!("stored JWT should load as agent identity auth");
|
|
};
|
|
assert_eq!(agent_identity_auth.run_task_id(), "task-id");
|
|
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
|
let auth = storage
|
|
.try_read_auth_json(&get_auth_file(codex_home.path()))
|
|
.expect("auth.json should parse");
|
|
assert_eq!(
|
|
auth.agent_identity,
|
|
Some(AgentIdentityStorage::Jwt(agent_identity))
|
|
);
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn login_with_access_token_writes_only_personal_access_token() {
|
|
let dir = tempdir().unwrap();
|
|
let auth_path = dir.path().join("auth.json");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.and(header("authorization", "Bearer at-login-test"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()];
|
|
super::login_with_access_token(
|
|
dir.path(),
|
|
"at-login-test",
|
|
AuthCredentialsStoreMode::File,
|
|
Some(&allowed_workspaces),
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("personal access token login should succeed");
|
|
|
|
let storage = FileAuthStorage::new(dir.path().to_path_buf());
|
|
let auth = storage
|
|
.try_read_auth_json(&auth_path)
|
|
.expect("auth.json should parse");
|
|
assert_eq!(
|
|
auth,
|
|
AuthDotJson {
|
|
auth_mode: None,
|
|
openai_api_key: None,
|
|
tokens: None,
|
|
last_refresh: None,
|
|
agent_identity: None,
|
|
personal_access_token: Some("at-login-test".to_string()),
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
}
|
|
);
|
|
assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken);
|
|
let persisted: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap();
|
|
assert!(persisted.get("auth_mode").is_none());
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn login_with_access_token_rejects_personal_access_token_workspace_mismatch() {
|
|
let dir = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.and(header("authorization", "Bearer at-workspace-mismatch"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()];
|
|
|
|
let err = super::login_with_access_token(
|
|
dir.path(),
|
|
"at-workspace-mismatch",
|
|
AuthCredentialsStoreMode::File,
|
|
Some(&allowed_workspaces),
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("personal access token workspace mismatch should fail");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
|
assert!(
|
|
!get_auth_file(dir.path()).exists(),
|
|
"workspace mismatch should not write auth.json"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn login_with_access_token_rejects_invalid_personal_access_token() {
|
|
let dir = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.respond_with(ResponseTemplate::new(403))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
|
|
let err = super::login_with_access_token(
|
|
dir.path(),
|
|
"at-invalid-login",
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("invalid personal access token should fail");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
|
assert!(
|
|
!get_auth_file(dir.path()).exists(),
|
|
"invalid personal access token should not write auth.json"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_with_access_token_rejects_invalid_jwt() {
|
|
let dir = tempdir().unwrap();
|
|
|
|
let err = super::login_with_access_token(
|
|
dir.path(),
|
|
"not-a-jwt",
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("invalid access token should fail");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
|
assert!(
|
|
!get_auth_file(dir.path()).exists(),
|
|
"invalid access token should not write auth.json"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<()> {
|
|
let codex_home = tempdir()?;
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some("account-123".to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)?;
|
|
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
|
let mut stored = storage.load()?.expect("auth should exist");
|
|
stored.tokens.as_mut().unwrap().login_owner_id = Some("login-a".into());
|
|
storage.save(&stored)?;
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should load");
|
|
|
|
// An older client rewrites the same credentials without login metadata.
|
|
stored.tokens.as_mut().unwrap().login_owner_id = None;
|
|
storage.save(&stored)?;
|
|
|
|
assert!(
|
|
auth.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::JwtOnly,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await?
|
|
.is_none()
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/register"))
|
|
.and(header("authorization", "Bearer test-access-token"))
|
|
.and(body_partial_json(json!({
|
|
"abom": {
|
|
"agent_harness_id": "codex-cli",
|
|
},
|
|
"capabilities": ["responsesapi"],
|
|
"ttl": null,
|
|
})))
|
|
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
|
"agent_runtime_id": "agent-runtime-123",
|
|
})))
|
|
.expect(/*r*/ 1)
|
|
.mount(&server)
|
|
.await;
|
|
mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await;
|
|
|
|
let agent_auth = auth
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await?
|
|
.expect("agent identity should register");
|
|
let reused = auth
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await?
|
|
.expect("agent identity should be reused");
|
|
|
|
assert_eq!(
|
|
agent_auth.record().agent_runtime_id,
|
|
reused.record().agent_runtime_id
|
|
);
|
|
assert_eq!(agent_auth.run_task_id(), "task-123");
|
|
assert_eq!(reused.run_task_id(), "task-123");
|
|
assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123");
|
|
assert_eq!(agent_auth.record().account_id, "account-123");
|
|
assert_eq!(agent_auth.record().chatgpt_user_id, "user-12345");
|
|
assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123"));
|
|
assert_eq!(reused.record().task_id.as_deref(), Some("task-123"));
|
|
let persisted = auth
|
|
.stored_managed_chatgpt_agent_identity_record("account-123")
|
|
.expect("identity should persist");
|
|
assert_eq!(persisted.agent_runtime_id, "agent-runtime-123");
|
|
assert_eq!(persisted.task_id.as_deref(), Some("task-123"));
|
|
stored.agent_identity = Some(AgentIdentityStorage::Record(agent_auth.record().clone()));
|
|
assert_eq!(storage.load()?, Some(stored));
|
|
|
|
let reloaded = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should reload");
|
|
let reloaded_agent_auth = reloaded
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await?
|
|
.expect("agent identity should reload from storage");
|
|
assert_eq!(
|
|
reloaded_agent_auth.record().agent_runtime_id,
|
|
"agent-runtime-123"
|
|
);
|
|
assert_eq!(reloaded_agent_auth.run_task_id(), "task-123");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow::Result<()> {
|
|
let codex_home = tempdir()?;
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some("account-123".to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)?;
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should load");
|
|
|
|
let server = MockServer::start().await;
|
|
let registration_count = Arc::new(AtomicUsize::new(0));
|
|
let response_count = Arc::clone(®istration_count);
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/register"))
|
|
.respond_with(move |_request: &wiremock::Request| {
|
|
if response_count.fetch_add(1, Ordering::SeqCst) < 2 {
|
|
ResponseTemplate::new(/*status*/ 503)
|
|
} else {
|
|
ResponseTemplate::new(/*status*/ 200).set_body_json(json!({
|
|
"agent_runtime_id": "agent-runtime-123",
|
|
}))
|
|
}
|
|
})
|
|
.expect(/*requests*/ 3)
|
|
.mount(&server)
|
|
.await;
|
|
mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await;
|
|
|
|
let agent_auth = auth
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await?
|
|
.expect("agent identity should register after retries");
|
|
|
|
assert_eq!(registration_count.load(Ordering::SeqCst), 3);
|
|
assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123");
|
|
assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123"));
|
|
assert_eq!(
|
|
auth.stored_managed_chatgpt_agent_identity_record("account-123")
|
|
.and_then(|record| record.task_id),
|
|
Some("task-123".to_string())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> {
|
|
let codex_home = tempdir()?;
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some("account-123".to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)?;
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should load");
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/register"))
|
|
.respond_with(ResponseTemplate::new(/*status*/ 503))
|
|
.expect(/*requests*/ 3)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let err = auth
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await
|
|
.expect_err("retry exhaustion should return an error");
|
|
|
|
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_some());
|
|
assert!(
|
|
auth.stored_managed_chatgpt_agent_identity_record("account-123")
|
|
.is_none()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()>
|
|
{
|
|
let codex_home = tempdir()?;
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some("account-123".to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)?;
|
|
let mut record = agent_identity_record("account-123");
|
|
record.chatgpt_user_id = "user-12345".to_string();
|
|
record.email = Some("user@example.com".to_string());
|
|
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
|
let auth_path = get_auth_file(codex_home.path());
|
|
let mut auth_json = storage.try_read_auth_json(&auth_path)?;
|
|
auth_json.agent_identity = Some(AgentIdentityStorage::Record(record.clone()));
|
|
storage.save(&auth_json)?;
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should load");
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"/v1/agent/{}/task/register",
|
|
record.agent_runtime_id
|
|
)))
|
|
.respond_with(ResponseTemplate::new(/*status*/ 503))
|
|
.expect(/*requests*/ 3)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let err = auth
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await
|
|
.expect_err("task retry exhaustion should return an error");
|
|
|
|
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_some());
|
|
record.task_id = None;
|
|
assert_eq!(
|
|
auth.stored_managed_chatgpt_agent_identity_record("account-123"),
|
|
Some(record)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyhow::Result<()> {
|
|
let codex_home = tempdir()?;
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some("account-123".to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)?;
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await?
|
|
.expect("auth should load");
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/register"))
|
|
.respond_with(ResponseTemplate::new(/*status*/ 403))
|
|
.expect(/*requests*/ 1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let err = auth
|
|
.agent_identity_auth(
|
|
AgentIdentityAuthPolicy::ChatGptAuth,
|
|
Some(&server.uri()),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
SessionSource::Cli,
|
|
)
|
|
.await
|
|
.expect_err("hard registration failure should return an error");
|
|
|
|
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_none());
|
|
assert!(
|
|
auth.stored_managed_chatgpt_agent_identity_record("account-123")
|
|
.is_none()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> anyhow::Result<()> {
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"/v1/agent/{}/task/register",
|
|
record.agent_runtime_id
|
|
)))
|
|
.respond_with(ResponseTemplate::new(/*status*/ 503))
|
|
.expect(/*requests*/ 3)
|
|
.mount(&server)
|
|
.await;
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
|
|
let err = CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
|
|
&agent_identity,
|
|
Some(&chatgpt_base_url),
|
|
&authapi_base_url,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("agent identity jwt task retry exhaustion should fail");
|
|
|
|
assert!(AgentIdentityAuthError::bootstrap_unavailable(&err).is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn login_with_access_token_rejects_unsigned_jwt() {
|
|
let dir = tempdir().unwrap();
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
|
|
super::login_with_access_token(
|
|
dir.path(),
|
|
&agent_identity,
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
Some(&chatgpt_base_url),
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect_err("unsigned access token should fail");
|
|
|
|
assert!(
|
|
!get_auth_file(dir.path()).exists(),
|
|
"unsigned access token should not write auth.json"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn missing_auth_json_returns_none() {
|
|
let dir = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let auth = CodexAuth::from_auth_storage(
|
|
dir.path(),
|
|
AuthCredentialsStoreMode::File,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("call should succeed");
|
|
assert_eq!(auth, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let fake_jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(None, auth.api_key());
|
|
assert_eq!(AuthMode::Chatgpt, auth.auth_mode());
|
|
assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-12345"));
|
|
|
|
let auth_dot_json = auth
|
|
.get_current_auth_json()
|
|
.expect("AuthDotJson should exist");
|
|
let last_refresh = auth_dot_json
|
|
.last_refresh
|
|
.expect("last_refresh should be recorded");
|
|
|
|
assert_eq!(
|
|
AuthDotJson {
|
|
auth_mode: None,
|
|
openai_api_key: None,
|
|
tokens: Some(TokenData {
|
|
id_token: IdTokenInfo {
|
|
email: Some("user@example.com".to_string()),
|
|
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
|
|
chatgpt_user_id: Some("user-12345".to_string()),
|
|
chatgpt_account_id: None,
|
|
chatgpt_account_is_fedramp: false,
|
|
raw_jwt: fake_jwt,
|
|
},
|
|
access_token: "test-access-token".to_string(),
|
|
refresh_token: "test-refresh-token".to_string(),
|
|
oauth_provenance: None,
|
|
login_owner_id: None,
|
|
account_id: None,
|
|
}),
|
|
last_refresh: Some(last_refresh),
|
|
agent_identity: None,
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
},
|
|
auth_dot_json
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn loads_api_key_from_auth_json() {
|
|
let dir = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let auth_file = dir.path().join("auth.json");
|
|
for (api_key, expected_fedramp) in [
|
|
("sk-test-key", false),
|
|
("sk-proj-fedramp-synthetic-test-key", true),
|
|
("sk-svcacct-fedramp-synthetic-test-key", true),
|
|
("sk-proj-synthetic-test-key", false),
|
|
("sk-svcacct-synthetic-test-key", false),
|
|
("sk-proj-fedramp", false),
|
|
("sk-svcacct-fedrampish-test-key", false),
|
|
("sk-proj-FEDRAMP-test-key", false),
|
|
("sk-admin-fedramp-test-key", false),
|
|
("other-sk-proj-fedramp-test-key", false),
|
|
] {
|
|
std::fs::write(
|
|
&auth_file,
|
|
json!({"OPENAI_API_KEY": api_key, "tokens": null, "last_refresh": null}).to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
let auth = super::load_auth(
|
|
dir.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(auth.auth_mode(), AuthMode::ApiKey);
|
|
assert_eq!(auth.api_key(), Some(api_key));
|
|
assert_eq!(
|
|
(auth.get_token().unwrap(), auth.is_fedramp_account()),
|
|
(api_key.to_string(), expected_fedramp)
|
|
);
|
|
|
|
assert!(auth.get_token_data().is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn logout_does_not_create_a_missing_file_store() -> std::io::Result<()> {
|
|
let root = tempdir()?;
|
|
let missing_home = root.path().join("missing");
|
|
assert!(!logout(
|
|
&missing_home,
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default()
|
|
)?);
|
|
assert!(!missing_home.exists());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
|
|
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,
|
|
agent_identity: None,
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
};
|
|
super::save_auth(
|
|
dir.path(),
|
|
&auth_dot_json,
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
let auth_file = get_auth_file(dir.path());
|
|
assert!(auth_file.exists());
|
|
assert!(logout(
|
|
dir.path(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?);
|
|
assert!(!auth_file.exists());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn unauthorized_recovery_reports_mode_and_step_names() {
|
|
let dir = tempdir().unwrap();
|
|
let manager = AuthManager::shared(
|
|
dir.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await;
|
|
let managed = UnauthorizedRecovery {
|
|
manager: Arc::clone(&manager),
|
|
step: UnauthorizedRecoveryStep::Reload,
|
|
expected_account_id: None,
|
|
mode: UnauthorizedRecoveryMode::Managed,
|
|
};
|
|
assert_eq!(managed.mode_name(), "managed");
|
|
assert_eq!(managed.step_name(), "reload");
|
|
|
|
let external = UnauthorizedRecovery {
|
|
manager,
|
|
step: UnauthorizedRecoveryStep::ExternalRefresh,
|
|
expected_account_id: None,
|
|
mode: UnauthorizedRecoveryMode::External,
|
|
};
|
|
assert_eq!(external.mode_name(), "external");
|
|
assert_eq!(external.step_name(), "external_refresh");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("load auth")
|
|
.expect("auth available");
|
|
let mut updated_auth_dot_json = auth
|
|
.get_current_auth_json()
|
|
.expect("AuthDotJson should exist");
|
|
let updated_tokens = updated_auth_dot_json
|
|
.tokens
|
|
.as_mut()
|
|
.expect("tokens should exist");
|
|
updated_tokens.access_token = "new-access-token".to_string();
|
|
updated_tokens.refresh_token = "new-refresh-token".to_string();
|
|
let updated_auth = CodexAuth::from_auth_dot_json(
|
|
codex_home.path(),
|
|
updated_auth_dot_json,
|
|
AuthCredentialsStoreMode::File,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
)
|
|
.await
|
|
.expect("updated auth should parse");
|
|
|
|
let manager = AuthManager::from_auth_for_testing(auth.clone());
|
|
let error = RefreshTokenFailedError::new(
|
|
RefreshTokenFailedReason::Exhausted,
|
|
"refresh token already used",
|
|
);
|
|
manager.record_permanent_refresh_failure_if_unchanged(&auth, &error);
|
|
|
|
assert_eq!(manager.refresh_failure_for_auth(&auth), Some(error));
|
|
assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_bearer_only_auth_manager_uses_cached_provider_token() {
|
|
let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap();
|
|
let manager = AuthManager::external_bearer_only(script.auth_config());
|
|
|
|
let first = manager
|
|
.auth()
|
|
.await
|
|
.and_then(|auth| auth.api_key().map(str::to_string));
|
|
let second = manager
|
|
.auth()
|
|
.await
|
|
.and_then(|auth| auth.api_key().map(str::to_string));
|
|
|
|
assert_eq!(first.as_deref(), Some("provider-token"));
|
|
assert_eq!(second.as_deref(), Some("provider-token"));
|
|
assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey));
|
|
assert_eq!(manager.get_api_auth_mode(), Some(AuthMode::ApiKey));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_bearer_only_auth_manager_disables_auto_refresh_when_interval_is_zero() {
|
|
let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap();
|
|
let mut auth_config = script.auth_config();
|
|
auth_config.refresh_interval_ms = 0;
|
|
let manager = AuthManager::external_bearer_only(auth_config);
|
|
|
|
let first = manager
|
|
.auth()
|
|
.await
|
|
.and_then(|auth| auth.api_key().map(str::to_string));
|
|
let second = manager
|
|
.auth()
|
|
.await
|
|
.and_then(|auth| auth.api_key().map(str::to_string));
|
|
|
|
assert_eq!(first.as_deref(), Some("provider-token"));
|
|
assert_eq!(second.as_deref(), Some("provider-token"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_bearer_only_auth_manager_returns_none_when_command_fails() {
|
|
let script = ProviderAuthScript::new_failing().unwrap();
|
|
let manager = AuthManager::external_bearer_only(script.auth_config());
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_retries_provider_command_after_initial_failure() {
|
|
let script = ProviderAuthScript::new(&["provider-token"]).unwrap();
|
|
std::fs::write(script.tempdir.path().join("fail-once"), "").unwrap();
|
|
let manager = AuthManager::external_bearer_only(script.auth_config());
|
|
let mut recovery = manager.unauthorized_recovery();
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
assert_eq!(manager.auth_cached(), None);
|
|
assert!(recovery.has_next());
|
|
assert_eq!(recovery.unavailable_reason(), "ready");
|
|
|
|
let result = recovery
|
|
.next()
|
|
.await
|
|
.expect("external refresh should succeed");
|
|
|
|
assert_eq!(result.auth_state_changed(), Some(true));
|
|
assert_eq!(
|
|
manager.auth_cached(),
|
|
Some(CodexAuth::from_api_key("provider-token"))
|
|
);
|
|
assert!(!recovery.has_next());
|
|
assert_eq!(recovery.unavailable_reason(), "recovery_exhausted");
|
|
recovery.next().await.expect_err("recovery is bounded");
|
|
}
|
|
|
|
#[test]
|
|
fn unauthorized_recovery_without_an_external_provider_still_requires_refreshable_auth() {
|
|
for auth in [None, Some(CodexAuth::from_api_key("static-token"))] {
|
|
let manager = AuthManager::from_optional_auth_for_testing(auth);
|
|
let recovery = manager.unauthorized_recovery();
|
|
|
|
assert!(!recovery.has_next());
|
|
assert_eq!(recovery.unavailable_reason(), "not_chatgpt_auth");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
|
|
let script = ProviderAuthScript::new(&["provider-token", "refreshed-provider-token"]).unwrap();
|
|
let mut auth_config = script.auth_config();
|
|
auth_config.refresh_interval_ms = 0;
|
|
let manager = AuthManager::external_bearer_only(auth_config);
|
|
let mut recovery = manager.unauthorized_recovery();
|
|
let initial_token = manager
|
|
.auth()
|
|
.await
|
|
.and_then(|auth| auth.api_key().map(str::to_string));
|
|
|
|
assert!(recovery.has_next());
|
|
assert_eq!(recovery.mode_name(), "external");
|
|
assert_eq!(recovery.step_name(), "external_refresh");
|
|
|
|
let result = recovery
|
|
.next()
|
|
.await
|
|
.expect("external refresh should succeed");
|
|
|
|
assert_eq!(result.auth_state_changed(), Some(true));
|
|
let refreshed_token = manager
|
|
.auth()
|
|
.await
|
|
.and_then(|auth| auth.api_key().map(str::to_string));
|
|
assert_eq!(initial_token.as_deref(), Some("provider-token"));
|
|
assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token"));
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct StaticExternalAuth(CodexAuth);
|
|
|
|
impl ExternalAuth for StaticExternalAuth {
|
|
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Ok(self.0.clone()) })
|
|
}
|
|
|
|
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Ok(self.0.clone()) })
|
|
}
|
|
}
|
|
|
|
struct RefreshingExternalAuth {
|
|
initial: CodexAuth,
|
|
refreshed: CodexAuth,
|
|
}
|
|
|
|
impl ExternalAuth for RefreshingExternalAuth {
|
|
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Ok(self.initial.clone()) })
|
|
}
|
|
|
|
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Ok(self.refreshed.clone()) })
|
|
}
|
|
}
|
|
|
|
fn external_header_auth(account_id: Option<&'static str>) -> CodexAuth {
|
|
let mut headers = http::HeaderMap::new();
|
|
headers.insert(
|
|
http::header::AUTHORIZATION,
|
|
http::HeaderValue::from_static("Bearer external"),
|
|
);
|
|
if let Some(account_id) = account_id {
|
|
headers.insert(
|
|
"chatgpt-account-id",
|
|
http::HeaderValue::from_static(account_id),
|
|
);
|
|
}
|
|
CodexAuth::Headers(AuthHeaders::new(headers))
|
|
}
|
|
|
|
struct FailingExternalAuth {
|
|
auth: CodexAuth,
|
|
resolve_count: AtomicUsize,
|
|
}
|
|
|
|
impl ExternalAuth for FailingExternalAuth {
|
|
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
let resolve_count = self.resolve_count.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async move {
|
|
if resolve_count == 0 {
|
|
Ok(self.auth.clone())
|
|
} else {
|
|
Err(std::io::Error::other("external auth failed"))
|
|
}
|
|
})
|
|
}
|
|
|
|
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
Box::pin(async { Err(std::io::Error::other("external auth failed")) })
|
|
}
|
|
|
|
fn classify_error(&self, error: std::io::Error) -> RefreshTokenError {
|
|
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
|
|
RefreshTokenFailedReason::Other,
|
|
error.to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_auth_keeps_cached_credentials_after_permanent_reload_failure() {
|
|
let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed"));
|
|
let auth = CodexAuth::from_api_key("configured-token");
|
|
let external_auth = Arc::new(FailingExternalAuth {
|
|
auth: auth.clone(),
|
|
resolve_count: AtomicUsize::new(0),
|
|
});
|
|
manager
|
|
.set_external_auth(external_auth.clone())
|
|
.await
|
|
.expect("external auth should install");
|
|
|
|
assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 1);
|
|
|
|
assert_eq!(manager.auth().await, Some(auth.clone()));
|
|
assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 2);
|
|
assert_eq!(
|
|
manager
|
|
.refresh_failure_for_auth(&auth)
|
|
.expect("permanent failure should be recorded")
|
|
.to_string(),
|
|
"external auth failed"
|
|
);
|
|
|
|
assert_eq!(manager.auth().await, Some(auth));
|
|
assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 2);
|
|
}
|
|
|
|
struct RecoveringExternalAuth {
|
|
initial: CodexAuth,
|
|
recovered: CodexAuth,
|
|
resolve_count: AtomicUsize,
|
|
}
|
|
|
|
impl ExternalAuth for RecoveringExternalAuth {
|
|
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
let attempt = self.resolve_count.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async move {
|
|
match attempt {
|
|
0 => Ok(self.initial.clone()),
|
|
1 => Err(std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
"external credential exchange timed out",
|
|
)),
|
|
_ => Ok(self.recovered.clone()),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
|
|
self.resolve()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_auth_keeps_its_identity_through_a_transient_reload_failure() {
|
|
let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None);
|
|
let initial = CodexAuth::from_api_key("initial-token");
|
|
let recovered = CodexAuth::from_api_key("recovered-token");
|
|
manager
|
|
.set_external_auth(Arc::new(RecoveringExternalAuth {
|
|
initial: initial.clone(),
|
|
recovered,
|
|
resolve_count: AtomicUsize::new(0),
|
|
}))
|
|
.await
|
|
.expect("external auth should install");
|
|
let mut changes = manager.auth_change_state_receiver();
|
|
let mut revisions = manager.auth_change_receiver();
|
|
let initial_state = *changes.borrow_and_update();
|
|
let initial_revision = *revisions.borrow_and_update();
|
|
|
|
let retained = manager
|
|
.auth()
|
|
.await
|
|
.expect("cached auth should be retained");
|
|
assert_eq!(retained.api_key(), Some("initial-token"));
|
|
assert_eq!(*changes.borrow(), initial_state);
|
|
assert!(
|
|
!changes
|
|
.has_changed()
|
|
.expect("auth state sender remains alive")
|
|
);
|
|
assert_eq!(*revisions.borrow(), initial_revision);
|
|
assert!(
|
|
!revisions
|
|
.has_changed()
|
|
.expect("auth revision sender remains alive")
|
|
);
|
|
assert!(manager.refresh_failure_for_auth(&initial).is_none());
|
|
|
|
let recovered = manager.auth().await.expect("provider should recover");
|
|
assert_eq!(recovered.api_key(), Some("recovered-token"));
|
|
let cached = manager
|
|
.auth_cached()
|
|
.expect("recovered auth should be cached");
|
|
assert_eq!(cached.api_key(), Some("recovered-token"));
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), changes.changed())
|
|
.await
|
|
.expect("recovery should notify auth state watchers")
|
|
.expect("auth state sender remains alive");
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), revisions.changed())
|
|
.await
|
|
.expect("recovery should notify auth revision watchers")
|
|
.expect("auth revision sender remains alive");
|
|
assert!(changes.borrow().generation > initial_state.generation);
|
|
assert!(*revisions.borrow() > initial_revision);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_policy_change_rejects_pending_transient_auth_fallback() {
|
|
let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None);
|
|
manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()]));
|
|
manager
|
|
.set_external_auth(Arc::new(RecoveringExternalAuth {
|
|
initial: external_header_auth(Some(WORKSPACE_ID_ALLOWED)),
|
|
recovered: external_header_auth(Some(WORKSPACE_ID_SECOND_ALLOWED)),
|
|
resolve_count: AtomicUsize::new(0),
|
|
}))
|
|
.await
|
|
.expect("external auth should install");
|
|
|
|
let generation = manager.auth_change_state_receiver().borrow().generation;
|
|
let fallback = manager
|
|
.load_auth(/*cached_auth*/ None)
|
|
.await
|
|
.expect("external auth fallback should load");
|
|
assert_eq!(
|
|
fallback
|
|
.as_ref()
|
|
.and_then(CodexAuth::get_account_id)
|
|
.as_deref(),
|
|
Some(WORKSPACE_ID_ALLOWED)
|
|
);
|
|
|
|
// Change policy after the fallback was validated but before it can be published.
|
|
manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_SECOND_ALLOWED.to_string()]));
|
|
assert!(
|
|
manager
|
|
.publish_reloaded_auth(generation, fallback)
|
|
.is_none()
|
|
);
|
|
|
|
let recovered = manager
|
|
.auth()
|
|
.await
|
|
.expect("new workspace should be allowed");
|
|
assert_eq!(
|
|
recovered.get_account_id().as_deref(),
|
|
Some(WORKSPACE_ID_SECOND_ALLOWED)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_auth_does_not_reuse_disallowed_credentials_after_a_transient_failure() {
|
|
let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None);
|
|
manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()]));
|
|
let initial = external_header_auth(Some(WORKSPACE_ID_ALLOWED));
|
|
let recovered = external_header_auth(Some(WORKSPACE_ID_SECOND_ALLOWED));
|
|
manager
|
|
.set_external_auth(Arc::new(RecoveringExternalAuth {
|
|
initial,
|
|
recovered: recovered.clone(),
|
|
resolve_count: AtomicUsize::new(0),
|
|
}))
|
|
.await
|
|
.expect("external auth should install");
|
|
|
|
manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_SECOND_ALLOWED.to_string()]));
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
assert_eq!(manager.auth_cached(), None);
|
|
assert_eq!(manager.auth().await, Some(recovered));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replacing_external_auth_clears_permanent_failure() {
|
|
let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed"));
|
|
let auth = CodexAuth::from_api_key("external-token");
|
|
manager
|
|
.set_external_auth(Arc::new(FailingExternalAuth {
|
|
auth: auth.clone(),
|
|
resolve_count: AtomicUsize::new(0),
|
|
}))
|
|
.await
|
|
.expect("external auth should install");
|
|
|
|
assert_eq!(manager.auth().await, Some(auth.clone()));
|
|
assert!(manager.refresh_failure_for_auth(&auth).is_some());
|
|
|
|
manager
|
|
.set_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
|
|
.await
|
|
.expect("replacement external auth should install");
|
|
|
|
manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.expect("replacement external auth should refresh");
|
|
assert_eq!(manager.auth_cached(), Some(auth));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_external_auth_uses_provider_error_classification() {
|
|
let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed"));
|
|
manager
|
|
.set_external_auth(Arc::new(FailingExternalAuth {
|
|
auth: CodexAuth::from_api_key("runtime-token"),
|
|
resolve_count: AtomicUsize::new(0),
|
|
}))
|
|
.await
|
|
.expect("runtime auth should install");
|
|
|
|
assert!(matches!(
|
|
manager.refresh_token_from_authority().await,
|
|
Err(RefreshTokenError::Permanent(_))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_auth_provider_can_install_headers() {
|
|
let mut headers = http::HeaderMap::new();
|
|
headers.insert(
|
|
http::header::AUTHORIZATION,
|
|
http::HeaderValue::from_static("Bearer external"),
|
|
);
|
|
headers.insert("x-external-auth", http::HeaderValue::from_static("enabled"));
|
|
let auth = CodexAuth::Headers(AuthHeaders::new(headers));
|
|
let codex_home = tempdir().expect("tempdir");
|
|
login_with_api_key(
|
|
codex_home.path(),
|
|
"stored-key",
|
|
AuthCredentialsStoreMode::Ephemeral,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.unwrap();
|
|
let manager = AuthManager::new(
|
|
codex_home.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::Ephemeral,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await;
|
|
|
|
manager
|
|
.set_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
|
|
.await
|
|
.expect("external auth should install");
|
|
|
|
assert!(!manager.reload_with_cached_auth().await.unwrap());
|
|
assert_eq!(manager.auth_cached(), Some(auth));
|
|
assert!(
|
|
manager
|
|
.auth_cached()
|
|
.is_some_and(|auth| auth.uses_codex_backend())
|
|
);
|
|
assert!(
|
|
!manager
|
|
.auth_cached()
|
|
.is_some_and(|auth| auth.is_chatgpt_auth())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_header_auth_obeys_workspace_policy() {
|
|
for (account_id, should_succeed) in [
|
|
(Some(WORKSPACE_ID_ALLOWED), true),
|
|
(Some(WORKSPACE_ID_DISALLOWED), false),
|
|
(None, false),
|
|
] {
|
|
let auth = external_header_auth(account_id);
|
|
let expected_auth = should_succeed.then_some(auth.clone());
|
|
let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None);
|
|
manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()]));
|
|
|
|
let result = manager
|
|
.set_external_auth(Arc::new(StaticExternalAuth(auth)))
|
|
.await;
|
|
|
|
assert_eq!(result.is_ok(), should_succeed, "account ID: {account_id:?}");
|
|
assert_eq!(manager.auth_cached(), expected_auth);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_header_auth_rejects_a_disallowed_workspace_on_refresh() {
|
|
let allowed_auth = external_header_auth(Some(WORKSPACE_ID_ALLOWED));
|
|
let disallowed_auth = external_header_auth(Some(WORKSPACE_ID_DISALLOWED));
|
|
let manager = AuthManager::from_optional_auth_for_testing(/*auth*/ None);
|
|
manager.set_forced_chatgpt_workspace_id(Some(vec![WORKSPACE_ID_ALLOWED.to_string()]));
|
|
manager
|
|
.set_external_auth(Arc::new(RefreshingExternalAuth {
|
|
initial: allowed_auth.clone(),
|
|
refreshed: disallowed_auth,
|
|
}))
|
|
.await
|
|
.expect("initial external header auth should install");
|
|
|
|
manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.expect_err("external header auth from a disallowed workspace must not replace the cache");
|
|
|
|
assert_eq!(manager.auth_cached(), Some(allowed_auth));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workload_identity_auth_is_immutable_and_process_local() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let mut manager = AuthManager::from_auth_for_testing_with_home(
|
|
CodexAuth::from_api_key("seed"),
|
|
codex_home.path().to_path_buf(),
|
|
);
|
|
Arc::get_mut(&mut manager)
|
|
.expect("test manager should not be shared yet")
|
|
.workload_identity_selected = true;
|
|
let access_token = fake_jwt_for_auth_file_params(&AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("enterprise".to_string()),
|
|
chatgpt_account_id: Some("workspace-one".to_string()),
|
|
})
|
|
.expect("fake access token");
|
|
let auth =
|
|
CodexAuth::from_external_chatgpt_tokens(&access_token, "workspace-one", Some("enterprise"))
|
|
.expect("external ChatGPT auth");
|
|
|
|
manager
|
|
.install_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
|
|
.await
|
|
.expect("workload identity auth should install");
|
|
manager.clear_external_auth();
|
|
|
|
assert!(manager.has_external_auth());
|
|
assert_eq!(manager.auth().await, Some(auth.clone()));
|
|
assert!(matches!(
|
|
manager
|
|
.set_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
|
|
.await,
|
|
Err(RefreshTokenError::Permanent(_))
|
|
));
|
|
|
|
let logout_error = manager
|
|
.logout()
|
|
.await
|
|
.expect_err("workload identity auth must not be logged out");
|
|
assert_eq!(logout_error.kind(), std::io::ErrorKind::PermissionDenied);
|
|
assert!(manager.has_external_auth());
|
|
assert_eq!(manager.auth_cached(), Some(auth));
|
|
|
|
assert!(!get_auth_file(codex_home.path()).exists());
|
|
let ephemeral_storage = create_auth_storage(
|
|
codex_home.path().to_path_buf(),
|
|
AuthCredentialsStoreMode::Ephemeral,
|
|
AuthKeyringBackendKind::default(),
|
|
);
|
|
assert!(
|
|
ephemeral_storage
|
|
.load()
|
|
.expect("load ephemeral auth")
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
struct ProviderAuthScript {
|
|
tempdir: TempDir,
|
|
command: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
impl ProviderAuthScript {
|
|
fn new(tokens: &[&str]) -> std::io::Result<Self> {
|
|
let tempdir = tempfile::tempdir()?;
|
|
let token_file = tempdir.path().join("tokens.txt");
|
|
// `cmd.exe`'s `set /p` treats LF-only input as one line, so use CRLF on Windows.
|
|
let token_line_ending = if cfg!(windows) { "\r\n" } else { "\n" };
|
|
let mut token_file_contents = String::new();
|
|
for token in tokens {
|
|
token_file_contents.push_str(token);
|
|
token_file_contents.push_str(token_line_ending);
|
|
}
|
|
std::fs::write(&token_file, token_file_contents)?;
|
|
|
|
#[cfg(unix)]
|
|
let (command, args) = {
|
|
let script_path = tempdir.path().join("print-token.sh");
|
|
codex_utils_cargo_bin::write_executable(
|
|
&script_path,
|
|
r#"#!/bin/sh
|
|
if [ -f fail-once ]; then
|
|
rm fail-once
|
|
exit 1
|
|
fi
|
|
first_line=$(sed -n '1p' tokens.txt)
|
|
printf '%s\n' "$first_line"
|
|
tail -n +2 tokens.txt > tokens.next
|
|
mv tokens.next tokens.txt
|
|
"#,
|
|
)?;
|
|
("./print-token.sh".to_string(), Vec::new())
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
let (command, args) = {
|
|
let script_path = tempdir.path().join("print-token.cmd");
|
|
std::fs::write(
|
|
&script_path,
|
|
r#"@echo off
|
|
setlocal EnableExtensions DisableDelayedExpansion
|
|
if exist fail-once (
|
|
del fail-once
|
|
exit /b 1
|
|
)
|
|
set "first_line="
|
|
<tokens.txt set /p "first_line="
|
|
if not defined first_line exit /b 1
|
|
setlocal EnableDelayedExpansion
|
|
echo(!first_line!
|
|
endlocal
|
|
more +1 tokens.txt > tokens.next
|
|
move /y tokens.next tokens.txt >nul
|
|
"#,
|
|
)?;
|
|
(
|
|
"cmd.exe".to_string(),
|
|
vec![
|
|
"/d".to_string(),
|
|
"/s".to_string(),
|
|
"/c".to_string(),
|
|
".\\print-token.cmd".to_string(),
|
|
],
|
|
)
|
|
};
|
|
|
|
Ok(Self {
|
|
tempdir,
|
|
command,
|
|
args,
|
|
})
|
|
}
|
|
|
|
fn new_failing() -> std::io::Result<Self> {
|
|
let tempdir = tempfile::tempdir()?;
|
|
|
|
#[cfg(unix)]
|
|
let (command, args) = {
|
|
let script_path = tempdir.path().join("fail.sh");
|
|
codex_utils_cargo_bin::write_executable(
|
|
&script_path,
|
|
r#"#!/bin/sh
|
|
exit 1
|
|
"#,
|
|
)?;
|
|
("./fail.sh".to_string(), Vec::new())
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
let (command, args) = (
|
|
"cmd.exe".to_string(),
|
|
vec![
|
|
"/d".to_string(),
|
|
"/s".to_string(),
|
|
"/c".to_string(),
|
|
"exit /b 1".to_string(),
|
|
],
|
|
);
|
|
|
|
Ok(Self {
|
|
tempdir,
|
|
command,
|
|
args,
|
|
})
|
|
}
|
|
|
|
fn auth_config(&self) -> ModelProviderAuthInfo {
|
|
serde_json::from_value(json!({
|
|
"command": self.command,
|
|
"args": self.args,
|
|
// Process startup can be slow on loaded Windows CI workers, so leave enough slack to
|
|
// avoid turning these auth-cache assertions into a process-launch timing test.
|
|
"timeout_ms": 10_000,
|
|
"refresh_interval_ms": 60000,
|
|
"cwd": self.tempdir.path(),
|
|
}))
|
|
.expect("provider auth config should deserialize")
|
|
}
|
|
}
|
|
|
|
struct AuthFileParams {
|
|
openai_api_key: Option<String>,
|
|
chatgpt_plan_type: Option<String>,
|
|
chatgpt_account_id: Option<String>,
|
|
}
|
|
|
|
fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<String> {
|
|
let fake_jwt = fake_jwt_for_auth_file_params(¶ms)?;
|
|
let auth_file = get_auth_file(codex_home);
|
|
let auth_json_data = json!({
|
|
"OPENAI_API_KEY": params.openai_api_key,
|
|
"tokens": {
|
|
"id_token": fake_jwt,
|
|
"access_token": "test-access-token",
|
|
"refresh_token": "test-refresh-token"
|
|
},
|
|
"last_refresh": Utc::now(),
|
|
});
|
|
let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
|
|
std::fs::write(auth_file, auth_json)?;
|
|
Ok(fake_jwt)
|
|
}
|
|
|
|
fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result<String> {
|
|
#[derive(Serialize)]
|
|
struct Header {
|
|
alg: &'static str,
|
|
typ: &'static str,
|
|
}
|
|
|
|
let header = Header {
|
|
alg: "none",
|
|
typ: "JWT",
|
|
};
|
|
let mut auth_payload = serde_json::json!({
|
|
"chatgpt_user_id": "user-12345",
|
|
"user_id": "user-12345",
|
|
});
|
|
|
|
if let Some(chatgpt_plan_type) = params.chatgpt_plan_type.as_ref() {
|
|
auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type.clone());
|
|
}
|
|
|
|
if let Some(chatgpt_account_id) = params.chatgpt_account_id.as_ref() {
|
|
auth_payload["chatgpt_account_id"] = serde_json::Value::String(chatgpt_account_id.clone());
|
|
}
|
|
|
|
let payload = serde_json::json!({
|
|
"email": "user@example.com",
|
|
"email_verified": true,
|
|
"https://api.openai.com/auth": auth_payload,
|
|
});
|
|
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
|
let header_b64 = b64(&serde_json::to_vec(&header)?);
|
|
let payload_b64 = b64(&serde_json::to_vec(&payload)?);
|
|
let signature_b64 = b64(b"sig");
|
|
Ok(format!("{header_b64}.{payload_b64}.{signature_b64}"))
|
|
}
|
|
|
|
async fn build_config(
|
|
codex_home: &Path,
|
|
forced_login_method: Option<ForcedLoginMethod>,
|
|
forced_chatgpt_workspace_id: Option<Vec<String>>,
|
|
) -> AuthConfig {
|
|
AuthConfig {
|
|
codex_home: codex_home.to_path_buf(),
|
|
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
|
keyring_backend_kind: AuthKeyringBackendKind::Direct,
|
|
forced_login_method,
|
|
forced_chatgpt_workspace_id,
|
|
managed_auth_policy: ManagedAuthPolicy::default(),
|
|
chatgpt_base_url: None,
|
|
auth_route_config: crate::test_support::transport_default_auth_route_config(),
|
|
}
|
|
}
|
|
|
|
/// Use sparingly.
|
|
/// TODO (gpeal): replace this with an injectable env var provider.
|
|
#[cfg(test)]
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
original: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: &str) -> Self {
|
|
let original = env::var_os(key);
|
|
unsafe {
|
|
env::set_var(key, value);
|
|
}
|
|
Self { key, original }
|
|
}
|
|
|
|
fn remove(key: &'static str) -> Self {
|
|
let original = env::var_os(key);
|
|
unsafe {
|
|
env::remove_var(key);
|
|
}
|
|
Self { key, original }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
match &self.original {
|
|
Some(value) => env::set_var(self.key, value),
|
|
None => env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remove_access_token_env_var() -> EnvVarGuard {
|
|
EnvVarGuard::remove(CODEX_ACCESS_TOKEN_ENV_VAR)
|
|
}
|
|
|
|
struct TestAuthManagerConfig(AuthConfig);
|
|
|
|
impl AuthManagerConfig for TestAuthManagerConfig {
|
|
fn codex_home(&self) -> PathBuf {
|
|
self.0.codex_home.clone()
|
|
}
|
|
|
|
fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode {
|
|
self.0.auth_credentials_store_mode
|
|
}
|
|
|
|
fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind {
|
|
self.0.keyring_backend_kind
|
|
}
|
|
|
|
fn forced_login_method(&self) -> Option<ForcedLoginMethod> {
|
|
self.0.forced_login_method
|
|
}
|
|
|
|
fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
|
|
self.0.forced_chatgpt_workspace_id.clone()
|
|
}
|
|
|
|
fn managed_auth_policy(&self) -> ManagedAuthPolicy {
|
|
self.0.managed_auth_policy.clone()
|
|
}
|
|
|
|
fn chatgpt_base_url(&self) -> String {
|
|
self.0
|
|
.chatgpt_base_url
|
|
.clone()
|
|
.expect("test config should include a ChatGPT base URL")
|
|
}
|
|
|
|
fn auth_route_config(&self) -> AuthRouteConfig {
|
|
self.0.auth_route_config.clone()
|
|
}
|
|
}
|
|
|
|
fn test_auth_manager_config(codex_home: &Path) -> TestAuthManagerConfig {
|
|
TestAuthManagerConfig(AuthConfig {
|
|
codex_home: codex_home.to_path_buf(),
|
|
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
|
keyring_backend_kind: AuthKeyringBackendKind::Direct,
|
|
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
|
chatgpt_base_url: Some("https://chatgpt-staging.com/backend-api".to_string()),
|
|
forced_chatgpt_workspace_id: Some(vec!["forced-workspace".to_string()]),
|
|
managed_auth_policy: ManagedAuthPolicy {
|
|
allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]),
|
|
allowed_chatgpt_workspaces: Some(vec![
|
|
"forced-workspace".to_string(),
|
|
"managed-workspace".to_string(),
|
|
]),
|
|
},
|
|
auth_route_config: AuthRouteConfig::from_http_client_factory(HttpClientFactory::new(
|
|
OutboundProxyPolicy::RespectSystemProxy,
|
|
)),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn auth_config_from_preserves_all_fields() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let config = test_auth_manager_config(codex_home.path());
|
|
|
|
assert_eq!(auth_config_from(&config), config.0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn shared_from_config_prefers_workload_identity_to_explicit_access_token() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let config = test_auth_manager_config(codex_home.path());
|
|
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-explicit");
|
|
let _rule_guard = EnvVarGuard::set(OPENAI_FEDERATION_RULE_ID_ENV_VAR, "rule-one");
|
|
let _assertion_file_guard = EnvVarGuard::remove(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR);
|
|
|
|
let error = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.expect_err("partial workload identity config should fail closed");
|
|
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains(OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn load_auth_reads_access_token_from_env() {
|
|
let codex_home = tempdir().unwrap();
|
|
let mut expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type))
|
|
.expect("signed agent identity");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/agent-runtime-id/task/register"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"task_id": "task-123",
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
expected_record.task_id = Some("task-123".to_string());
|
|
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity);
|
|
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
Some(&chatgpt_base_url),
|
|
AuthKeyringBackendKind::Direct,
|
|
Some(&authapi_base_url),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("env auth should load")
|
|
.expect("env auth should be present");
|
|
|
|
let CodexAuth::AgentIdentity(agent_identity) = auth else {
|
|
panic!("env auth should load as agent identity");
|
|
};
|
|
assert_eq!(agent_identity.record(), &expected_record);
|
|
assert_eq!(agent_identity.run_task_id(), "task-123");
|
|
assert!(
|
|
!get_auth_file(codex_home.path()).exists(),
|
|
"env auth should not write auth.json"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn load_auth_reads_personal_access_token_from_env() {
|
|
let codex_home = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.and(header("authorization", "Bearer at-env-test"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
|
|
)
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test");
|
|
|
|
for auth_credentials_store_mode in [
|
|
AuthCredentialsStoreMode::File,
|
|
AuthCredentialsStoreMode::Ephemeral,
|
|
] {
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
auth_credentials_store_mode,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("env auth should load")
|
|
.expect("env auth should be present");
|
|
|
|
assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken);
|
|
assert_eq!(
|
|
auth.get_token()
|
|
.expect("personal access token should be exposed"),
|
|
"at-env-test"
|
|
);
|
|
assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED));
|
|
assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123"));
|
|
assert_eq!(
|
|
auth.get_account_email().as_deref(),
|
|
Some("user@example.com")
|
|
);
|
|
assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business));
|
|
assert!(auth.is_fedramp_account());
|
|
}
|
|
assert!(
|
|
!get_auth_file(codex_home.path()).exists(),
|
|
"env auth should not write auth.json"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() {
|
|
let codex_home = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.and(header("authorization", "Bearer at-env-workspace-mismatch"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let _access_token_guard =
|
|
EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-workspace-mismatch");
|
|
|
|
let manager = AuthManager::new(
|
|
codex_home.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.and(header(
|
|
"authorization",
|
|
"Bearer at-stored-workspace-mismatch",
|
|
))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)),
|
|
)
|
|
.expect(4)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
|
|
for auth_credentials_store_mode in [
|
|
AuthCredentialsStoreMode::File,
|
|
AuthCredentialsStoreMode::Ephemeral,
|
|
] {
|
|
let codex_home = tempdir().unwrap();
|
|
super::login_with_access_token(
|
|
codex_home.path(),
|
|
"at-stored-workspace-mismatch",
|
|
auth_credentials_store_mode,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("personal access token login should succeed");
|
|
|
|
let manager = AuthManager::new(
|
|
codex_home.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
auth_credentials_store_mode,
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
}
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn personal_access_token_does_not_offer_unauthorized_recovery() {
|
|
let codex_home = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let _access_token_guard =
|
|
EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery");
|
|
let manager = Arc::new(
|
|
AuthManager::new(
|
|
codex_home.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await,
|
|
);
|
|
|
|
let recovery = manager.unauthorized_recovery();
|
|
|
|
assert!(!recovery.has_next());
|
|
assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth");
|
|
manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.expect("personal access tokens do not use OAuth refresh");
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn load_auth_keeps_codex_api_key_env_precedence() {
|
|
let codex_home = tempdir().unwrap();
|
|
let record = agent_identity_record(WORKSPACE_ID_ALLOWED);
|
|
let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity");
|
|
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity);
|
|
let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ true,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("env auth should load")
|
|
.expect("env auth should be present");
|
|
|
|
assert_eq!(auth.api_key(), Some("sk-env"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
login_with_api_key(
|
|
codex_home.path(),
|
|
"sk-test",
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.expect("seed api key");
|
|
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
Some(ForcedLoginMethod::Chatgpt),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await;
|
|
|
|
let err = super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect_err("expected method mismatch to error");
|
|
assert!(err.to_string().contains("ChatGPT login is required"));
|
|
assert!(
|
|
!codex_home.path().join("auth.json").exists(),
|
|
"auth.json should be removed on mismatch"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn auth_manager_rejects_disallowed_stored_and_external_auth() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
login_with_api_key(
|
|
codex_home.path(),
|
|
"sk-test",
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.expect("seed api key");
|
|
let mut config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await;
|
|
config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Chatgpt]);
|
|
let manager =
|
|
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.expect("auth manager");
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
assert!(
|
|
manager
|
|
.set_external_auth(Arc::new(StaticExternalAuth(CodexAuth::from_api_key(
|
|
"sk-external",
|
|
))))
|
|
.await
|
|
.is_err(),
|
|
"external auth cannot bypass managed login policy"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn api_only_policy_rejects_access_tokens_before_hydration() {
|
|
let codex_home = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-rejected");
|
|
let mut config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await;
|
|
config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Api]);
|
|
let manager =
|
|
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.expect("auth manager");
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
assert!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("inspect auth requests")
|
|
.is_empty(),
|
|
"rejected access tokens must not call whoami or register Agent Identity"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn workspace_policy_rejects_agent_identity_before_hydration() {
|
|
let codex_home = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
let record = agent_identity_record(WORKSPACE_ID_DISALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
let _access_token_reset = remove_access_token_env_var();
|
|
let access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity);
|
|
let mut config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await;
|
|
config.managed_auth_policy.allowed_chatgpt_workspaces =
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]);
|
|
let manager =
|
|
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.expect("auth manager");
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
drop(access_token_guard);
|
|
|
|
for stored_agent_identity in [
|
|
AgentIdentityStorage::Jwt(agent_identity),
|
|
AgentIdentityStorage::Record(record),
|
|
] {
|
|
save_auth(
|
|
codex_home.path(),
|
|
&AuthDotJson {
|
|
auth_mode: Some(AuthMode::AgentIdentity),
|
|
openai_api_key: None,
|
|
tokens: None,
|
|
last_refresh: None,
|
|
agent_identity: Some(stored_agent_identity),
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
},
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::Direct,
|
|
)
|
|
.expect("store agent identity");
|
|
let mut config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await;
|
|
config.managed_auth_policy.allowed_chatgpt_workspaces =
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]);
|
|
let manager =
|
|
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.expect("auth manager");
|
|
assert_eq!(manager.auth().await, None);
|
|
}
|
|
|
|
assert!(
|
|
server.received_requests().await.unwrap().is_empty(),
|
|
"rejected agent identities must not fetch JWKS or register"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn workspace_policy_checks_the_selected_request_account() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("seed ChatGPT credentials");
|
|
let auth_path = codex_home.path().join("auth.json");
|
|
let mut stored: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap();
|
|
stored["tokens"]["account_id"] = json!(WORKSPACE_ID_DISALLOWED);
|
|
std::fs::write(&auth_path, serde_json::to_vec(&stored).unwrap()).unwrap();
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
)
|
|
.await;
|
|
let manager =
|
|
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false)
|
|
.await
|
|
.expect("auth manager");
|
|
|
|
assert_eq!(manager.auth().await, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_DISALLOWED.to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
)
|
|
.await;
|
|
|
|
let err = super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect_err("expected workspace mismatch to error");
|
|
assert!(
|
|
err.to_string()
|
|
.contains(&format!("workspace(s) {WORKSPACE_ID_ALLOWED}"))
|
|
);
|
|
assert!(
|
|
!codex_home.path().join("auth.json").exists(),
|
|
"auth.json should be removed on mismatch"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace_mismatch() {
|
|
let codex_home = tempdir().unwrap();
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/v1/user-auth-credential/whoami"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(personal_access_token_whoami(WORKSPACE_ID_DISALLOWED)),
|
|
)
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
|
|
super::login_with_access_token(
|
|
codex_home.path(),
|
|
"at-workspace-mismatch",
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("personal access token login should succeed");
|
|
|
|
let config = AuthConfig {
|
|
codex_home: codex_home.path().to_path_buf(),
|
|
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
|
keyring_backend_kind: AuthKeyringBackendKind::default(),
|
|
forced_login_method: None,
|
|
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
managed_auth_policy: ManagedAuthPolicy::default(),
|
|
chatgpt_base_url: None,
|
|
auth_route_config: crate::test_support::transport_default_auth_route_config(),
|
|
};
|
|
|
|
let err = super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect_err("expected workspace mismatch to error");
|
|
assert!(err.to_string().contains(&format!(
|
|
"current credentials belong to {WORKSPACE_ID_DISALLOWED}"
|
|
)));
|
|
assert!(
|
|
!codex_home.path().join("auth.json").exists(),
|
|
"auth.json should be removed on mismatch"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_allows_matching_workspace() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
)
|
|
.await;
|
|
|
|
super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect("matching workspace should succeed");
|
|
assert!(
|
|
codex_home.path().join("auth.json").exists(),
|
|
"auth.json should remain when restrictions pass"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_allows_any_matching_workspace_in_list() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()),
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
Some(vec![
|
|
WORKSPACE_ID_SECOND_ALLOWED.to_string(),
|
|
WORKSPACE_ID_ALLOWED.to_string(),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect("any matching workspace in the allowed list should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismatch() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let record = agent_identity_record(WORKSPACE_ID_DISALLOWED);
|
|
let agent_identity =
|
|
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/agent-runtime-id/task/register"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"task_id": "task-123",
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
save_auth(
|
|
codex_home.path(),
|
|
&AuthDotJson {
|
|
auth_mode: Some(AuthMode::AgentIdentity),
|
|
openai_api_key: None,
|
|
tokens: None,
|
|
last_refresh: None,
|
|
agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)),
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
bedrock_access_keys: None,
|
|
},
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.expect("seed agent identity auth");
|
|
|
|
let config = AuthConfig {
|
|
codex_home: codex_home.path().to_path_buf(),
|
|
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
|
keyring_backend_kind: AuthKeyringBackendKind::Direct,
|
|
forced_login_method: None,
|
|
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
managed_auth_policy: ManagedAuthPolicy::default(),
|
|
chatgpt_base_url: Some(chatgpt_base_url),
|
|
auth_route_config: crate::test_support::transport_default_auth_route_config(),
|
|
};
|
|
|
|
let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url(
|
|
&config,
|
|
Some(&authapi_base_url),
|
|
)
|
|
.await
|
|
.expect_err("expected workspace mismatch to error");
|
|
let message = err.to_string();
|
|
assert!(
|
|
message.contains(&format!(
|
|
"current credentials belong to {WORKSPACE_ID_DISALLOWED}"
|
|
)),
|
|
"{message}"
|
|
);
|
|
assert!(
|
|
!codex_home.path().join("auth.json").exists(),
|
|
"auth.json should be removed on mismatch"
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set()
|
|
{
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
login_with_api_key(
|
|
codex_home.path(),
|
|
"sk-test",
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.expect("seed api key");
|
|
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
/*forced_login_method*/ None,
|
|
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
|
)
|
|
.await;
|
|
|
|
super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect("matching workspace should succeed");
|
|
assert!(
|
|
codex_home.path().join("auth.json").exists(),
|
|
"auth.json should remain when restrictions pass"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() {
|
|
let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let codex_home = tempdir().unwrap();
|
|
|
|
let config = build_config(
|
|
codex_home.path(),
|
|
Some(ForcedLoginMethod::Chatgpt),
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
)
|
|
.await;
|
|
|
|
let err = super::enforce_login_restrictions(&config)
|
|
.await
|
|
.expect_err("environment API key should not satisfy forced ChatGPT login");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("ChatGPT login is required, but an API key is currently being used.")
|
|
);
|
|
}
|
|
|
|
fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
|
|
let key_material =
|
|
codex_agent_identity::generate_agent_key_material().expect("generate agent key material");
|
|
AgentIdentityAuthRecord {
|
|
agent_runtime_id: "agent-runtime-id".to_string(),
|
|
agent_private_key: key_material.private_key_pkcs8_base64,
|
|
account_id: account_id.to_string(),
|
|
chatgpt_user_id: "user-id".to_string(),
|
|
email: Some("user@example.com".to_string()),
|
|
plan_type: AccountPlanType::Pro,
|
|
chatgpt_account_is_fedramp: false,
|
|
task_id: None,
|
|
}
|
|
}
|
|
|
|
async fn mock_agent_task_registration(
|
|
server: &MockServer,
|
|
path_prefix: &str,
|
|
agent_runtime_id: &str,
|
|
task_id: &str,
|
|
) {
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"{path_prefix}/v1/agent/{agent_runtime_id}/task/register"
|
|
)))
|
|
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
|
"task_id": task_id,
|
|
})))
|
|
.expect(/*r*/ 1)
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<String> {
|
|
fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?)
|
|
}
|
|
|
|
fn fake_agent_identity_jwt_with_plan_type(
|
|
record: &AgentIdentityAuthRecord,
|
|
plan_type: serde_json::Value,
|
|
) -> std::io::Result<String> {
|
|
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
|
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
|
|
let payload = json!({
|
|
"iss": "https://chatgpt.com/codex-backend/agent-identity",
|
|
"aud": "codex-app-server",
|
|
"iat": 1_700_000_000usize,
|
|
"exp": 4_000_000_000usize,
|
|
"agent_runtime_id": record.agent_runtime_id,
|
|
"agent_private_key": record.agent_private_key,
|
|
"account_id": record.account_id,
|
|
"chatgpt_user_id": record.chatgpt_user_id,
|
|
"email": record.email,
|
|
"plan_type": plan_type,
|
|
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
|
|
});
|
|
let payload_b64 = encode(&serde_json::to_vec(&payload)?);
|
|
let signature_b64 = encode(b"sig");
|
|
Ok(format!("{header_b64}.{payload_b64}.{signature_b64}"))
|
|
}
|
|
|
|
fn signed_agent_identity_jwt(
|
|
record: &AgentIdentityAuthRecord,
|
|
plan_type: serde_json::Value,
|
|
) -> jsonwebtoken::errors::Result<String> {
|
|
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
|
|
header.kid = Some("test-key".to_string());
|
|
jsonwebtoken::encode(
|
|
&header,
|
|
&json!({
|
|
"iss": "https://chatgpt.com/codex-backend/agent-identity",
|
|
"aud": "codex-app-server",
|
|
"iat": 1_700_000_000usize,
|
|
"exp": 4_000_000_000usize,
|
|
"agent_runtime_id": record.agent_runtime_id,
|
|
"agent_private_key": record.agent_private_key,
|
|
"account_id": record.account_id,
|
|
"chatgpt_user_id": record.chatgpt_user_id,
|
|
"email": record.email,
|
|
"plan_type": plan_type,
|
|
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
|
|
}),
|
|
&jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?,
|
|
)
|
|
}
|
|
|
|
fn test_jwks_body() -> serde_json::Value {
|
|
json!({
|
|
"keys": [{
|
|
"kty": "RSA",
|
|
"kid": "test-key",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q",
|
|
"e": "AQAB",
|
|
}]
|
|
})
|
|
}
|
|
|
|
fn personal_access_token_whoami(account_id: &str) -> serde_json::Value {
|
|
json!({
|
|
"email": "user@example.com",
|
|
"chatgpt_user_id": "user-123",
|
|
"chatgpt_account_id": account_id,
|
|
"chatgpt_plan_type": "business",
|
|
"chatgpt_account_is_fedramp": true,
|
|
})
|
|
}
|
|
|
|
const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY-----
|
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO
|
|
bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9
|
|
FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9
|
|
mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg
|
|
0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs
|
|
zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa
|
|
uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2
|
|
1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu
|
|
RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8
|
|
NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ
|
|
rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj
|
|
Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL
|
|
aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL
|
|
iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN
|
|
YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL
|
|
3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq
|
|
sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg
|
|
gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP
|
|
sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5
|
|
BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD
|
|
KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c
|
|
r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3
|
|
29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc
|
|
J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN
|
|
5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR
|
|
8U4M2TSWCKUY/A6sT4W8+mT9
|
|
-----END PRIVATE KEY-----"#;
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn agent_identity_plan_type_maps_raw_enterprise_alias() {
|
|
assert_agent_identity_plan_alias(json!("hc"), AccountPlanType::Enterprise).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn agent_identity_plan_type_maps_raw_education_alias() {
|
|
assert_agent_identity_plan_alias(json!("education"), AccountPlanType::Edu).await;
|
|
}
|
|
|
|
async fn assert_agent_identity_plan_alias(
|
|
plan_type: serde_json::Value,
|
|
expected_plan_type: AccountPlanType,
|
|
) {
|
|
let record = agent_identity_record("account-id");
|
|
let jwt = signed_agent_identity_jwt(&record, plan_type).expect("agent identity jwt");
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/wham/agent-identities/jwks"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/v1/agent/agent-runtime-id/task/register"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"task_id": "task-123",
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let authapi_base_url = server.uri();
|
|
let chatgpt_base_url = format!("{authapi_base_url}/backend-api");
|
|
let auth = CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
|
|
&jwt,
|
|
Some(&chatgpt_base_url),
|
|
&authapi_base_url,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await
|
|
.expect("agent identity auth");
|
|
|
|
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type));
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn plan_type_maps_known_plan() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("pro".to_string()),
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("load auth")
|
|
.expect("auth available");
|
|
|
|
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn plan_type_maps_self_serve_business_usage_based_plan() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()),
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("load auth")
|
|
.expect("auth available");
|
|
|
|
pretty_assertions::assert_eq!(
|
|
auth.account_plan_type(),
|
|
Some(AccountPlanType::SelfServeBusinessUsageBased)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn plan_type_maps_enterprise_cbp_usage_based_plan() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()),
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("load auth")
|
|
.expect("auth available");
|
|
|
|
pretty_assertions::assert_eq!(
|
|
auth.account_plan_type(),
|
|
Some(AccountPlanType::EnterpriseCbpUsageBased)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn plan_type_maps_unknown_to_unknown() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: Some("mystery-tier".to_string()),
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("load auth")
|
|
.expect("auth available");
|
|
|
|
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial(codex_auth_env)]
|
|
async fn missing_plan_type_maps_to_unknown() {
|
|
let codex_home = tempdir().unwrap();
|
|
let _access_token_guard = remove_access_token_env_var();
|
|
let _jwt = write_auth_file(
|
|
AuthFileParams {
|
|
openai_api_key: None,
|
|
chatgpt_plan_type: None,
|
|
chatgpt_account_id: None,
|
|
},
|
|
codex_home.path(),
|
|
)
|
|
.expect("failed to write auth file");
|
|
|
|
let auth = super::load_auth(
|
|
codex_home.path(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*allowed_login_methods*/ None,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::Direct,
|
|
/*agent_identity_authapi_base_url*/ None,
|
|
&crate::test_support::transport_default_auth_route_config(),
|
|
/*cached_auth*/ None,
|
|
/*allowed_auth_modes*/ None,
|
|
)
|
|
.await
|
|
.expect("load auth")
|
|
.expect("auth available");
|
|
|
|
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
|
|
}
|