diff --git a/src/infrastructure/api.rs b/src/infrastructure/api.rs index b37fe4b..57885f4 100644 --- a/src/infrastructure/api.rs +++ b/src/infrastructure/api.rs @@ -1,221 +1,98 @@ -use std::io::{BufRead, BufReader, Write}; -use std::net::TcpListener; use std::path::PathBuf; -use std::sync::Arc; -use chrono::{DateTime, Utc}; use reqwest::Client; -use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; - -use url::Url; +use yup_oauth2::{InstalledFlowAuthenticator, InstalledFlowReturnMethod, ApplicationSecret}; use crate::domain::models::*; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthToken { - pub access_token: String, - pub refresh_token: Option, - pub expires_at: Option>, -} - #[derive(Debug)] -#[allow(dead_code)] pub enum ApiError { Network(String), Auth(String), Api(String), } +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::Network(s) => write!(f, "Network error: {}", s), + ApiError::Auth(s) => write!(f, "Auth error: {}", s), + ApiError::Api(s) => write!(f, "API error: {}", s), + } + } +} + +impl std::error::Error for ApiError {} + pub struct ApiClient { client: Client, - client_id: String, - client_secret: String, - token: Arc>>, + authenticator: InstalledFlowAuthenticator, token_path: PathBuf, } -const SCOPES: &str = "https://www.googleapis.com/auth/tasks"; +const SCOPES: &[&str] = &["https://www.googleapis.com/auth/tasks"]; impl ApiClient { - pub fn new(client_id: String, client_secret: String) -> Self { + pub async fn new(client_id: String, client_secret: String) -> Result { let token_path = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("task_app") .join("token.json"); - Self { - client: Client::new(), - client_id, - client_secret, - token: Arc::new(Mutex::new(None)), - token_path, - } - } - - pub fn token_file_exists(&self) -> bool { - self.token_path.exists() - && std::fs::read_to_string(&self.token_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .is_some() - } - - pub async fn load_token(&self) -> Option { - let content = std::fs::read_to_string(&self.token_path).ok()?; - serde_json::from_str(&content).ok() - } - - pub async fn save_token(&self, token: &OAuthToken) { - if let Some(parent) = self.token_path.parent() { + if let Some(parent) = token_path.parent() { std::fs::create_dir_all(parent).ok(); } - if let Ok(content) = serde_json::to_string_pretty(token) { - std::fs::write(&self.token_path, content).ok(); - } + + let secret = ApplicationSecret { + client_id, + client_secret, + auth_uri: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + redirect_uris: vec!["http://127.0.0.1:8080/".to_string()], + client_email: String::new(), + client_x509_cert_url: String::new(), + project_id: String::new(), + ..Default::default() + }; + + let authenticator = InstalledFlowAuthenticator::builder( + secret, + InstalledFlowReturnMethod::HTTPRedirect, + ) + .persist_tokens_to_disk(token_path.clone()) + .build() + .await + .map_err(|e| ApiError::Auth(format!("Failed to build authenticator: {}", e)))?; + + Ok(Self { + client: Client::new(), + authenticator, + token_path, + }) } - /// Starts the Loopback IP Redirect OAuth flow (RFC 8252). - /// Returns (auth_url, callback_port) so the app can tell the user - /// to open the URL or open it automatically. - pub async fn start_auth_flow(&self) -> Result<(String, u16), ApiError> { - if self.client_id.is_empty() { - return Err(ApiError::Auth( - "GOOGLE_CLIENT_ID not set".to_string(), - )); - } - - // Find a free port - let listener = TcpListener::bind("127.0.0.1:0") - .map_err(|e| ApiError::Network(format!("Failed to bind port: {}", e)))?; - let port = listener.local_addr().unwrap().port(); - let redirect_uri = format!("http://127.0.0.1:{}/", port); - - // Build Google auth URL - let auth_url = format!( - "https://accounts.google.com/o/oauth2/v2/auth?\ - response_type=code&\ - client_id={}&\ - redirect_uri={}&\ - scope={}&\ - access_type=offline&\ - prompt=consent", - urlencoding(&self.client_id), - urlencoding(&redirect_uri), - urlencoding(SCOPES), - ); - - // Spawn a thread that accepts one connection and parses the code - let client_id = self.client_id.clone(); - let client_secret = self.client_secret.clone(); - let token = self.token.clone(); - let token_path = self.token_path.clone(); - - std::thread::spawn(move || { - if let Err(e) = handle_oauth_callback( - listener, - &client_id, - &client_secret, - &token, - &token_path, - ) { - eprintln!("OAuth callback error: {}", e); - } - }); - - Ok((auth_url, port)) + pub fn has_token(&self) -> bool { + self.token_path.exists() } - /// Opens the browser or returns the URL for manual opening - pub fn open_browser(auth_url: &str) -> bool { - webbrowser::open(auth_url).is_ok() + pub async fn start_and_wait_for_auth(&self) -> Result<(), ApiError> { + self.authenticator.token(SCOPES).await.map_err(|e| { + ApiError::Auth(format!("Authorization failed: {}", e)) + })?; + Ok(()) } - /// Polls the in-memory token to see if auth completed - pub async fn token_is_ready(&self) -> bool { - self.token.lock().await.is_some() - } - - pub async fn refresh_access_token(&self, refresh_token: &str) -> Result<(), ApiError> { - let params = serde_json::json!({ - "client_id": self.client_id, - "client_secret": self.client_secret, - "refresh_token": refresh_token, - "grant_type": "refresh_token", - }); - - let resp = self - .client - .post("https://oauth2.googleapis.com/token") - .json(¶ms) - .send() + async fn get_token(&self) -> Result { + let token = self + .authenticator + .token(SCOPES) .await - .map_err(|e| ApiError::Network(format!("HTTP request failed: {}", e)))?; - - let status = resp.status(); - let data: serde_json::Value = resp - .json() - .await - .map_err(|e| ApiError::Api(format!("Invalid response (status {}): {}", status, e)))?; - - if !status.is_success() { - return Err(ApiError::Api(format!( - "Token refresh failed ({}): {:?}", - status, data - ))); - } - - if let Some(access_token) = data["access_token"].as_str() { - let expires_in = data["expires_in"].as_i64().unwrap_or(3600); - let token = OAuthToken { - access_token: access_token.to_string(), - refresh_token: Some(refresh_token.to_string()), - expires_at: Some(Utc::now() + chrono::Duration::seconds(expires_in)), - }; - - self.save_token(&token).await; - let mut t = self.token.lock().await; - *t = Some(token); - Ok(()) - } else { - Err(ApiError::Auth("Failed to refresh token".to_string())) - } - } - - pub async fn ensure_token(&self) -> Result { - let mut token = self.token.lock().await; - if let Some(ref t) = *token { - if let Some(expires_at) = t.expires_at { - if Utc::now() < expires_at { - return Ok(t.access_token.clone()); - } - } - if let Some(ref refresh) = t.refresh_token { - let refresh_token = refresh.clone(); - drop(token); - self.refresh_access_token(&refresh_token).await?; - let t2 = self.token.lock().await; - if let Some(ref t) = *t2 { - return Ok(t.access_token.clone()); - } - } - Err(ApiError::Auth( - "Token expired and no refresh token".to_string(), - )) - } else if let Some(saved) = self.load_token().await { - *token = Some(saved); - if let Some(ref t) = *token { - Ok(t.access_token.clone()) - } else { - Err(ApiError::Auth("No token available".to_string())) - } - } else { - Err(ApiError::Auth("Not authenticated".to_string())) - } + .map_err(|e| ApiError::Auth(format!("Token error: {}", e)))?; + Ok(token.as_str().to_string()) } pub async fn fetch_lists(&self) -> Result, ApiError> { - let token = self.ensure_token().await?; + let token = self.get_token().await?; let resp = self .client @@ -244,7 +121,7 @@ impl ApiClient { } pub async fn fetch_tasks(&self, list_id: &str) -> Result, ApiError> { - let token = self.ensure_token().await?; + let token = self.get_token().await?; let url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true", @@ -302,7 +179,7 @@ impl ApiClient { } pub async fn create_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> { - let token = self.ensure_token().await?; + let token = self.get_token().await?; let mut body = serde_json::json!({ "title": task.title, @@ -344,7 +221,7 @@ impl ApiClient { } pub async fn update_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> { - let token = self.ensure_token().await?; + let token = self.get_token().await?; let mut body = serde_json::json!({ "title": task.title, @@ -387,7 +264,7 @@ impl ApiClient { } pub async fn delete_task(&self, list_id: &str, task_id: &str) -> Result<(), ApiError> { - let token = self.ensure_token().await?; + let token = self.get_token().await?; let url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}", @@ -419,7 +296,7 @@ impl ApiClient { prev: Option<&str>, sibling: Option<&str>, ) -> Result<(), ApiError> { - let token = self.ensure_token().await?; + let token = self.get_token().await?; let mut url = format!( "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}/move", @@ -448,87 +325,3 @@ impl ApiClient { Ok(()) } } - -fn urlencoding(s: &str) -> String { - s.chars() - .map(|c| match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), - _ => format!("%{:02X}", c as u8), - }) - .collect() -} - -fn handle_oauth_callback( - listener: TcpListener, - client_id: &str, - client_secret: &str, - token_storage: &Arc>>, - token_path: &PathBuf, -) -> Result<(), Box> { - let (stream, _) = listener.accept()?; - let mut reader = BufReader::new(&stream); - let mut request_line = String::new(); - reader.read_line(&mut request_line)?; - - // Parse the GET request to extract the code - let code = request_line - .split_whitespace() - .nth(1) - .and_then(|path| { - let parsed = Url::parse(&format!("http://localhost{}", path)).ok()?; - parsed.query_pairs().find(|(k, _)| k == "code")?.1.to_string().into() - }); - - let reply = if let Some(ref _code) = code { - // Send success response to browser - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authorized!

