mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
## Summary Auth loading used to expose synchronous construction helpers in several places even though some auth sources now need async work. This PR makes the auth-loading surface async and updates the callers to await it. This is intentionally only plumbing. It does not change how AgentIdentity tokens are decoded, how task runtime ids are allocated, or how JWT signatures are verified. ## Stack 1. **This PR:** [refactor: make auth loading async](https://github.com/openai/codex/pull/19762) 2. [refactor: load AgentIdentity runtime eagerly](https://github.com/openai/codex/pull/19763) 3. [feat: verify AgentIdentity JWTs with JWKS](https://github.com/openai/codex/pull/19764) ## Important call sites | Area | Change | | --- | --- | | `codex-login` auth loading | `CodexAuth` and `AuthManager` construction paths now await auth loading. | | app-server startup | Auth manager construction is awaited during initialization. | | CLI/TUI/exec/MCP/chatgpt callers | Existing auth-loading calls now await the same behavior. | | cloud requirements storage loader | The loader becomes async so it can share the same auth construction path. | | auth tests | Tests that load auth now run in async contexts. | ## Testing Tests: targeted Rust auth test compilation, formatter, scoped Clippy fix, and Bazel lock check.
1093 lines
33 KiB
Rust
1093 lines
33 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use base64::Engine;
|
|
use chrono::Duration;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_login::AuthDotJson;
|
|
use codex_login::AuthManager;
|
|
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
|
use codex_login::RefreshTokenError;
|
|
use codex_login::load_auth_dot_json;
|
|
use codex_login::save_auth;
|
|
use codex_login::token_data::IdTokenInfo;
|
|
use codex_login::token_data::TokenData;
|
|
use codex_protocol::auth::RefreshTokenFailedReason;
|
|
use core_test_support::skip_if_no_network;
|
|
use pretty_assertions::assert_eq;
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
use std::ffi::OsString;
|
|
use std::sync::Arc;
|
|
use tempfile::TempDir;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const INITIAL_ACCESS_TOKEN: &str = "initial-access-token";
|
|
const INITIAL_REFRESH_TOKEN: &str = "initial-refresh-token";
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_succeeds_updates_storage() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.context("refresh should succeed")?;
|
|
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "new-access-token".to_string(),
|
|
refresh_token: "new-refresh-token".to_string(),
|
|
..initial_tokens.clone()
|
|
};
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
let refreshed_at = stored
|
|
.last_refresh
|
|
.as_ref()
|
|
.context("last_refresh should be recorded")?;
|
|
assert!(
|
|
*refreshed_at >= initial_last_refresh,
|
|
"last_refresh should advance"
|
|
);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached, refreshed_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.context("refresh should succeed")?;
|
|
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "new-access-token".to_string(),
|
|
refresh_token: "new-refresh-token".to_string(),
|
|
..initial_tokens.clone()
|
|
};
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
let refreshed_at = stored
|
|
.last_refresh
|
|
.as_ref()
|
|
.context("last_refresh should be recorded")?;
|
|
assert!(
|
|
*refreshed_at >= initial_last_refresh,
|
|
"last_refresh should advance"
|
|
);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached, refreshed_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.context("refresh should be skipped")?;
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should be cached")?;
|
|
let cached_tokens = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_tokens, disk_tokens);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_errors_on_account_mismatch() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "recovered-access-token",
|
|
"refresh_token": "recovered-refresh-token"
|
|
})))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let mut disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
disk_tokens.account_id = Some("other-account".to_string());
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let err = ctx
|
|
.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.err()
|
|
.context("refresh should fail due to account mismatch")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Other));
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
let cached_after = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should be cached after refresh")?;
|
|
let cached_after_tokens = cached_after
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached_after_tokens, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn returns_fresh_tokens_as_is() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let stale_refresh = Utc::now() - Duration::days(9);
|
|
let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::hours(1));
|
|
let initial_tokens = build_tokens(&fresh_access_token, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(stale_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refreshes_token_when_access_token_is_expired() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let fresh_refresh = Utc::now() - Duration::days(1);
|
|
let expired_access_token = access_token_with_expiration(Utc::now() - Duration::hours(1));
|
|
let initial_tokens = build_tokens(&expired_access_token, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(fresh_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "new-access-token".to_string(),
|
|
refresh_token: "new-refresh-token".to_string(),
|
|
..initial_tokens.clone()
|
|
};
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should refresh")?;
|
|
assert_eq!(cached, refreshed_tokens);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
let refreshed_at = stored
|
|
.last_refresh
|
|
.as_ref()
|
|
.context("last_refresh should be recorded")?;
|
|
assert!(
|
|
*refreshed_at >= fresh_refresh,
|
|
"last_refresh should advance"
|
|
);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let stale_refresh = Utc::now() - Duration::days(9);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens),
|
|
last_refresh: Some(stale_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let fresh_refresh = Utc::now() - Duration::days(1);
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(fresh_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should reload from disk")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should reload from disk")?;
|
|
assert_eq!(cached, disk_tokens);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
|
"error": {
|
|
"code": "refresh_token_expired"
|
|
}
|
|
})))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let stale_refresh = Utc::now() - Duration::days(9);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens),
|
|
last_refresh: Some(stale_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let fresh_refresh = Utc::now() - Duration::days(1);
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(fresh_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should reload from disk")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should reload from disk")?;
|
|
assert_eq!(cached, disk_tokens);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
|
"error": {
|
|
"code": "refresh_token_expired"
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let err = ctx
|
|
.auth_manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.err()
|
|
.context("refresh should fail")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Expired));
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should remain cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
|
"error": {
|
|
"code": "refresh_token_reused"
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let first_err = ctx
|
|
.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.err()
|
|
.context("first refresh should fail")?;
|
|
assert_eq!(
|
|
first_err.failed_reason(),
|
|
Some(RefreshTokenFailedReason::Exhausted)
|
|
);
|
|
|
|
let second_err = ctx
|
|
.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.err()
|
|
.context("second refresh should fail without retrying")?;
|
|
assert_eq!(
|
|
second_err.failed_reason(),
|
|
Some(RefreshTokenFailedReason::Exhausted)
|
|
);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should remain cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
|
"error": {
|
|
"code": "refresh_token_reused"
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let first_err = ctx
|
|
.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.err()
|
|
.context("first refresh should fail")?;
|
|
assert_eq!(
|
|
first_err.failed_reason(),
|
|
Some(RefreshTokenFailedReason::Exhausted)
|
|
);
|
|
|
|
let fresh_refresh = Utc::now() - Duration::hours(1);
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(fresh_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.context("refresh should reload changed auth without retrying")?;
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should reload from disk")?;
|
|
assert_eq!(cached, disk_tokens);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert_eq!(
|
|
requests.len(),
|
|
1,
|
|
"expected only the initial refresh request"
|
|
);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
|
|
"error": "temporary-failure"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let err = ctx
|
|
.auth_manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.err()
|
|
.context("refresh should fail")?;
|
|
assert!(matches!(err, RefreshTokenError::Transient(_)));
|
|
assert_eq!(err.failed_reason(), None);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should remain cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "recovered-access-token",
|
|
"refresh_token": "recovered-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_before = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.expect("auth should be cached");
|
|
let cached_before_tokens = cached_before
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_before_tokens, initial_tokens);
|
|
|
|
let mut recovery = ctx.auth_manager.unauthorized_recovery();
|
|
assert!(recovery.has_next());
|
|
|
|
recovery.next().await?;
|
|
|
|
let cached_after = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.expect("auth should be cached after reload");
|
|
let cached_after_tokens = cached_after
|
|
.get_token_data()
|
|
.context("token data should reload")?;
|
|
assert_eq!(cached_after_tokens, disk_tokens);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
recovery.next().await?;
|
|
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "recovered-access-token".to_string(),
|
|
refresh_token: "recovered-refresh-token".to_string(),
|
|
..disk_tokens.clone()
|
|
};
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.expect("auth should be cached");
|
|
let cached_tokens = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_tokens, refreshed_tokens);
|
|
assert!(!recovery.has_next());
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "recovered-access-token",
|
|
"refresh_token": "recovered-refresh-token"
|
|
})))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&initial_auth).await?;
|
|
|
|
let mut disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
disk_tokens.account_id = Some("other-account".to_string());
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens),
|
|
last_refresh: Some(initial_last_refresh),
|
|
agent_identity: None,
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_before = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.expect("auth should be cached");
|
|
let cached_before_tokens = cached_before
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_before_tokens, initial_tokens);
|
|
|
|
let mut recovery = ctx.auth_manager.unauthorized_recovery();
|
|
assert!(recovery.has_next());
|
|
|
|
let err = recovery
|
|
.next()
|
|
.await
|
|
.err()
|
|
.context("recovery should fail due to account mismatch")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Other));
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
let cached_after = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should remain cached after refresh")?;
|
|
let cached_after_tokens = cached_after
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached_after_tokens, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
let ctx = RefreshTokenTestContext::new(&server).await?;
|
|
let auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::ApiKey),
|
|
openai_api_key: Some("sk-test".to_string()),
|
|
tokens: None,
|
|
last_refresh: None,
|
|
agent_identity: None,
|
|
};
|
|
ctx.write_auth(&auth).await?;
|
|
|
|
let mut recovery = ctx.auth_manager.unauthorized_recovery();
|
|
assert!(!recovery.has_next());
|
|
|
|
let err = recovery
|
|
.next()
|
|
.await
|
|
.err()
|
|
.context("recovery should fail")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Other));
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct RefreshTokenTestContext {
|
|
codex_home: TempDir,
|
|
auth_manager: Arc<AuthManager>,
|
|
_env_guard: EnvGuard,
|
|
}
|
|
|
|
impl RefreshTokenTestContext {
|
|
async fn new(server: &MockServer) -> Result<Self> {
|
|
let codex_home = TempDir::new()?;
|
|
|
|
let endpoint = format!("{}/oauth/token", server.uri());
|
|
let env_guard = EnvGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, endpoint);
|
|
|
|
let auth_manager = AuthManager::shared(
|
|
codex_home.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*chatgpt_base_url*/ None,
|
|
)
|
|
.await;
|
|
|
|
Ok(Self {
|
|
codex_home,
|
|
auth_manager,
|
|
_env_guard: env_guard,
|
|
})
|
|
}
|
|
|
|
fn load_auth(&self) -> Result<AuthDotJson> {
|
|
load_auth_dot_json(self.codex_home.path(), AuthCredentialsStoreMode::File)
|
|
.context("load auth.json")?
|
|
.context("auth.json should exist")
|
|
}
|
|
|
|
async fn write_auth(&self, auth_dot_json: &AuthDotJson) -> Result<()> {
|
|
save_auth(
|
|
self.codex_home.path(),
|
|
auth_dot_json,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
self.auth_manager.reload().await;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn jwt_with_payload(payload: serde_json::Value) -> String {
|
|
#[derive(Serialize)]
|
|
struct Header {
|
|
alg: &'static str,
|
|
typ: &'static str,
|
|
}
|
|
|
|
let header = Header {
|
|
alg: "none",
|
|
typ: "JWT",
|
|
};
|
|
|
|
fn b64(data: &[u8]) -> String {
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
|
|
}
|
|
|
|
let header_bytes = match serde_json::to_vec(&header) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) => panic!("serialize header: {err}"),
|
|
};
|
|
let payload_bytes = match serde_json::to_vec(&payload) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) => panic!("serialize payload: {err}"),
|
|
};
|
|
let header_b64 = b64(&header_bytes);
|
|
let payload_b64 = b64(&payload_bytes);
|
|
let signature_b64 = b64(b"sig");
|
|
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
|
}
|
|
|
|
fn minimal_jwt() -> String {
|
|
jwt_with_payload(json!({ "sub": "user-123" }))
|
|
}
|
|
|
|
fn access_token_with_expiration(expires_at: chrono::DateTime<Utc>) -> String {
|
|
jwt_with_payload(json!({ "sub": "user-123", "exp": expires_at.timestamp() }))
|
|
}
|
|
|
|
fn build_tokens(access_token: &str, refresh_token: &str) -> TokenData {
|
|
let id_token = IdTokenInfo {
|
|
raw_jwt: minimal_jwt(),
|
|
..Default::default()
|
|
};
|
|
TokenData {
|
|
id_token,
|
|
access_token: access_token.to_string(),
|
|
refresh_token: refresh_token.to_string(),
|
|
account_id: Some("account-id".to_string()),
|
|
}
|
|
}
|