//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building. use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; /// An OAuth access token plus its refresh token and absolute expiry (unix seconds). #[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 { } /// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes. #[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()], } } } /// Drives one OAuth flow: holds config, the current token (if any), and an HTTP client. pub struct OAuthManager { pub config: OAuthConfig, pub token: Option, client: reqwest::blocking::Client, } impl OAuthManager { /// Create a manager for the given provider config with no token yet acquired. pub fn new(config: OAuthConfig) -> Self { OAuthManager { config, token: None, client: reqwest::blocking::Client::new(), } } /// Exchange an authorization code for an access token via the provider's token endpoint. /// /// Flow: POST form-encoded grant to `token_url` → parse JSON body → /// compute absolute `expires_at` from `expires_in` → store on `self.token`. /// /// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field. 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(std::string::ToString::to_string), expires_at: now + expires_in, token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), }); Ok(()) } /// Build the provider's authorization URL with PKCE and state params attached. /// /// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously /// this silently fell back to , which produced a valid-looking /// auth URL pointing at the wrong server and leaked client credentials in /// query params. Returning an empty string signals failure to callers, who /// can prompt the user to fix the OAuth config instead of starting a flow /// against a wrong host. /// /// Return: the full authorization URL, or `""` if `auth_url` is empty/unparseable. pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String { let mut url = match url::Url::parse(&self.config.auth_url) { Ok(u) if !self.config.auth_url.is_empty() => u, _ => { tracing::warn!( "warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url", self.config.auth_url ); return String::new(); } }; 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() } }