feat: add Bedrock API key as a managed auth mode (#27443)

## Why

Codex needs to manage Amazon Bedrock API key credentials through the
existing auth lifecycle instead of introducing a separate auth manager
or provider-specific credential file. Treating Bedrock API key login as
a primary auth mode gives it the same persistence, keyring, reload, and
logout behavior as the existing OpenAI API key and ChatGPT modes.

The credential is valid only for the `amazon-bedrock` model provider.
OpenAI-compatible providers must reject this auth mode rather than
treating the Bedrock key as an OpenAI bearer token.

## What changed

- Added `bedrockApiKey` as an app-server `AuthMode` and
`CodexAuth::BedrockApiKey` as a primary `AuthManager` mode.
- Added `BedrockApiKeyAuth`, containing the API key and AWS region, to
the existing `AuthDotJson` payload stored in `$CODEX_HOME/auth.json` or
the configured keyring backend.
- Added `login_with_bedrock_api_key(...)`, parallel to
`login_with_api_key(...)`, which replaces the current stored login with
Bedrock credentials.
- Reused generic auth reload and logout behavior instead of adding a
Bedrock-specific auth manager or logout path.
- Updated login restrictions, status reporting, diagnostics, telemetry
classification, generated app-server schemas, and auth fixtures for the
new mode.
- Added explicit errors when Bedrock API key auth is selected with an
OpenAI-compatible model provider.

This PR establishes managed storage and auth-mode behavior. Routing the
managed key and region into Amazon Bedrock requests will be in follow-up
PRs.
This commit is contained in:
Celia Chen authored and GitHub committed 2026-06-10 20:42:38 -07:00
1 parent 87ab01834a
commit 06afd63f4a
30 files changed
+426 -15

No files matched your search

+4
View File
@@ -166,6 +166,7 @@ async fn login_with_access_token_writes_only_personal_access_token() {
last_refresh: None,
agent_identity: None,
personal_access_token: Some("at-login-test".to_string()),
bedrock_api_key: None,
}
);
assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken);
@@ -325,6 +326,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
last_refresh: Some(last_refresh),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
},
auth_dot_json
);
@@ -367,6 +369,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?;
let auth_file = get_auth_file(dir.path());
@@ -1112,6 +1115,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
last_refresh: None,
agent_identity: Some(agent_identity),
personal_access_token: None,
bedrock_api_key: None,
},
AuthCredentialsStoreMode::File,
)
@@ -0,0 +1,42 @@
use std::path::Path;
use codex_config::types::AuthCredentialsStoreMode;
use serde::Deserialize;
use serde::Serialize;
use super::manager::save_auth;
use super::storage::AuthDotJson;
use codex_app_server_protocol::AuthMode;
/// Managed Amazon Bedrock API key persisted in `auth.json`.
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub struct BedrockApiKeyAuth {
pub api_key: String,
pub region: String,
}
/// Writes an `auth.json` that contains only the Amazon Bedrock API key auth.
pub fn login_with_bedrock_api_key(
codex_home: &Path,
api_key: &str,
region: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::BedrockApiKey),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: Some(BedrockApiKeyAuth {
api_key: api_key.to_string(),
region: region.to_string(),
}),
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
#[cfg(test)]
#[path = "bedrock_api_key_tests.rs"]
mod tests;
@@ -0,0 +1,162 @@
use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use super::*;
use crate::auth::AuthManager;
use crate::auth::CodexAuth;
use crate::auth::storage::AuthStorageBackend;
use crate::auth::storage::FileAuthStorage;
fn api_key_auth() -> AuthDotJson {
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,
}
}
fn bedrock_only_auth() -> AuthDotJson {
AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: Some(bedrock_auth()),
}
}
fn bedrock_auth() -> BedrockApiKeyAuth {
BedrockApiKeyAuth {
api_key: "bedrock-api-key-test".to_string(),
region: "us-east-1".to_string(),
}
}
#[tokio::test]
async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
storage.save(&api_key_auth())?;
login_with_bedrock_api_key(
codex_home.path(),
"bedrock-api-key-test",
"us-east-1",
AuthCredentialsStoreMode::File,
)?;
let auth_manager = AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
let loaded = storage.load()?.expect("auth should be stored");
let expected = AuthDotJson {
auth_mode: Some(AuthMode::BedrockApiKey),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: Some(bedrock_auth()),
};
assert_eq!(loaded, expected);
assert_eq!(auth_manager.auth_mode(), Some(AuthMode::BedrockApiKey));
assert_eq!(
auth_manager.auth_cached().and_then(|auth| match auth {
CodexAuth::BedrockApiKey(auth) => Some(auth),
CodexAuth::ApiKey(_)
| CodexAuth::Chatgpt(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => None,
}),
Some(bedrock_auth())
);
Ok(())
}
#[tokio::test]
async fn logout_removes_bedrock_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
login_with_bedrock_api_key(
codex_home.path(),
"bedrock-api-key-test",
"us-east-1",
AuthCredentialsStoreMode::File,
)?;
let auth_manager = AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
assert!(auth_manager.logout().await?);
assert_eq!(storage.load()?, None);
assert_eq!(auth_manager.auth_cached(), None);
Ok(())
}
#[tokio::test]
async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
storage.save(&bedrock_only_auth())?;
let auth_manager = AuthManager::new(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
assert_eq!(auth_manager.auth_mode(), Some(AuthMode::BedrockApiKey));
assert_eq!(
auth_manager.auth_cached().and_then(|auth| match auth {
CodexAuth::BedrockApiKey(auth) => Some(auth),
CodexAuth::ApiKey(_)
| CodexAuth::Chatgpt(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => None,
}),
Some(bedrock_auth())
);
Ok(())
}
#[tokio::test]
async fn login_with_api_key_clears_bedrock_api_key() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
login_with_bedrock_api_key(
codex_home.path(),
"bedrock-api-key-test",
"us-east-1",
AuthCredentialsStoreMode::File,
)?;
crate::auth::login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
)?;
assert_eq!(storage.load()?, Some(api_key_auth()));
Ok(())
}
+42 -7
View File
@@ -29,6 +29,7 @@ use super::access_token::classify_codex_access_token;
use super::external_bearer::BearerTokenRefresher;
use super::revoke::revoke_auth_tokens;
pub use crate::auth::agent_identity::AgentIdentityAuth;
pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth;
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
pub use crate::auth::storage::AgentIdentityAuthRecord;
pub use crate::auth::storage::AuthDotJson;
@@ -57,12 +58,14 @@ pub enum CodexAuth {
ChatgptAuthTokens(ChatgptAuthTokens),
AgentIdentity(AgentIdentityAuth),
PersonalAccessToken(PersonalAccessTokenAuth),
BedrockApiKey(BedrockApiKeyAuth),
}
impl PartialEq for CodexAuth {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b,
(Self::BedrockApiKey(a), Self::BedrockApiKey(b)) => a == b,
_ => self.api_auth_mode() == other.api_auth_mode(),
}
}
@@ -235,6 +238,14 @@ impl CodexAuth {
};
return Self::from_personal_access_token(personal_access_token).await;
}
if auth_mode == ApiAuthMode::BedrockApiKey {
let Some(auth) = auth_dot_json.bedrock_api_key else {
return Err(std::io::Error::other(
"Bedrock API key auth is missing a Bedrock API key.",
));
};
return Ok(Self::BedrockApiKey(auth));
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let state = ChatgptAuthState {
@@ -255,6 +266,7 @@ impl CodexAuth {
ApiAuthMode::PersonalAccessToken => {
unreachable!("personal access token mode is handled above")
}
ApiAuthMode::BedrockApiKey => unreachable!("bedrock api key mode is handled above"),
}
}
@@ -296,6 +308,7 @@ impl CodexAuth {
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
Self::AgentIdentity(_) => AuthMode::AgentIdentity,
Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
Self::BedrockApiKey(_) => AuthMode::BedrockApiKey,
}
}
@@ -306,6 +319,7 @@ impl CodexAuth {
Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity,
Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken,
Self::BedrockApiKey(_) => ApiAuthMode::BedrockApiKey,
}
}
@@ -346,7 +360,8 @@ impl CodexAuth {
Self::Chatgpt(_)
| Self::ChatgptAuthTokens(_)
| Self::AgentIdentity(_)
| Self::PersonalAccessToken(_) => None,
| Self::PersonalAccessToken(_)
| Self::BedrockApiKey(_) => None,
}
}
@@ -375,6 +390,9 @@ impl CodexAuth {
"agent identity auth does not expose a bearer token",
)),
Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()),
Self::BedrockApiKey(_) => Err(std::io::Error::other(
"Bedrock API key auth does not expose a Codex bearer token",
)),
}
}
@@ -447,7 +465,10 @@ impl CodexAuth {
let state = match self {
Self::Chatgpt(auth) => &auth.state,
Self::ChatgptAuthTokens(auth) => &auth.state,
Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None,
Self::ApiKey(_)
| Self::AgentIdentity(_)
| Self::PersonalAccessToken(_)
| Self::BedrockApiKey(_) => return None,
};
#[expect(clippy::unwrap_used)]
state.auth_dot_json.lock().unwrap().clone()
@@ -472,6 +493,7 @@ impl CodexAuth {
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
let client = create_client();
@@ -589,6 +611,7 @@ pub fn login_with_api_key(
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
@@ -612,6 +635,7 @@ pub async fn login_with_access_token(
last_refresh: None,
agent_identity: None,
personal_access_token: Some(access_token.to_string()),
bedrock_api_key: None,
}
}
CodexAccessToken::AgentIdentityJwt(jwt) => {
@@ -627,6 +651,7 @@ pub async fn login_with_access_token(
last_refresh: None,
agent_identity: Some(jwt.to_string()),
personal_access_token: None,
bedrock_api_key: None,
}
}
};
@@ -698,7 +723,8 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
if let Some(required_method) = config.forced_login_method {
let method_violation = match (required_method, auth.auth_mode()) {
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
(ForcedLoginMethod::Api, AuthMode::ApiKey)
| (ForcedLoginMethod::Api, AuthMode::BedrockApiKey) => None,
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt)
| (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens)
| (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity)
@@ -710,7 +736,8 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
"API key login is required, but ChatGPT is currently being used. Logging out."
.to_string(),
),
(ForcedLoginMethod::Chatgpt, AuthMode::ApiKey) => Some(
(ForcedLoginMethod::Chatgpt, AuthMode::ApiKey)
| (ForcedLoginMethod::Chatgpt, AuthMode::BedrockApiKey) => Some(
"ChatGPT login is required, but an API key is currently being used. Logging out."
.to_string(),
),
@@ -727,7 +754,9 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
let chatgpt_account_id = match &auth {
CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()),
CodexAuth::ApiKey(_)
| CodexAuth::PersonalAccessToken(_)
| CodexAuth::BedrockApiKey(_) => return Ok(()),
CodexAuth::AgentIdentity(_) => auth.get_account_id(),
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
let token_data = match auth.get_token_data() {
@@ -1043,6 +1072,7 @@ impl AuthDotJson {
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
})
}
@@ -1059,13 +1089,16 @@ impl AuthDotJson {
Self::from_external_tokens(&external)
}
fn resolved_mode(&self) -> ApiAuthMode {
pub(super) fn resolved_mode(&self) -> ApiAuthMode {
if let Some(mode) = self.auth_mode {
return mode;
}
if self.personal_access_token.is_some() {
return ApiAuthMode::PersonalAccessToken;
}
if self.bedrock_api_key.is_some() {
return ApiAuthMode::BedrockApiKey;
}
if self.openai_api_key.is_some() {
return ApiAuthMode::ApiKey;
}
@@ -1590,6 +1623,7 @@ impl AuthManager {
_ => false,
},
(ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b,
(ApiAuthMode::BedrockApiKey, ApiAuthMode::BedrockApiKey) => a == b,
_ => false,
},
_ => false,
@@ -1851,7 +1885,8 @@ impl AuthManager {
}
CodexAuth::ApiKey(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => Ok(()),
| CodexAuth::PersonalAccessToken(_)
| CodexAuth::BedrockApiKey(_) => Ok(()),
};
if let Err(RefreshTokenError::Permanent(error)) = &result {
self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
+3
View File
@@ -1,5 +1,6 @@
mod access_token;
mod agent_identity;
mod bedrock_api_key;
pub mod default_client;
pub mod error;
mod personal_access_token;
@@ -10,6 +11,8 @@ mod external_bearer;
mod manager;
mod revoke;
pub use bedrock_api_key::BedrockApiKeyAuth;
pub use bedrock_api_key::login_with_bedrock_api_key;
pub use error::RefreshTokenFailedError;
pub use error::RefreshTokenFailedReason;
pub use manager::*;
+4
View File
@@ -18,6 +18,7 @@ use std::sync::Arc;
use std::sync::Mutex;
use tracing::warn;
use super::BedrockApiKeyAuth;
use crate::token_data::TokenData;
use codex_agent_identity::AgentIdentityJwtClaims;
use codex_agent_identity::decode_agent_identity_jwt;
@@ -48,6 +49,9 @@ pub struct AuthDotJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personal_access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bedrock_api_key: Option<BedrockApiKeyAuth>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
+9
View File
@@ -20,6 +20,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> {
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
storage
@@ -42,6 +43,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> {
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
let file = get_auth_file(codex_home.path());
@@ -76,6 +78,7 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> {
last_refresh: None,
agent_identity: Some(agent_identity),
personal_access_token: None,
bedrock_api_key: None,
};
storage.save(&auth_dot_json)?;
@@ -96,6 +99,7 @@ async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result
last_refresh: None,
agent_identity: None,
personal_access_token: Some("at-example".to_string()),
bedrock_api_key: None,
};
storage.save(&auth_dot_json)?;
@@ -146,6 +150,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File);
storage.save(&auth_dot_json)?;
@@ -171,6 +176,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()>
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
storage.save(&auth_dot_json)?;
@@ -271,6 +277,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
}
}
@@ -297,6 +304,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
seed_keyring_with_auth(
&mock_keyring,
@@ -341,6 +349,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
storage.save(&auth)?;
+1
View File
@@ -40,6 +40,7 @@ pub use auth::enforce_login_restrictions;
pub use auth::load_auth_dot_json;
pub use auth::login_with_access_token;
pub use auth::login_with_api_key;
pub use auth::login_with_bedrock_api_key;
pub use auth::logout;
pub use auth::logout_with_revoke;
pub use auth::read_codex_access_token_from_env;
+2
View File
@@ -823,6 +823,7 @@ pub(crate) async fn persist_tokens_async(
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
save_auth(&codex_home, &auth, auth_credentials_store_mode)?;
Ok::<_, io::Error>((previous_auth, auth))
@@ -1327,6 +1328,7 @@ mod tests {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
}
}