Enforce workspace restrictions for header authentication (#39322)

## What changed

- Validate externally supplied header credentials against configured ChatGPT workspace restrictions using their `chatgpt-account-id` header.
- Reject header credentials with a missing or disallowed account ID, including credentials returned during refresh, without replacing the previously cached authentication.
- Document that identity headers must be bound to validated credentials because Codex uses them for managed authentication policy.

## Testing

- Add coverage for allowed, disallowed, and missing account IDs.
- Add coverage for rejecting a refresh that changes to a disallowed workspace.

GitOrigin-RevId: 3e3db2607cd8f9f1b8000472342e42e4b1cf00b2
This commit is contained in:
cooper-oai
2026-08-19 00:28:52 +00:00
committed by copyberry
parent f1087ff151
commit b537d5a097
3 changed files with 80 additions and 8 deletions
+2
View File
@@ -6,6 +6,8 @@ use http::HeaderMap;
///
/// The provider owns credential validation, rotation, and persistence. Codex
/// keeps the resolved headers in memory and attaches them to backend requests.
/// Identity headers must be derived from and bound to the validated credentials;
/// Codex may use them to enforce managed authentication policy.
#[derive(Clone, PartialEq, Eq)]
pub struct AuthHeaders {
headers: HeaderMap,
+73
View File
@@ -1319,6 +1319,36 @@ impl ExternalAuth for StaticExternalAuth {
}
}
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,
@@ -1460,6 +1490,49 @@ async fn external_auth_provider_can_install_headers() {
);
}
#[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");
+5 -8
View File
@@ -1219,10 +1219,7 @@ fn validate_auth_restrictions(
let Some(expected_workspaces) = expected_workspaces else {
return Ok(());
};
if matches!(
auth,
CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_)
) {
if matches!(auth, CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_)) {
return Ok(());
}
@@ -1317,12 +1314,12 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
let chatgpt_account_id = match &auth {
CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_) => {
CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_) => {
return Ok(());
}
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => {
auth.get_account_id()
}
CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_) => auth.get_account_id(),
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
let token_data = match auth.get_token_data() {
Ok(data) => data,