mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
## Stack This is PR 2 of the simplified HAI single-run-task stack: - [#19047](https://github.com/openai/codex/pull/19047) Agent Identity assertion and task-registration primitives, including the shared run-task helper used by existing Agent Identity JWT auth. - [#19049](https://github.com/openai/codex/pull/19049) Disabled-by-default ChatGPT auth opt-in that provisions/reuses persisted Agent Identity runtime auth and its single run task. - [#19051](https://github.com/openai/codex/pull/19051) Run-scoped provider auth that uses one backend-owned task id for first-party inference and compaction requests. [#19054](https://github.com/openai/codex/pull/19054) collapsed out of the active stack because the simplified design no longer needs a separate background/control-plane task helper. ## Summary This PR adds the disabled-by-default path for normal ChatGPT-login Codex sessions to obtain Agent Identity runtime auth through the Codex backend. Existing Agent Identity JWT startup mode remains a separate path and does not require the feature flag. What changed: - adds the experimental `use_agent_identity` feature flag and config schema entry - adds an explicit `AgentIdentityAuthPolicy` so call sites choose `JwtOnly` or `ChatGptAuth` instead of passing a bare boolean - stores standalone Agent Identity JWT credentials separately from backend-registered Agent Identity records - persists the registered Agent Identity record, private key, and single run task id in `auth.json` so process restarts reuse the same identity - derives the agent/task registration base URL from ChatGPT/Codex auth config while keeping JWT JWKS lookup separate - provisions and caches ChatGPT-derived Agent Identity runtime auth when `use_agent_identity` is enabled - reuses the shared run-task registration helper from PR1 rather than adding a second task-registration path This PR intentionally does not switch model inference over to `AgentAssertion` auth. The provider-auth integration lands in the next PR. ## Testing - `just test -p codex-login`
300 lines
8.4 KiB
Rust
300 lines
8.4 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use base64::Engine;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_login::AuthDotJson;
|
|
use codex_login::AuthKeyringBackendKind;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CLIENT_ID;
|
|
use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR;
|
|
use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR;
|
|
use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
|
|
use codex_login::logout_with_revoke;
|
|
use codex_login::save_auth;
|
|
use codex_login::token_data::IdTokenInfo;
|
|
use codex_login::token_data::TokenData;
|
|
use core_test_support::skip_if_no_network;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::ffi::OsString;
|
|
use tempfile::TempDir;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const ACCESS_TOKEN: &str = "access-token";
|
|
const REFRESH_TOKEN: &str = "refresh-token";
|
|
|
|
#[serial_test::serial(auth_env)]
|
|
#[tokio::test]
|
|
async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let _client_id_guard = EnvGuard::set(CLIENT_ID_OVERRIDE_ENV_VAR, "staging-client".to_string());
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/revoke"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"message": "success"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _env_guard = EnvGuard::set(
|
|
REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR,
|
|
format!("{}/oauth/revoke", server.uri()),
|
|
);
|
|
|
|
let codex_home = TempDir::new()?;
|
|
save_auth(
|
|
codex_home.path(),
|
|
&chatgpt_auth(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
|
|
let removed = logout_with_revoke(
|
|
codex_home.path(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.await?;
|
|
|
|
assert!(removed);
|
|
assert!(!codex_home.path().join("auth.json").exists());
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.context("failed to fetch revoke requests")?;
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(
|
|
requests[0]
|
|
.body_json::<Value>()
|
|
.context("revoke request should be JSON")?,
|
|
json!({
|
|
"token": REFRESH_TOKEN,
|
|
"token_type_hint": "refresh_token",
|
|
"client_id": "staging-client",
|
|
})
|
|
);
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_env)]
|
|
#[tokio::test]
|
|
async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/revoke"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _revoke_env_guard = EnvGuard::set(
|
|
REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR,
|
|
format!("{}/oauth/revoke", server.uri()),
|
|
);
|
|
let _access_token_env_guard = EnvGuard::set(
|
|
CODEX_ACCESS_TOKEN_ENV_VAR,
|
|
"at-environment-token".to_string(),
|
|
);
|
|
|
|
let codex_home = TempDir::new()?;
|
|
save_auth(
|
|
codex_home.path(),
|
|
&chatgpt_auth(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
|
|
let removed = logout_with_revoke(
|
|
codex_home.path(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.await?;
|
|
|
|
assert!(removed);
|
|
assert!(!codex_home.path().join("auth.json").exists());
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_env)]
|
|
#[tokio::test]
|
|
async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/revoke"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
|
|
"error": {
|
|
"message": "revoke failed"
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _env_guard = EnvGuard::set(
|
|
REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR,
|
|
format!("{}/oauth/revoke", server.uri()),
|
|
);
|
|
|
|
let codex_home = TempDir::new()?;
|
|
save_auth(
|
|
codex_home.path(),
|
|
&chatgpt_auth(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
|
|
let removed = logout_with_revoke(
|
|
codex_home.path(),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)
|
|
.await?;
|
|
|
|
assert!(removed);
|
|
assert!(!codex_home.path().join("auth.json").exists());
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_env)]
|
|
#[tokio::test]
|
|
async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/revoke"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"message": "success"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let _env_guard = EnvGuard::set(
|
|
REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR,
|
|
format!("{}/oauth/revoke", server.uri()),
|
|
);
|
|
|
|
let codex_home = TempDir::new()?;
|
|
save_auth(
|
|
codex_home.path(),
|
|
&chatgpt_auth_with_refresh_token(REFRESH_TOKEN),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
let manager = 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(),
|
|
)
|
|
.await;
|
|
save_auth(
|
|
codex_home.path(),
|
|
&chatgpt_auth_with_refresh_token("newer-disk-refresh-token"),
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
|
|
let removed = manager.logout_with_revoke().await?;
|
|
|
|
assert!(removed);
|
|
assert!(manager.auth_cached().is_none());
|
|
assert!(!codex_home.path().join("auth.json").exists());
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.context("failed to fetch revoke requests")?;
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(
|
|
requests[0]
|
|
.body_json::<Value>()
|
|
.context("revoke request should be JSON")?,
|
|
json!({
|
|
"token": REFRESH_TOKEN,
|
|
"token_type_hint": "refresh_token",
|
|
"client_id": CLIENT_ID,
|
|
})
|
|
);
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
fn chatgpt_auth() -> AuthDotJson {
|
|
chatgpt_auth_with_refresh_token(REFRESH_TOKEN)
|
|
}
|
|
|
|
fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson {
|
|
AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(TokenData {
|
|
id_token: IdTokenInfo {
|
|
raw_jwt: minimal_jwt(),
|
|
..Default::default()
|
|
},
|
|
access_token: ACCESS_TOKEN.to_string(),
|
|
refresh_token: refresh_token.to_string(),
|
|
account_id: Some("account-id".to_string()),
|
|
}),
|
|
last_refresh: None,
|
|
agent_identity: None,
|
|
personal_access_token: None,
|
|
bedrock_api_key: None,
|
|
}
|
|
}
|
|
|
|
fn minimal_jwt() -> String {
|
|
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
|
|
let header_b64 = b64(br#"{"alg":"none"}"#);
|
|
let payload_b64 = b64(br#"{"sub":"user-123"}"#);
|
|
let signature_b64 = b64(b"sig");
|
|
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
|
}
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
original: Option<OsString>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set(key: &'static str, value: String) -> Self {
|
|
let original = std::env::var_os(key);
|
|
// SAFETY: these tests execute serially, so updating the process environment is safe.
|
|
unsafe {
|
|
std::env::set_var(key, &value);
|
|
}
|
|
Self { key, original }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
// SAFETY: the guard restores the original environment value before other tests run.
|
|
unsafe {
|
|
match &self.original {
|
|
Some(value) => std::env::set_var(self.key, value),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
}
|