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
This commit is contained in:
Felipe Coury
2026-09-15 17:21:21 +00:00
committed by copyberry
parent 7224096b85
commit db078158c3
7 changed files with 452 additions and 1 deletions

View File

@@ -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<String>,
pub plan_type: Option<PlanType>,
}
/// A backend client that remains bound to its initial ChatGPT account and user.
pub struct AnalyticsSession {
client: Client,
auth_manager: Arc<AuthManager>,
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<Self, String> {
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<T, F>(&self, request: impl Fn(Client) -> F) -> Result<T, RequestError>
where
F: std::future::Future<Output = Result<T, RequestError>>,
{
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;
}
}
}

View File

@@ -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;

View File

@@ -1,5 +1,6 @@
//! Account analytics data preparation, staged for the dashboard integration.
mod client;
mod data;
mod models;
mod normalize;

View File

@@ -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<Config>,
session: OnceCell<Session>,
}
pub(super) struct Session {
pub(super) kind: AccountKind,
pub(super) backend: AnalyticsSession,
credit_groups: Vec<usize>,
}
impl Live {
pub(super) fn new(config: Arc<Config>) -> Self {
Self {
config,
session: OnceCell::new(),
}
}
pub(super) fn account_label(&self) -> Option<String> {
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;

View File

@@ -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"
);
}

View File

@@ -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 {

View File

@@ -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<Option<PlanType>> for AccountKind {
fn from(plan: Option<PlanType>) -> 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<PlanType>) -> &'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,