use base64::Engine; use serde::Deserialize; use serde::Serialize; use thiserror::Error; #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] pub struct TokenData { /// Flat info parsed from the JWT in auth.json. #[serde( deserialize_with = "deserialize_id_token", serialize_with = "serialize_id_token" )] pub id_token: IdTokenInfo, /// This is a JWT. pub access_token: String, pub refresh_token: String, pub account_id: Option, } /// Flat subset of useful claims in id_token from auth.json. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct IdTokenInfo { pub email: Option, /// The ChatGPT subscription plan type /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, /// ChatGPT user identifier associated with the token, if present. pub chatgpt_user_id: Option, /// Organization/workspace identifier associated with the token, if present. pub chatgpt_account_id: Option, pub raw_jwt: String, } impl IdTokenInfo { pub fn get_chatgpt_plan_type(&self) -> Option { self.chatgpt_plan_type.as_ref().map(|t| match t { PlanType::Known(plan) => format!("{plan:?}"), PlanType::Unknown(s) => s.clone(), }) } pub fn is_workspace_account(&self) -> bool { matches!( self.chatgpt_plan_type, Some(PlanType::Known( KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu )) ) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub(crate) enum PlanType { Known(KnownPlan), Unknown(String), } impl PlanType { pub(crate) fn from_raw_value(raw: &str) -> Self { match raw.to_ascii_lowercase().as_str() { "free" => Self::Known(KnownPlan::Free), "go" => Self::Known(KnownPlan::Go), "plus" => Self::Known(KnownPlan::Plus), "pro" => Self::Known(KnownPlan::Pro), "team" => Self::Known(KnownPlan::Team), "business" => Self::Known(KnownPlan::Business), "enterprise" => Self::Known(KnownPlan::Enterprise), "education" | "edu" => Self::Known(KnownPlan::Edu), _ => Self::Unknown(raw.to_string()), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(crate) enum KnownPlan { Free, Go, Plus, Pro, Team, Business, Enterprise, Edu, } #[derive(Deserialize)] struct IdClaims { #[serde(default)] email: Option, #[serde(rename = "https://api.openai.com/profile", default)] profile: Option, #[serde(rename = "https://api.openai.com/auth", default)] auth: Option, } #[derive(Deserialize)] struct ProfileClaims { #[serde(default)] email: Option, } #[derive(Deserialize)] struct AuthClaims { #[serde(default)] chatgpt_plan_type: Option, #[serde(default)] chatgpt_user_id: Option, #[serde(default)] user_id: Option, #[serde(default)] chatgpt_account_id: Option, } #[derive(Debug, Error)] pub enum IdTokenInfoError { #[error("invalid ID token format")] InvalidFormat, #[error(transparent)] Base64(#[from] base64::DecodeError), #[error(transparent)] Json(#[from] serde_json::Error), } pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result { // JWT format: header.payload.signature let mut parts = jwt.split('.'); let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), _ => return Err(IdTokenInfoError::InvalidFormat), }; let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; let email = claims .email .or_else(|| claims.profile.and_then(|profile| profile.email)); match claims.auth { Some(auth) => Ok(IdTokenInfo { email, raw_jwt: jwt.to_string(), chatgpt_plan_type: auth.chatgpt_plan_type, chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id), chatgpt_account_id: auth.chatgpt_account_id, }), None => Ok(IdTokenInfo { email, raw_jwt: jwt.to_string(), chatgpt_plan_type: None, chatgpt_user_id: None, chatgpt_account_id: None, }), } } fn deserialize_id_token<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; parse_chatgpt_jwt_claims(&s).map_err(serde::de::Error::custom) } fn serialize_id_token(id_token: &IdTokenInfo, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&id_token.raw_jwt) } #[cfg(test)] #[path = "token_data_tests.rs"] mod tests;