From f06f0a7f3e0fd363389305c6c52628563fb53729 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 30 Mar 2026 14:32:02 -0700 Subject: [PATCH] auth: generalize ExternalAuthTokens for bearer-only refreshers --- codex-rs/app-server/src/message_processor.rs | 10 +-- codex-rs/login/src/auth/auth_tests.rs | 10 +++ codex-rs/login/src/auth/manager.rs | 68 ++++++++++++++++---- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7f0e1d18f8..37ed7421cc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -144,11 +144,11 @@ impl ExternalAuthRefresher for ExternalAuthRefreshBridge { let response: ChatgptAuthTokensRefreshResponse = serde_json::from_value(result).map_err(std::io::Error::other)?; - Ok(ExternalAuthTokens { - access_token: response.access_token, - chatgpt_account_id: response.chatgpt_account_id, - chatgpt_plan_type: response.chatgpt_plan_type, - }) + Ok(ExternalAuthTokens::chatgpt( + response.access_token, + response.chatgpt_account_id, + response.chatgpt_plan_type, + )) } } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index ef0238d3cc..f511e2593e 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -205,6 +205,16 @@ fn unauthorized_recovery_reports_mode_and_step_names() { assert_eq!(external.step_name(), "external_refresh"); } +#[test] +fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() { + let err = AuthDotJson::from_external_tokens(&ExternalAuthTokens::access_token_only("token")) + .expect_err("ChatGPT auth should require ChatGPT metadata"); + assert_eq!( + err.to_string(), + "external auth tokens are missing ChatGPT metadata" + ); +} + #[test] fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { let codex_home = tempdir().unwrap(); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 19d13fc08a..faf8abac30 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -90,11 +90,43 @@ pub enum RefreshTokenError { Transient(#[from] std::io::Error), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalAuthChatgptMetadata { + pub account_id: String, + pub plan_type: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalAuthTokens { pub access_token: String, - pub chatgpt_account_id: String, - pub chatgpt_plan_type: Option, + pub chatgpt_metadata: Option, +} + +impl ExternalAuthTokens { + pub fn access_token_only(access_token: impl Into) -> Self { + Self { + access_token: access_token.into(), + chatgpt_metadata: None, + } + } + + pub fn chatgpt( + access_token: impl Into, + chatgpt_account_id: impl Into, + chatgpt_plan_type: Option, + ) -> Self { + Self { + access_token: access_token.into(), + chatgpt_metadata: Some(ExternalAuthChatgptMetadata { + account_id: chatgpt_account_id.into(), + plan_type: chatgpt_plan_type, + }), + } + } + + pub fn chatgpt_metadata(&self) -> Option<&ExternalAuthChatgptMetadata> { + self.chatgpt_metadata.as_ref() + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -736,11 +768,16 @@ fn refresh_token_endpoint() -> String { impl AuthDotJson { fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result { + let Some(chatgpt_metadata) = external.chatgpt_metadata() else { + return Err(std::io::Error::other( + "external auth tokens are missing ChatGPT metadata", + )); + }; let mut token_info = parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?; - token_info.chatgpt_account_id = Some(external.chatgpt_account_id.clone()); - token_info.chatgpt_plan_type = external - .chatgpt_plan_type + token_info.chatgpt_account_id = Some(chatgpt_metadata.account_id.clone()); + token_info.chatgpt_plan_type = chatgpt_metadata + .plan_type .as_deref() .map(InternalPlanType::from_raw_value) .or(token_info.chatgpt_plan_type) @@ -749,7 +786,7 @@ impl AuthDotJson { id_token: token_info, access_token: external.access_token.clone(), refresh_token: String::new(), - account_id: Some(external.chatgpt_account_id.clone()), + account_id: Some(chatgpt_metadata.account_id.clone()), }; Ok(Self { @@ -765,11 +802,11 @@ impl AuthDotJson { chatgpt_account_id: &str, chatgpt_plan_type: Option<&str>, ) -> std::io::Result { - let external = ExternalAuthTokens { - access_token: access_token.to_string(), - chatgpt_account_id: chatgpt_account_id.to_string(), - chatgpt_plan_type: chatgpt_plan_type.map(str::to_string), - }; + let external = ExternalAuthTokens::chatgpt( + access_token, + chatgpt_account_id, + chatgpt_plan_type.map(str::to_string), + ); Self::from_external_tokens(&external) } @@ -1457,13 +1494,18 @@ impl AuthManager { }; let refreshed = refresher.refresh(context).await?; + let Some(chatgpt_metadata) = refreshed.chatgpt_metadata() else { + return Err(RefreshTokenError::Transient(std::io::Error::other( + "external auth refresh did not return ChatGPT metadata", + ))); + }; if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() - && refreshed.chatgpt_account_id != expected_workspace_id + && chatgpt_metadata.account_id != expected_workspace_id { return Err(RefreshTokenError::Transient(std::io::Error::other( format!( "external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}", - refreshed.chatgpt_account_id, + chatgpt_metadata.account_id, ), ))); }