You can close this tab and return to the terminal.

" - } else { - "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n

Authorization failed

No code received.

" - }; - - let mut response = stream.try_clone()?; - response.write_all(reply.as_bytes())?; - response.flush()?; - - if let Some(auth_code) = code { - // Exchange code for token - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async move { - let client = Client::new(); - let params = serde_json::json!({ - "client_id": client_id, - "client_secret": client_secret, - "code": auth_code, - "redirect_uri": format!("http://127.0.0.1:{}/", listener.local_addr().unwrap().port()), - "grant_type": "authorization_code", - }); - - if let Ok(resp) = client - .post("https://oauth2.googleapis.com/token") - .json(¶ms) - .send() - .await - { - if let Ok(data) = resp.json::().await { - if let Some(access_token) = data["access_token"].as_str() { - let expires_in = data["expires_in"].as_i64().unwrap_or(3600); - let oauth_token = OAuthToken { - access_token: access_token.to_string(), - refresh_token: data["refresh_token"].as_str().map(|s| s.to_string()), - expires_at: Some(Utc::now() + chrono::Duration::seconds(expires_in)), - }; - - if let Ok(content) = serde_json::to_string_pretty(&oauth_token) { - std::fs::write(token_path, content).ok(); - } - - let mut t = token_storage.lock().await; - *t = Some(oauth_token); - } - } - } - }); - } - - Ok(()) -}