//! ChatGPT subscription browser OAuth state used by the AI settings page. //! //! Instead of using Rig's device-code flow (which requires users to copy a code), //! this module implements a standard OAuth 2.0 Authorization Code + PKCE flow: //! 1. Open the browser to OpenAI's authorize endpoint //! 2. User approves in browser //! 3. Browser redirects back to `galaxy://chatgpt/oauth2callback?code=...&state=...` //! 4. We exchange the code for tokens and write them to Rig's auth file use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use galaxy_core::channel::ChannelState; use galaxyui::{Entity, ModelContext, SingletonEntity}; use rand::Rng; use sha2::{Digest, Sha256}; use url::Url; const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/api/accounts/authorize"; const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/oauth/token"; const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const CHATGPT_SCOPES: &str = "openid profile email offline_access"; /// Current state of the local ChatGPT subscription connection. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ChatGPTAuthState { NotConnected, AwaitingBrowser, ExchangingToken, Connected, Failed(String), } #[derive(Clone, Debug)] pub(crate) enum ChatGPTAuthModelEvent { StateChanged, } /// Coordinates browser-based OAuth authorization for ChatGPT subscriptions. pub(crate) struct ChatGPTAuthModel { state: ChatGPTAuthState, /// PKCE code verifier stored between authorize and callback. pending_code_verifier: Option, /// CSRF state token stored between authorize and callback. pending_state: Option, } impl ChatGPTAuthModel { pub(crate) fn new() -> Self { let state = match load_or_import_auth_credentials() { Ok(_) => ChatGPTAuthState::Connected, Err(error) => { log::debug!( "[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}" ); ChatGPTAuthState::NotConnected } }; Self { state, pending_code_verifier: None, pending_state: None, } } pub(crate) fn state(&self) -> &ChatGPTAuthState { &self.state } /// Attempts to connect using existing Codex credentials, falling back to browser OAuth. pub(crate) fn connect(&mut self, ctx: &mut ModelContext) { if matches!( self.state, ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken ) { return; } // Try to import credentials from ~/.codex/auth.json first. if let Ok(()) = import_codex_credentials() { self.state = ChatGPTAuthState::Connected; ctx.emit(ChatGPTAuthModelEvent::StateChanged); return; } // No existing credentials — start the browser OAuth flow. let code_verifier = generate_random_string(64); let code_challenge = compute_code_challenge(&code_verifier); let state = generate_random_string(32); let redirect_uri = chatgpt_redirect_uri(); let authorize_url = format!( "{CHATGPT_AUTHORIZE_URL}?\ client_id={CHATGPT_CLIENT_ID}\ &response_type=code\ &redirect_uri={redirect_uri}\ &code_challenge={code_challenge}\ &code_challenge_method=S256\ &state={state}\ &scope={}", urlencoding::encode(CHATGPT_SCOPES), ); self.pending_code_verifier = Some(code_verifier); self.pending_state = Some(state); self.state = ChatGPTAuthState::AwaitingBrowser; ctx.emit(ChatGPTAuthModelEvent::StateChanged); ctx.open_url(&authorize_url); } /// Called when the OS routes back `galaxy://chatgpt/oauth2callback?code=...&state=...` pub(crate) fn handle_oauth_callback(&mut self, url: &Url, ctx: &mut ModelContext) { let Some(expected_state) = self.pending_state.take() else { self.fail( "Received OAuth callback but no authorization was in progress.", ctx, ); return; }; let Some(code_verifier) = self.pending_code_verifier.take() else { self.fail("Received OAuth callback but code verifier is missing.", ctx); return; }; // Extract query parameters let params: std::collections::HashMap<_, _> = url.query_pairs().collect(); // Check for error response from the authorization server if let Some(error) = params.get("error") { let description = params .get("error_description") .map(|d| d.to_string()) .unwrap_or_else(|| error.to_string()); self.fail(&format!("ChatGPT authorization denied: {description}"), ctx); return; } let Some(code) = params.get("code") else { self.fail("OAuth callback missing authorization code.", ctx); return; }; let code = code.to_string(); let Some(state) = params.get("state") else { self.fail("OAuth callback missing state parameter.", ctx); return; }; if *state != expected_state { self.fail("OAuth callback state mismatch (possible CSRF).", ctx); return; } self.state = ChatGPTAuthState::ExchangingToken; ctx.emit(ChatGPTAuthModelEvent::StateChanged); let redirect_uri = chatgpt_redirect_uri(); let _ = ctx.spawn( async move { exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await }, |model, result, ctx| match result { Ok(()) => { model.state = ChatGPTAuthState::Connected; ctx.emit(ChatGPTAuthModelEvent::StateChanged); } Err(error) => { model.fail(&error, ctx); } }, ); } fn fail(&mut self, message: &str, ctx: &mut ModelContext) { self.state = ChatGPTAuthState::Failed(message.to_string()); self.pending_code_verifier = None; self.pending_state = None; ctx.emit(ChatGPTAuthModelEvent::StateChanged); } } impl Entity for ChatGPTAuthModel { type Event = ChatGPTAuthModelEvent; } impl SingletonEntity for ChatGPTAuthModel {} // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn chatgpt_redirect_uri() -> String { format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme()) } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ChatGPTAuthCredentials { pub(crate) access_token: String, pub(crate) account_id: Option, } /// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file. /// Returns `Ok(())` if credentials were found and successfully imported. pub(crate) fn import_codex_credentials() -> Result<(), String> { let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?; let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?; let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?; let tokens = doc.get("tokens").ok_or("No tokens object")?; let access_token = tokens .get("access_token") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .ok_or("No access_token")?; let expires_at = extract_expiration_timestamp(access_token); // If the token is expired, don't import stale credentials. if let Some(exp) = expires_at { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); if now >= exp - 60 { return Err("Codex access token is expired".to_string()); } } let refresh_token = tokens .get("refresh_token") .and_then(|v| v.as_str()) .map(ToOwned::to_owned); let id_token = tokens .get("id_token") .and_then(|v| v.as_str()) .map(ToOwned::to_owned); let account_id = tokens .get("account_id") .and_then(|v| v.as_str()) .map(ToOwned::to_owned) .or_else(|| extract_account_id(id_token.as_deref())) .or_else(|| extract_account_id(Some(access_token))); let record = AuthRecord { access_token: Some(access_token.to_owned()), refresh_token, id_token, expires_at, account_id, }; write_auth_file(&record) } pub(crate) fn load_or_import_auth_credentials() -> Result { load_auth_credentials().or_else(|load_error| { import_codex_credentials().map_err(|import_error| { format!( "Could not load ChatGPT credentials ({load_error}) or import Codex credentials ({import_error})." ) })?; load_auth_credentials() }) } fn load_auth_credentials() -> Result { let path = auth_file_path().ok_or("Cannot determine ChatGPT auth file path")?; let bytes = std::fs::read(&path) .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; let record: AuthRecord = serde_json::from_slice(&bytes) .map_err(|error| format!("Failed to parse {}: {error}", path.display()))?; let access_token = record .access_token .as_deref() .filter(|token| !token.trim().is_empty()) .ok_or("ChatGPT auth file does not contain an access token")?; let expires_at = record .expires_at .or_else(|| extract_expiration_timestamp(access_token)); if let Some(expires_at) = expires_at { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or(0); if now >= expires_at - 60 { return Err("ChatGPT access token is expired".to_string()); } } let account_id = record .account_id .clone() .filter(|account_id| !account_id.trim().is_empty()) .or_else(|| extract_account_id(record.id_token.as_deref())) .or_else(|| extract_account_id(Some(access_token))); Ok(ChatGPTAuthCredentials { access_token: access_token.to_string(), account_id, }) } fn codex_auth_file_path() -> Option { if let Some(codex_home) = std::env::var_os("CODEX_HOME") { return Some(std::path::PathBuf::from(codex_home).join("auth.json")); } std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json")) } fn generate_random_string(len: usize) -> String { const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; let mut rng = rand::thread_rng(); (0..len) .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char) .collect() } fn compute_code_challenge(verifier: &str) -> String { let hash = Sha256::digest(verifier.as_bytes()); URL_SAFE_NO_PAD.encode(hash) } /// Exchange the authorization code for tokens and write them to Rig's auth file. async fn exchange_code_for_tokens( code: &str, code_verifier: &str, redirect_uri: &str, ) -> Result<(), String> { let client = reqwest::Client::new(); let form = [ ("grant_type", "authorization_code"), ("client_id", CHATGPT_CLIENT_ID), ("code", code), ("redirect_uri", redirect_uri), ("code_verifier", code_verifier), ]; let body = url::form_urlencoded::Serializer::new(String::new()) .extend_pairs(form) .finish(); let response = client .post(CHATGPT_TOKEN_URL) .header( reqwest::header::CONTENT_TYPE, "application/x-www-form-urlencoded", ) .body(body) .send() .await .map_err(|e| format!("Token exchange request failed: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Token exchange failed: {status} {body}")); } let token_response: TokenResponse = response .json() .await .map_err(|e| format!("Failed to parse token response: {e}"))?; let access_token = token_response.access_token; let refresh_token = token_response.refresh_token; let id_token = token_response.id_token; let expires_at = extract_expiration_timestamp(&access_token); let account_id = extract_account_id(id_token.as_deref()).or_else(|| extract_account_id(Some(&access_token))); let auth_record = AuthRecord { access_token: Some(access_token), refresh_token, id_token, expires_at, account_id, }; write_auth_file(&auth_record)?; Ok(()) } fn write_auth_file(record: &AuthRecord) -> Result<(), String> { let path = auth_file_path().ok_or("Cannot determine auth file path")?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create auth directory: {e}"))?; } let json = serde_json::to_vec_pretty(record).map_err(|e| format!("Failed to serialize auth: {e}"))?; std::fs::write(&path, json).map_err(|e| format!("Failed to write auth file: {e}"))?; Ok(()) } fn auth_file_path() -> Option { #[cfg(target_os = "windows")] { std::env::var_os("APPDATA").map(|d| { std::path::PathBuf::from(d) .join("chatgpt") .join("auth.json") }) } #[cfg(not(target_os = "windows"))] { std::env::var_os("XDG_CONFIG_HOME") .map(std::path::PathBuf::from) .or_else(|| { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")) }) .map(|d| d.join("chatgpt").join("auth.json")) } } fn extract_expiration_timestamp(token: &str) -> Option { decode_jwt_claims(token) .get("exp") .and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|u| u as i64))) } fn extract_account_id(token: Option<&str>) -> Option { let claims = decode_jwt_claims(token?); claims .get("https://api.openai.com/auth") .and_then(|v| v.as_object()) .and_then(|map| map.get("chatgpt_account_id")) .and_then(|v| v.as_str()) .map(ToOwned::to_owned) } fn decode_jwt_claims(token: &str) -> serde_json::Value { let payload = token.split('.').nth(1).unwrap_or_default(); let decoded = URL_SAFE_NO_PAD.decode(payload.as_bytes()); decoded .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .unwrap_or(serde_json::Value::Null) } #[derive(serde::Deserialize)] struct TokenResponse { access_token: String, refresh_token: Option, id_token: Option, } #[derive(serde::Deserialize, serde::Serialize)] struct AuthRecord { access_token: Option, refresh_token: Option, id_token: Option, expires_at: Option, account_id: Option, }