//! 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 { .. }) } } #[cfg(test)] mod tests { use super::*; use goingson_core::{EmailAccountId, UserId}; /// Build an EmailAccount with the auth/token fields under test and inert /// defaults for everything else. `expires_at`/`access_token` drive the /// refresh-decision and token-source paths. fn account( auth_type: EmailAuthType, expires_at: Option>, access_token: Option<&str>, ) -> EmailAccount { EmailAccount { id: EmailAccountId::new(), user_id: UserId::from_uuid(uuid::Uuid::from_u128(1)), account_name: "Test".to_string(), email_address: "me@example.com".to_string(), imap_server: String::new(), imap_port: 0, smtp_server: String::new(), smtp_port: 0, username: String::new(), password: String::new(), use_tls: true, last_sync_at: None, created_at: Utc::now(), archive_folder_name: None, auth_type, oauth2_access_token: access_token.map(str::to_string), oauth2_refresh_token: None, oauth2_token_expires_at: expires_at, jmap_session_url: None, jmap_account_id: None, sync_interval_minutes: None, email_signature: None, notify_new_emails: false, } } #[test] fn needs_refresh_by_expiry() { // Comfortably in the future -> no refresh needed. let future = account( EmailAuthType::OAuth2Fastmail, Some(Utc::now() + Duration::hours(1)), Some("tok"), ); assert!(!TokenManager::needs_refresh(&future)); // Already expired -> needs refresh. let past = account( EmailAuthType::OAuth2Fastmail, Some(Utc::now() - Duration::hours(1)), Some("tok"), ); assert!(TokenManager::needs_refresh(&past)); // Inside the 5-minute buffer -> needs refresh (refresh before it lapses). let soon = account( EmailAuthType::OAuth2Fastmail, Some(Utc::now() + Duration::minutes(2)), Some("tok"), ); assert!(TokenManager::needs_refresh(&soon)); } #[test] fn needs_refresh_with_no_expiry_depends_on_auth_type() { // OAuth account with no recorded expiry is assumed to need a refresh. let oauth = account(EmailAuthType::OAuth2Google, None, None); assert!(TokenManager::needs_refresh(&oauth)); // A password account never needs an OAuth refresh. let password = account(EmailAuthType::Password, None, None); assert!(!TokenManager::needs_refresh(&password)); } #[test] fn provider_id_maps_every_auth_type() { assert_eq!( TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Fastmail), Some("fastmail") ); assert_eq!( TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Google), Some("google") ); assert_eq!( TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Microsoft), Some("microsoft") ); assert_eq!( TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Yahoo), Some("yahoo") ); assert_eq!( TokenManager::provider_id_for_auth_type(&EmailAuthType::Password), None ); } #[tokio::test] async fn refresh_if_needed_is_noop_when_token_still_valid() { // Future expiry -> no network call, returns Ok(None). let tm = TokenManager::from_env(); let acct = account( EmailAuthType::OAuth2Fastmail, Some(Utc::now() + Duration::hours(1)), Some("tok"), ); let result = tm.refresh_if_needed(&acct).await.expect("no error"); assert!(result.is_none(), "a still-valid token must not refresh"); } #[tokio::test] async fn refresh_if_needed_rejects_non_oauth_account() { // A password account whose (spurious) expiry has lapsed reaches the // provider lookup, which must reject it rather than hit the network. let tm = TokenManager::from_env(); let acct = account( EmailAuthType::Password, Some(Utc::now() - Duration::hours(1)), None, ); let err = tm.refresh_if_needed(&acct).await.expect_err("should reject"); assert!(err.contains("OAuth"), "got: {err}"); } #[tokio::test] async fn get_valid_token_returns_db_token_when_no_refresh_needed() { // No keychain entry exists for this fresh random id, so get_valid_token // falls back to the DB access-token column without any network call. let tm = TokenManager::from_env(); let acct = account( EmailAuthType::OAuth2Fastmail, Some(Utc::now() + Duration::hours(1)), Some("db-access-token"), ); match tm.get_valid_token(&acct).await.expect("token") { TokenRefreshResult::Valid(t) => assert_eq!(t, "db-access-token"), other => panic!("expected Valid, got {other:?}"), } } #[tokio::test] async fn get_valid_token_errors_when_no_token_anywhere() { // Valid (future) expiry so no refresh is attempted, keychain empty, and // no DB token -> a clean error rather than a network call. let tm = TokenManager::from_env(); let acct = account( EmailAuthType::OAuth2Fastmail, Some(Utc::now() + Duration::hours(1)), None, ); assert!(tm.get_valid_token(&acct).await.is_err()); } }