457 lines
15 KiB
Rust
457 lines
15 KiB
Rust
//! 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<String>,
|
|
/// CSRF state token stored between authorize and callback.
|
|
pending_state: Option<String>,
|
|
}
|
|
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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>) {
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<ChatGPTAuthCredentials, String> {
|
|
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<ChatGPTAuthCredentials, String> {
|
|
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<std::path::PathBuf> {
|
|
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<std::path::PathBuf> {
|
|
#[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<i64> {
|
|
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<String> {
|
|
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::<serde_json::Value>(&bytes).ok())
|
|
.unwrap_or(serde_json::Value::Null)
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct TokenResponse {
|
|
access_token: String,
|
|
refresh_token: Option<String>,
|
|
id_token: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize)]
|
|
struct AuthRecord {
|
|
access_token: Option<String>,
|
|
refresh_token: Option<String>,
|
|
id_token: Option<String>,
|
|
expires_at: Option<i64>,
|
|
account_id: Option<String>,
|
|
}
|