//! OAuth2 token lifecycle management. //! //! Handles token refresh and provides valid access tokens for API calls. //! Supports multiple OAuth providers through the generic provider system. //! //! Tokens are stored securely in the OS keychain (macOS Keychain, //! Windows Credential Manager, or Linux Secret Service). use chrono::{Duration, Utc}; use goingson_core::{EmailAccount, EmailAuthType}; use super::credentials::CredentialStore; use super::providers::{ProviderConfig, ProviderRegistry}; use super::OAuthProvider; /// Manages OAuth2 tokens for email accounts. pub struct TokenManager { /// Registry of available OAuth providers. registry: ProviderRegistry, } impl TokenManager { /// Creates a new token manager with providers from config. pub fn new(config: ProviderConfig) -> Self { Self { registry: ProviderRegistry::new(config), } } /// Creates a token manager from environment variables. pub fn from_env() -> Self { Self::new(ProviderConfig::from_env()) } /// Returns the provider registry. pub fn registry(&self) -> &ProviderRegistry { &self.registry } /// Gets a provider by ID. pub fn provider(&self, id: &str) -> Option<&dyn OAuthProvider> { self.registry.get(id) } /// Returns the list of available provider IDs. pub fn available_providers(&self) -> Vec<&'static str> { self.registry.available_providers() } /// Gets the provider ID from an EmailAuthType. pub fn provider_id_for_auth_type(auth_type: &EmailAuthType) -> Option<&'static str> { match auth_type { EmailAuthType::Password => None, EmailAuthType::OAuth2Fastmail => Some("fastmail"), EmailAuthType::OAuth2Google => Some("google"), EmailAuthType::OAuth2Microsoft => Some("microsoft"), EmailAuthType::OAuth2Yahoo => Some("yahoo"), } } /// Checks if an account's token needs refresh. /// /// Returns true if: /// - The token has expired /// - The token will expire within the buffer period (5 minutes) /// - No expiration time is set (assumes expired) pub fn needs_refresh(account: &EmailAccount) -> bool { account.needs_token_refresh() } /// Refreshes the access token for an account if needed. /// /// Returns the new access token and optional new refresh token, /// along with the expiration time. /// /// # Returns /// * `Ok(Some((access_token, refresh_token, expires_at)))` - Token refreshed /// * `Ok(None)` - Token doesn't need refresh /// * `Err(String)` - Refresh failed pub async fn refresh_if_needed( &self, account: &EmailAccount, ) -> Result, chrono::DateTime)>, String> { if !Self::needs_refresh(account) { return Ok(None); } let provider_id = Self::provider_id_for_auth_type(&account.auth_type) .ok_or_else(|| "Account does not use OAuth2".to_string())?; let provider = self.registry.get(provider_id) .ok_or_else(|| format!("OAuth provider '{}' not configured", provider_id))?; // Get refresh token from keychain (fall back to database for migration) let refresh_token = CredentialStore::get_oauth(account.id.into()) .and_then(|c| c.refresh_token) .or_else(|| account.oauth2_refresh_token.clone()) .ok_or_else(|| "No refresh token available".to_string())?; let result = provider.refresh_token(&refresh_token).await?; // Calculate expiration time let expires_at = Utc::now() + Duration::seconds(result.expires_in.unwrap_or(3600) as i64); Ok(Some(( result.access_token, result.refresh_token, expires_at, ))) } /// Gets a valid access token for an account, refreshing if necessary. /// /// This is a convenience method that returns the current token if valid, /// or refreshes and returns the new token. /// /// Tokens are retrieved from the OS keychain, falling back to the database /// for migration from older versions. /// /// # Note /// The caller is responsible for persisting any new tokens returned. pub async fn get_valid_token( &self, account: &EmailAccount, ) -> Result { if !Self::needs_refresh(account) { // Current token is still valid - get from keychain (fall back to DB) let token = CredentialStore::get_oauth(account.id.into()) .map(|c| c.access_token) .or_else(|| account.oauth2_access_token.clone()) .ok_or_else(|| "No access token available".to_string())?; return Ok(TokenRefreshResult::Valid(token)); } // Need to refresh match self.refresh_if_needed(account).await? { Some((access_token, refresh_token, expires_at)) => { Ok(TokenRefreshResult::Refreshed { access_token, refresh_token, expires_at, }) } None => { // This shouldn't happen since we just checked needs_refresh let token = CredentialStore::get_oauth(account.id.into()) .map(|c| c.access_token) .or_else(|| account.oauth2_access_token.clone()) .ok_or_else(|| "No access token available".to_string())?; Ok(TokenRefreshResult::Valid(token)) } } } } /// Result of attempting to get a valid token. #[derive(Debug)] pub enum TokenRefreshResult { /// The existing token is still valid. Valid(String), /// The token was refreshed; caller should persist the new values. Refreshed { access_token: String, refresh_token: Option, expires_at: chrono::DateTime, }, } impl TokenRefreshResult { /// Returns the access token (either existing or newly refreshed). pub fn access_token(&self) -> &str { match self { TokenRefreshResult::Valid(token) => token, TokenRefreshResult::Refreshed { access_token, .. } => access_token, } } /// Returns true if the token was refreshed (caller should persist new values). pub fn was_refreshed(&self) -> bool { matches!(self, TokenRefreshResult::Refreshed { .. }) } }