Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion codex-rs/app-server-protocol/schema/typescript/AuthMode.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ pub enum AuthMode {
#[ts(rename = "chatgptAuthTokens")]
#[strum(serialize = "chatgptAuthTokens")]
ChatgptAuthTokens,
/// Backend auth supplied as request headers.
#[serde(rename = "headers")]
#[ts(rename = "headers")]
#[strum(serialize = "headers")]
Headers,
/// Programmatic Codex auth backed by a registered Agent Identity.
#[serde(rename = "agentIdentity")]
#[ts(rename = "agentIdentity")]
Expand All @@ -53,7 +58,7 @@ impl AuthMode {
pub fn has_chatgpt_account(self) -> bool {
match self {
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false,
}
}

Expand All @@ -62,6 +67,7 @@ impl AuthMode {
match self {
Self::Chatgpt
| Self::ChatgptAuthTokens
| Self::Headers
| Self::AgentIdentity
| Self::PersonalAccessToken => true,
Self::ApiKey | Self::BedrockApiKey => false,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/src/auth_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub(crate) fn auth_mode_to_api(auth_mode: AuthMode) -> ApiAuthMode {
AuthMode::ApiKey => ApiAuthMode::ApiKey,
AuthMode::Chatgpt => ApiAuthMode::Chatgpt,
AuthMode::ChatgptAuthTokens => ApiAuthMode::ChatgptAuthTokens,
AuthMode::Headers => ApiAuthMode::Headers,
AuthMode::AgentIdentity => ApiAuthMode::AgentIdentity,
AuthMode::PersonalAccessToken => ApiAuthMode::PersonalAccessToken,
AuthMode::BedrockApiKey => ApiAuthMode::BedrockApiKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,9 @@ impl AccountRequestProcessor {
let auth_mode = auth_mode_to_api(auth.api_auth_mode());
let (reported_auth_method, token_opt) = if matches!(
auth,
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_)
CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_)
) || include_token
&& permanent_refresh_failure
{
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str {
AuthMode::ApiKey => "api_key",
AuthMode::Chatgpt => "chatgpt",
AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
AuthMode::Headers => "headers",
AuthMode::AgentIdentity => "agent_identity",
AuthMode::PersonalAccessToken => "personal_access_token",
AuthMode::BedrockApiKey => "bedrock_api_key",
Expand Down Expand Up @@ -1397,6 +1398,9 @@ fn stored_auth_issues(
issues.push("external ChatGPT auth is missing refresh metadata");
}
}
AuthMode::Headers => {
issues.push("header auth cannot be loaded from auth storage");
}
AuthMode::AgentIdentity => {
if auth
.agent_identity
Expand Down Expand Up @@ -2466,6 +2470,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str {
AuthMode::ApiKey => "api_key",
AuthMode::Chatgpt => "chatgpt",
AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
AuthMode::Headers => "headers",
AuthMode::AgentIdentity => "agent_identity",
AuthMode::PersonalAccessToken => "personal_access_token",
AuthMode::BedrockApiKey => "bedrock_api_key",
Expand Down Expand Up @@ -2605,6 +2610,7 @@ fn provider_auth_reachability_mode_from_auth(
Some(
AuthMode::Chatgpt
| AuthMode::ChatgptAuthTokens
| AuthMode::Headers
| AuthMode::AgentIdentity
| AuthMode::PersonalAccessToken,
)
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/cli/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,9 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
eprintln!("Logged in using ChatGPT");
std::process::exit(0);
}
AuthMode::Headers => {
unreachable!("header auth cannot be loaded from auth storage")
}
AuthMode::AgentIdentity => {
eprintln!("Logged in using access token");
std::process::exit(0);
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/core-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ pub use codex_extension_api::empty_extension_registry;
pub use codex_features::Feature;
pub use codex_features::Features;
pub use codex_home::CodexHomeUserInstructionsProvider;
pub use codex_login::AuthHeaders;
pub use codex_login::AuthManager;
pub use codex_login::CodexAuth;
pub use codex_login::ExternalAuth;
pub use codex_login::ExternalAuthFuture;
pub use codex_login::ExternalAuthRefreshContext;
pub use codex_login::ExternalAuthRefreshReason;
pub use codex_login::default_client::set_default_originator;
pub use codex_model_provider_info::OPENAI_PROVIDER_ID;
pub use codex_model_provider_info::built_in_model_providers;
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2141,6 +2141,7 @@ impl AuthRequestTelemetryContext {
AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey",
AuthMode::Chatgpt
| AuthMode::ChatgptAuthTokens
| AuthMode::Headers
| AuthMode::AgentIdentity
| AuthMode::PersonalAccessToken => "Chatgpt",
}),
Expand Down
65 changes: 65 additions & 0 deletions codex-rs/core/tests/suite/external_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use std::sync::Arc;

use codex_login::AuthHeaders;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use reqwest::header::AUTHORIZATION;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;

#[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> {
self.resolve()
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_header_auth_is_attached_to_responses_requests() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));

let server = start_mock_server().await;
let response_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer external"));
headers.insert("x-external-auth", HeaderValue::from_static("enabled"));
let external_auth = StaticExternalAuth(CodexAuth::Headers(AuthHeaders::new(headers)));
let mut builder = test_codex();
let test = builder.build_with_auto_env(&server).await?;
test.thread_manager
.auth_manager()
.set_external_auth(Arc::new(external_auth))
.await?;

test.submit_turn("hello").await?;

let request = response_mock.single_request();
assert_eq!(
request.header("authorization").as_deref(),
Some("Bearer external")
);
assert_eq!(
request.header("x-external-auth").as_deref(),
Some("enabled")
);
Ok(())
}
1 change: 1 addition & 0 deletions codex-rs/core/tests/suite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ mod exec;
mod exec_policy;
#[cfg(not(target_os = "windows"))]
mod extension_sandbox;
mod external_auth;
mod fork_thread;
#[cfg(not(target_os = "windows"))]
mod guardian_review;
Expand Down
30 changes: 30 additions & 0 deletions codex-rs/login/src/auth/auth_headers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
use std::fmt;

use reqwest::header::HeaderMap;

/// Request headers returned by an external auth provider.
///
/// The provider owns credential validation, rotation, and persistence. Codex
/// keeps the resolved headers in memory and attaches them to backend requests.
#[derive(Clone, PartialEq, Eq)]
pub struct AuthHeaders {
headers: HeaderMap,
}

impl AuthHeaders {
pub fn new(headers: HeaderMap) -> Self {
Self { headers }
}

pub fn headers(&self) -> &HeaderMap {
&self.headers
}
}

impl fmt::Debug for AuthHeaders {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthHeaders")
.field("headers", &"<redacted>")
.finish()
}
}
55 changes: 55 additions & 0 deletions codex-rs/login/src/auth/auth_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,61 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
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()) })
}
}

#[tokio::test]
async fn external_auth_provider_can_install_headers() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_static("Bearer external"),
);
headers.insert(
"x-external-auth",
reqwest::header::HeaderValue::from_static("enabled"),
);
let auth = CodexAuth::Headers(AuthHeaders::new(headers));
let codex_home = tempdir().expect("tempdir");
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(),
/*auth_route_config*/ None,
)
.await;

manager
.set_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
.await
.expect("external auth should install");

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())
);
}

struct ProviderAuthScript {
tempdir: TempDir,
command: String,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/login/src/auth/bedrock_api_key_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()>
CodexAuth::ApiKey(_)
| CodexAuth::Chatgpt(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => None,
}),
Expand Down Expand Up @@ -148,6 +149,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()>
CodexAuth::ApiKey(_)
| CodexAuth::Chatgpt(_)
| CodexAuth::ChatgptAuthTokens(_)
| CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => None,
}),
Expand Down
Loading
Loading