mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user