From db078158c302276b63f0d6ae3120433c6ca94a49 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 15 Sep 2026 17:21:21 +0000 Subject: [PATCH] Add account-bound authentication for analytics requests (#45742) ## What changed - Add `AnalyticsSession` to load local ChatGPT credentials, bind requests to the initial account and user, and reject identity changes before requests or when accepting results. - Reload credentials and provide bounded recovery for unauthorized requests, using a backend client that disables redirects. - Add lazy TUI analytics session initialization, account display metadata, and plan-specific credit groupings for future dashboard integration. ## Testing Add regression tests for account and user changes, missing or API-key-only authentication, credential reloads, recovery after a `401` response, and session initialization after signing in. GitOrigin-RevId: 2ba0d96fb44936d12d13aedffc3f2e61aedea560 --- .../backend-client/src/analytics_session.rs | 106 +++++++++ codex-rs/backend-client/src/lib.rs | 3 + codex-rs/tui/src/analytics.rs | 1 + codex-rs/tui/src/analytics/client.rs | 73 ++++++ codex-rs/tui/src/analytics/client_tests.rs | 223 ++++++++++++++++++ codex-rs/tui/src/analytics/data.rs | 14 +- codex-rs/tui/src/analytics/models.rs | 33 +++ 7 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 codex-rs/backend-client/src/analytics_session.rs create mode 100644 codex-rs/tui/src/analytics/client.rs create mode 100644 codex-rs/tui/src/analytics/client_tests.rs diff --git a/codex-rs/backend-client/src/analytics_session.rs b/codex-rs/backend-client/src/analytics_session.rs new file mode 100644 index 0000000000..9919dfe846 --- /dev/null +++ b/codex-rs/backend-client/src/analytics_session.rs @@ -0,0 +1,106 @@ +//! Account-scoped analytics authentication, credential recovery, and request identity checks. + +use crate::Client; +use crate::RequestError; +use codex_http_client::HttpClientFactory; +use codex_login::AuthManager; +use codex_login::AuthManagerConfig; +use codex_login::CodexAuth; +use codex_protocol::account::PlanType; +use std::sync::Arc; + +/// Non-secret account metadata associated with an analytics session. +#[derive(Clone, Debug)] +pub struct AnalyticsAccount { + pub id: String, + pub email: Option, + pub plan_type: Option, +} + +/// A backend client that remains bound to its initial ChatGPT account and user. +pub struct AnalyticsSession { + client: Client, + auth_manager: Arc, + auth: CodexAuth, + account: AnalyticsAccount, +} + +impl AnalyticsSession { + /// Load local ChatGPT credentials using the configured auth and HTTP policies. + pub async fn from_config( + config: &impl AuthManagerConfig, + http_client_factory: HttpClientFactory, + ) -> Result { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) + .await + .map_err(|_| { + "Couldn't load local sign-in. Sign in with ChatGPT and retry.".to_string() + })?; + let auth = auth_manager + .auth() + .await + .filter(CodexAuth::is_chatgpt_auth) + .ok_or("Sign in locally with ChatGPT to view Analytics.")?; + let (Some(id), Some(_)) = (auth.get_account_id(), auth.get_chatgpt_user_id()) else { + return Err("Analytics requires a ChatGPT account and user identity.".into()); + }; + let account = AnalyticsAccount { + id, + email: auth.get_account_email(), + plan_type: auth.account_plan_type(), + }; + let client = Client::new_without_redirects(config.chatgpt_base_url(), http_client_factory) + .with_auth_provider(codex_model_provider::auth_provider_from_auth_manager( + Arc::clone(&auth_manager), + &auth, + )); + Ok(Self { + client, + auth_manager, + auth, + account, + }) + } + + /// Return the account metadata captured when this session was opened. + pub fn account(&self) -> &AnalyticsAccount { + &self.account + } + /// Reject responses or cached data after a local account or user switch. + pub async fn ensure_identity(&self) -> Result<(), String> { + self.auth_manager.reload().await; + let current = self.auth_manager.auth().await; + if current.is_none_or(|auth| { + auth.get_account_id() != self.auth.get_account_id() + || auth.get_chatgpt_user_id() != self.auth.get_chatgpt_user_id() + }) { + return Err("Account changed. Press R to refresh Analytics.".into()); + } + Ok(()) + } + + /// Run an account-scoped request with bounded unauthorized recovery. + pub async fn request(&self, request: impl Fn(Client) -> F) -> Result + where + F: std::future::Future>, + { + let mut recovery = self.auth_manager.unauthorized_recovery(); + loop { + self.ensure_identity() + .await + .map_err(|error| RequestError::Other(anyhow::anyhow!(error)))?; + let result = request(self.client.clone()).await; + if result.as_ref().is_err_and(RequestError::is_unauthorized) && recovery.has_next() { + recovery.next().await.map_err(|_| { + RequestError::Other(anyhow::anyhow!("Sign in again to view Analytics.")) + })?; + continue; + } + self.ensure_identity() + .await + .map_err(|error| RequestError::Other(anyhow::anyhow!(error)))?; + return result; + } + } +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index a168cad1e0..2cb459de7d 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -1,6 +1,9 @@ +mod analytics_session; mod client; pub(crate) mod types; +pub use analytics_session::AnalyticsAccount; +pub use analytics_session::AnalyticsSession; pub use client::AddCreditsNudgeCreditType; pub use client::ChatgptThreadTurnCosts; pub use client::ChatgptTurnCost; diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index 1ebb5bbc35..8a53f2b6aa 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -1,5 +1,6 @@ //! Account analytics data preparation, staged for the dashboard integration. +mod client; mod data; mod models; mod normalize; diff --git a/codex-rs/tui/src/analytics/client.rs b/codex-rs/tui/src/analytics/client.rs new file mode 100644 index 0000000000..48cd7b2f7f --- /dev/null +++ b/codex-rs/tui/src/analytics/client.rs @@ -0,0 +1,73 @@ +//! Local account sessions and display metadata for authenticated analytics. +//! The backend client owns credentials, recovery, and request identity checks. + +use super::models::AccountAnalyticsGrouping as Grouping; +use super::models::AccountKind; +use crate::legacy_core::config::Config; +use codex_backend_client::AnalyticsSession; +use std::sync::Arc; +use tokio::sync::OnceCell; + +pub(super) struct Live { + config: Arc, + session: OnceCell, +} + +pub(super) struct Session { + pub(super) kind: AccountKind, + pub(super) backend: AnalyticsSession, + credit_groups: Vec, +} + +impl Live { + pub(super) fn new(config: Arc) -> Self { + Self { + config, + session: OnceCell::new(), + } + } + + pub(super) fn account_label(&self) -> Option { + let session = self.session.get()?; + let account = session.backend.account(); + Some(match &account.email { + Some(email) => format!("{email} · {}", account.id), + None => account.id.clone(), + }) + } + + pub(super) fn credit_groups(&self) -> &[usize] { + self.session + .get() + .map_or(&[0], |session| &session.credit_groups) + } + + pub(super) async fn session(&self) -> Result<&Session, String> { + self.session + .get_or_try_init(|| async { + let session = AnalyticsSession::from_config( + self.config.as_ref(), + self.config.http_client_factory(), + ) + .await?; + let credit_groups = Grouping::credit_groupings(session.account().plan_type) + .iter() + .filter_map(|group| { + super::data::GROUPINGS + .iter() + .position(|candidate| candidate == group) + }) + .collect(); + Ok(Session { + kind: AccountKind::from(session.account().plan_type), + credit_groups, + backend: session, + }) + }) + .await + } +} + +#[cfg(test)] +#[path = "client_tests.rs"] +pub(super) mod tests; diff --git a/codex-rs/tui/src/analytics/client_tests.rs b/codex-rs/tui/src/analytics/client_tests.rs new file mode 100644 index 0000000000..ce5601b811 --- /dev/null +++ b/codex-rs/tui/src/analytics/client_tests.rs @@ -0,0 +1,223 @@ +//! Account-scoped authentication and request identity regression coverage. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use base64::Engine; +use codex_config::LoaderOverrides; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn sign_in(home: &std::path::Path, account: &str, user: &str, plan: &str) { + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + json!({ + "exp": 4102444800_i64, "email": "analytics@example.test", + "https://api.openai.com/auth": { + "chatgpt_account_id": account, "chatgpt_user_id": user, "chatgpt_plan_type": plan, + }, + }) + .to_string(), + ); + let token = format!("e30.{claims}.test"); + let auth = serde_json::from_value(json!({ + "auth_mode": "chatgpt", "tokens": {"id_token": token, "access_token": token, + "refresh_token": "test-refresh", "account_id": account}, + "last_refresh": chrono::Utc::now(), + })) + .unwrap(); + codex_login::save_auth( + home, + &auth, + codex_login::AuthCredentialsStoreMode::File, + codex_login::AuthKeyringBackendKind::default(), + ) + .unwrap(); +} + +pub(in crate::analytics) async fn live( + server: &MockServer, + plan: &str, +) -> (tempfile::TempDir, Live) { + let home = tempfile::tempdir().unwrap(); + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await + .unwrap(); + config.chatgpt_base_url = format!("{}/backend-api", server.uri()); + config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File; + sign_in(home.path(), "account-a", "user-a", plan); + (home, Live::new(Arc::new(config))) +} + +#[tokio::test] +async fn analytics_rejects_identity_changes_during_requests() { + let server = MockServer::start().await; + let (home, live) = live(&server, "business").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + let result = authenticated + .request(|_| async { + sign_in(home.path(), "account-b", "user-a", "business"); + Ok(123) + }) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "Account changed. Press R to refresh Analytics." + ); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn analytics_rejects_account_and_user_changes_before_requests() { + for (account, user) in [("account-b", "user-a"), ("account-a", "user-b")] { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + sign_in(home.path(), account, user, "plus"); + let requested = std::cell::Cell::new(/*value*/ false); + let result = authenticated + .request(|_| async { + requested.set(/*val*/ true); + Ok(()) + }) + .await; + assert!(!requested.get()); + assert_eq!( + result.unwrap_err().to_string(), + "Account changed. Press R to refresh Analytics." + ); + } +} + +#[tokio::test] +async fn analytics_requires_local_chatgpt_authentication() { + for auth in [None, Some(json!({"OPENAI_API_KEY": "sk-test-only"}))] { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let path = home.path().join("auth.json"); + match auth { + Some(auth) => std::fs::write(path, serde_json::to_vec(&auth).unwrap()).unwrap(), + None => std::fs::remove_file(path).unwrap(), + } + assert_eq!( + live.session().await.err(), + Some("Sign in locally with ChatGPT to view Analytics.".to_string()) + ); + assert!(server.received_requests().await.unwrap().is_empty()); + } +} + +#[tokio::test] +async fn analytics_requests_use_reloaded_credentials_for_the_same_identity() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + assert_eq!( + live.account_label().as_deref(), + Some("analytics@example.test · account-a") + ); + let auth_path = home.path().join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + auth["tokens"]["access_token"] = json!("refreshed-access-token"); + std::fs::write(auth_path, serde_json::to_vec(&auth).unwrap()).unwrap(); + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage/daily-token-usage-breakdown")) + .and(header("chatgpt-account-id", "account-a")) + .and(header("authorization", "Bearer refreshed-access-token")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []}))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let result = authenticated + .request(|client| async move { + client + .get_account_analytics( + codex_backend_client::AnalyticsReport::Usage, + "2026-09-01", + "2026-09-07", + ) + .await + }) + .await + .unwrap(); + assert_eq!( + result, + codex_backend_client::AnalyticsResponse::Usage( + codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default(), + ), + ); + server.verify().await; +} + +#[tokio::test] +async fn analytics_retries_unauthorized_requests_after_credentials_reload() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + let session = live.session().await.unwrap(); + let authenticated = &session.backend; + let auth_path = home.path().join("auth.json"); + let mut auth: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + let original_token = auth["tokens"]["access_token"].as_str().unwrap().to_owned(); + auth["tokens"]["access_token"] = json!("recovered-access-token"); + let updated_auth = serde_json::to_vec(&auth).unwrap(); + Mock::given(method("GET")) + .and(header("authorization", format!("Bearer {original_token}"))) + .respond_with(move |_: &wiremock::Request| { + std::fs::write(&auth_path, &updated_auth).unwrap(); + ResponseTemplate::new(/*s*/ 401) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(header("authorization", "Bearer recovered-access-token")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []}))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let result = authenticated + .request(|client| async move { + client + .get_account_analytics( + codex_backend_client::AnalyticsReport::Usage, + "2026-09-01", + "2026-09-07", + ) + .await + }) + .await + .unwrap(); + assert_eq!( + result, + codex_backend_client::AnalyticsResponse::Usage( + codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default(), + ), + ); + server.verify().await; +} + +#[tokio::test] +async fn analytics_retries_session_initialization_after_sign_in() { + let server = MockServer::start().await; + let (home, live) = live(&server, "plus").await; + std::fs::remove_file(home.path().join("auth.json")).unwrap(); + assert!(live.session().await.is_err()); + sign_in(home.path(), "account-a", "user-a", "plus"); + assert_eq!( + live.session().await.unwrap().backend.account().id, + "account-a" + ); +} diff --git a/codex-rs/tui/src/analytics/data.rs b/codex-rs/tui/src/analytics/data.rs index 9eca2ac263..d72fe15837 100644 --- a/codex-rs/tui/src/analytics/data.rs +++ b/codex-rs/tui/src/analytics/data.rs @@ -1,4 +1,16 @@ -//! Precise signed credit amounts and dates for analytics presentation. +//! Grouping metadata and numeric formatting for account analytics. + +use super::models::AccountAnalyticsGrouping as Grouping; + +pub(super) const GROUPINGS: [Grouping; 7] = [ + Grouping::Surface, + Grouping::Feature, + Grouping::Model, + Grouping::TaskStart, + Grouping::Speed, + Grouping::Reasoning, + Grouping::TokenType, +]; /// Keep tiny refunds visible while avoiding noise on ordinary credit amounts. pub(super) fn amount(value: f64) -> String { diff --git a/codex-rs/tui/src/analytics/models.rs b/codex-rs/tui/src/analytics/models.rs index 9523963ab5..ab78ee3311 100644 --- a/codex-rs/tui/src/analytics/models.rs +++ b/codex-rs/tui/src/analytics/models.rs @@ -1,5 +1,27 @@ //! Private analytics display types, independent of the app-server wire protocol. +use codex_protocol::account::PlanType; + +/// Billing families match the App's consumer, business, and workspace scopes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AccountKind { + Consumer, + Business, + Enterprise, + Unknown, +} + +impl From> for AccountKind { + fn from(plan: Option) -> Self { + match plan { + None | Some(PlanType::Unknown) => Self::Unknown, + Some(plan) if plan.is_team_like() => Self::Business, + Some(plan) if plan.is_workspace_account() => Self::Enterprise, + Some(_) => Self::Consumer, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AccountAnalyticsReport { Usage, @@ -20,6 +42,17 @@ pub(crate) enum AccountAnalyticsGrouping { TokenType, } +impl AccountAnalyticsGrouping { + /// Credit breakdowns supported by the account's billing report. + pub(crate) fn credit_groupings(plan: Option) -> &'static [Self] { + match AccountKind::from(plan) { + AccountKind::Business => &[Self::Surface, Self::Model, Self::Speed], + AccountKind::Enterprise => &[Self::Surface, Self::Model, Self::Speed, Self::Reasoning], + AccountKind::Consumer | AccountKind::Unknown => &[Self::Surface], + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AccountAnalyticsUnit { RelativeUsage,