[codex-rs] enforce PAT workspace restrictions (#27450)

## Summary

- validate a hydrated personal access token's workspace against
`forced_chatgpt_workspace_id` before persisting `codex login
--with-access-token`
- apply the same PAT-only check when restricted auth managers load
environment, ephemeral, or persisted credentials
- enforce PAT workspace restrictions in the existing central
login-restriction path
- leave Agent Identity and cloud bootstrap behavior unchanged

## Scope

This is intentionally the small PAT-only change. It does not attempt the
broader auth-manager/bootstrap unification; that needs separate design
work.

## Validation

- `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR=/tmp/codex-pat-target just test
-p codex-login -p codex-cli` (410 passed)
- `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR=/tmp/codex-pat-target just fix
-p codex-login -p codex-cli`
- `just fmt`
- `git diff --check`

Context: https://openai.slack.com/archives/D0AUPLV03RQ/p1781138331548269
This commit is contained in:
cooper-oai authored and GitHub committed 2026-06-11 14:17:37 -07:00
1 parent 14df0e8833
commit b5f1bb75d4
3 files changed
+241 -17

No files matched your search

+57 -17
View File
@@ -279,6 +279,7 @@ impl CodexAuth {
codex_home,
/*enable_codex_api_key_env*/ false,
auth_credentials_store_mode,
/*forced_chatgpt_workspace_id*/ None,
chatgpt_base_url,
)
.await
@@ -621,11 +622,13 @@ pub async fn login_with_access_token(
codex_home: &Path,
access_token: &str,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&[String]>,
chatgpt_base_url: Option<&str>,
) -> std::io::Result<()> {
let auth_dot_json = match classify_codex_access_token(access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
PersonalAccessTokenAuth::load(access_token).await?;
let auth = PersonalAccessTokenAuth::load(access_token).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
AuthDotJson {
// Infer PAT auth from the credential field so older Codex builds can still
// deserialize auth.json after a rollback.
@@ -658,6 +661,14 @@ pub async fn login_with_access_token(
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
}
fn ensure_personal_access_token_workspace_allowed(
expected_workspace_ids: Option<&[String]>,
auth: &PersonalAccessTokenAuth,
) -> std::io::Result<()> {
crate::server::ensure_workspace_account_allowed(expected_workspace_ids, auth.account_id())
.map_err(|message| std::io::Error::new(std::io::ErrorKind::PermissionDenied, message))
}
/// Writes an in-memory auth payload for externally managed ChatGPT tokens.
pub fn login_with_chatgpt_auth_tokens(
codex_home: &Path,
@@ -714,6 +725,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
&config.codex_home,
/*enable_codex_api_key_env*/ true,
config.auth_credentials_store_mode,
/*forced_chatgpt_workspace_id*/ None,
config.chatgpt_base_url.as_deref(),
)
.await?
@@ -754,10 +766,10 @@ 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(_)
| CodexAuth::BedrockApiKey(_) => return Ok(()),
CodexAuth::AgentIdentity(_) => auth.get_account_id(),
CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_) => return Ok(()),
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => {
auth.get_account_id()
}
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
let token_data = match auth.get_token_data() {
Ok(data) => data,
@@ -833,6 +845,7 @@ async fn load_auth(
codex_home: &Path,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&[String]>,
chatgpt_base_url: Option<&str>,
) -> std::io::Result<Option<CodexAuth>> {
// API key via env var takes precedence over any other auth method.
@@ -854,15 +867,18 @@ async fn load_auth(
chatgpt_base_url,
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?;
}
return Ok(Some(auth));
}
if let Some(access_token) = read_codex_access_token_from_env() {
return match classify_codex_access_token(&access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
CodexAuth::from_personal_access_token(access_token)
.await
.map(Some)
let auth = PersonalAccessTokenAuth::load(access_token).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
Ok(Some(CodexAuth::PersonalAccessToken(auth)))
}
CodexAccessToken::AgentIdentityJwt(jwt) => {
CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url)
@@ -891,6 +907,9 @@ async fn load_auth(
chatgpt_base_url,
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?;
}
Ok(Some(auth))
}
@@ -1438,11 +1457,29 @@ impl AuthManager {
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: Option<String>,
) -> Self {
Self::new_with_workspace_restriction(
codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
/*forced_chatgpt_workspace_id*/ None,
chatgpt_base_url,
)
.await
}
async fn new_with_workspace_restriction(
codex_home: PathBuf,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<Vec<String>>,
chatgpt_base_url: Option<String>,
) -> Self {
let managed_auth = load_auth(
&codex_home,
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
chatgpt_base_url.as_deref(),
)
.await
@@ -1458,7 +1495,7 @@ impl AuthManager {
auth_change_tx,
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id: RwLock::new(None),
forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id),
chatgpt_base_url,
refresh_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
@@ -1658,10 +1695,12 @@ impl AuthManager {
}
async fn load_auth_from_storage(&self) -> Option<CodexAuth> {
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
load_auth(
&self.codex_home,
self.enable_codex_api_key_env,
self.auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
self.chatgpt_base_url.as_deref(),
)
.await
@@ -1753,15 +1792,16 @@ impl AuthManager {
config: &impl AuthManagerConfig,
enable_codex_api_key_env: bool,
) -> Arc<Self> {
let auth_manager = Self::shared(
config.codex_home(),
enable_codex_api_key_env,
config.cli_auth_credentials_store_mode(),
Some(config.chatgpt_base_url()),
Arc::new(
Self::new_with_workspace_restriction(
config.codex_home(),
enable_codex_api_key_env,
config.cli_auth_credentials_store_mode(),
config.forced_chatgpt_workspace_id(),
Some(config.chatgpt_base_url()),
)
.await,
)
.await;
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id());
auth_manager
}
pub fn unauthorized_recovery(self: &Arc<Self>) -> UnauthorizedRecovery {