use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthToken { pub access_token: String, pub refresh_token: Option, pub expires_at: u64, pub token_type: String, } impl OAuthToken { pub fn is_expired(&self) -> bool { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); now >= self.expires_at } pub fn remaining_secs(&self) -> i64 { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); self.expires_at as i64 - now as i64 } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthConfig { pub auth_url: String, pub token_url: String, pub client_id: String, pub client_secret: Option, pub scopes: Vec, } impl Default for OAuthConfig { fn default() -> Self { OAuthConfig { auth_url: String::new(), token_url: String::new(), client_id: String::new(), client_secret: None, scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], } } } pub struct OAuthManager { pub config: OAuthConfig, pub token: Option, client: reqwest::blocking::Client, } impl OAuthManager { pub fn new(config: OAuthConfig) -> Self { OAuthManager { config, token: None, client: reqwest::blocking::Client::new(), } } pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> { let mut params = std::collections::HashMap::new(); params.insert("grant_type", "authorization_code"); params.insert("code", code); params.insert("redirect_uri", redirect_uri); params.insert("client_id", &self.config.client_id); params.insert("code_verifier", code_verifier); let resp = self.client .post(&self.config.token_url) .form(¶ms) .send() .map_err(|e| format!("token request failed: {}", e))?; let status = resp.status(); let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?; if !status.is_success() { return Err(format!("token endpoint returned {}: {}", status, body)); } let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string(); let expires_in = body["expires_in"].as_u64().unwrap_or(3600); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); self.token = Some(OAuthToken { access_token, refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()), expires_at: now + expires_in, token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), }); Ok(()) } pub fn refresh_token(&mut self) -> Result<(), String> { let refresh_token = self.token.as_ref() .and_then(|t| t.refresh_token.clone()) .ok_or("no refresh token available")?; let mut params = std::collections::HashMap::new(); params.insert("grant_type", "refresh_token"); params.insert("refresh_token", &refresh_token); params.insert("client_id", &self.config.client_id); let resp = self.client .post(&self.config.token_url) .form(¶ms) .send() .map_err(|e| format!("refresh failed: {}", e))?; let status = resp.status(); let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?; if !status.is_success() { return Err(format!("refresh endpoint returned {}: {}", status, body)); } let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string(); let expires_in = body["expires_in"].as_u64().unwrap_or(3600); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); self.token = Some(OAuthToken { access_token, refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()).or(self.token.as_ref().and_then(|t| t.refresh_token.clone())), expires_at: now + expires_in, token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), }); Ok(()) } pub fn ensure_token(&mut self) -> Result<(), String> { if let Some(ref token) = self.token { if token.remaining_secs() < 60 { return self.refresh_token(); } } Ok(()) } pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String { let mut url = url::Url::parse(&self.config.auth_url).unwrap_or_else(|_| url::Url::parse("https://example.com").unwrap()); url.query_pairs_mut() .append_pair("response_type", "code") .append_pair("client_id", &self.config.client_id) .append_pair("redirect_uri", redirect_uri) .append_pair("scope", &self.config.scopes.join(" ")) .append_pair("state", state) .append_pair("code_challenge_method", "S256") .append_pair("code_challenge", code_challenge); url.to_string() } }