Skip to main content

max / goingson

6.4 KB · 184 lines History Blame Raw
1 //! OAuth2 token lifecycle management.
2 //!
3 //! Handles token refresh and provides valid access tokens for API calls.
4 //! Supports multiple OAuth providers through the generic provider system.
5 //!
6 //! Tokens are stored securely in the OS keychain (macOS Keychain,
7 //! Windows Credential Manager, or Linux Secret Service).
8
9 use chrono::{Duration, Utc};
10 use goingson_core::{EmailAccount, EmailAuthType};
11
12 use super::credentials::CredentialStore;
13 use super::providers::{ProviderConfig, ProviderRegistry};
14 use super::OAuthProvider;
15
16 /// Manages OAuth2 tokens for email accounts.
17 pub struct TokenManager {
18 /// Registry of available OAuth providers.
19 registry: ProviderRegistry,
20 }
21
22 impl TokenManager {
23 /// Creates a new token manager with providers from config.
24 pub fn new(config: ProviderConfig) -> Self {
25 Self {
26 registry: ProviderRegistry::new(config),
27 }
28 }
29
30 /// Creates a token manager from environment variables.
31 pub fn from_env() -> Self {
32 Self::new(ProviderConfig::from_env())
33 }
34
35 /// Returns the provider registry.
36 pub fn registry(&self) -> &ProviderRegistry {
37 &self.registry
38 }
39
40 /// Gets a provider by ID.
41 pub fn provider(&self, id: &str) -> Option<&dyn OAuthProvider> {
42 self.registry.get(id)
43 }
44
45 /// Returns the list of available provider IDs.
46 pub fn available_providers(&self) -> Vec<&'static str> {
47 self.registry.available_providers()
48 }
49
50 /// Gets the provider ID from an EmailAuthType.
51 pub fn provider_id_for_auth_type(auth_type: &EmailAuthType) -> Option<&'static str> {
52 match auth_type {
53 EmailAuthType::Password => None,
54 EmailAuthType::OAuth2Fastmail => Some("fastmail"),
55 EmailAuthType::OAuth2Google => Some("google"),
56 EmailAuthType::OAuth2Microsoft => Some("microsoft"),
57 EmailAuthType::OAuth2Yahoo => Some("yahoo"),
58 }
59 }
60
61 /// Checks if an account's token needs refresh.
62 ///
63 /// Returns true if:
64 /// - The token has expired
65 /// - The token will expire within the buffer period (5 minutes)
66 /// - No expiration time is set (assumes expired)
67 pub fn needs_refresh(account: &EmailAccount) -> bool {
68 account.needs_token_refresh()
69 }
70
71 /// Refreshes the access token for an account if needed.
72 ///
73 /// Returns the new access token and optional new refresh token,
74 /// along with the expiration time.
75 ///
76 /// # Returns
77 /// * `Ok(Some((access_token, refresh_token, expires_at)))` - Token refreshed
78 /// * `Ok(None)` - Token doesn't need refresh
79 /// * `Err(String)` - Refresh failed
80 pub async fn refresh_if_needed(
81 &self,
82 account: &EmailAccount,
83 ) -> Result<Option<(String, Option<String>, chrono::DateTime<Utc>)>, String> {
84 if !Self::needs_refresh(account) {
85 return Ok(None);
86 }
87
88 let provider_id = Self::provider_id_for_auth_type(&account.auth_type)
89 .ok_or_else(|| "Account does not use OAuth2".to_string())?;
90
91 let provider = self.registry.get(provider_id)
92 .ok_or_else(|| format!("OAuth provider '{}' not configured", provider_id))?;
93
94 // Get refresh token from keychain (fall back to database for migration)
95 let refresh_token = CredentialStore::get_oauth(account.id.into())
96 .and_then(|c| c.refresh_token)
97 .or_else(|| account.oauth2_refresh_token.clone())
98 .ok_or_else(|| "No refresh token available".to_string())?;
99
100 let result = provider.refresh_token(&refresh_token).await?;
101
102 // Calculate expiration time
103 let expires_at = Utc::now()
104 + Duration::seconds(result.expires_in.unwrap_or(3600) as i64);
105
106 Ok(Some((
107 result.access_token,
108 result.refresh_token,
109 expires_at,
110 )))
111 }
112
113 /// Gets a valid access token for an account, refreshing if necessary.
114 ///
115 /// This is a convenience method that returns the current token if valid,
116 /// or refreshes and returns the new token.
117 ///
118 /// Tokens are retrieved from the OS keychain, falling back to the database
119 /// for migration from older versions.
120 ///
121 /// # Note
122 /// The caller is responsible for persisting any new tokens returned.
123 pub async fn get_valid_token(
124 &self,
125 account: &EmailAccount,
126 ) -> Result<TokenRefreshResult, String> {
127 if !Self::needs_refresh(account) {
128 // Current token is still valid - get from keychain (fall back to DB)
129 let token = CredentialStore::get_oauth(account.id.into())
130 .map(|c| c.access_token)
131 .or_else(|| account.oauth2_access_token.clone())
132 .ok_or_else(|| "No access token available".to_string())?;
133 return Ok(TokenRefreshResult::Valid(token));
134 }
135
136 // Need to refresh
137 match self.refresh_if_needed(account).await? {
138 Some((access_token, refresh_token, expires_at)) => {
139 Ok(TokenRefreshResult::Refreshed {
140 access_token,
141 refresh_token,
142 expires_at,
143 })
144 }
145 None => {
146 // This shouldn't happen since we just checked needs_refresh
147 let token = CredentialStore::get_oauth(account.id.into())
148 .map(|c| c.access_token)
149 .or_else(|| account.oauth2_access_token.clone())
150 .ok_or_else(|| "No access token available".to_string())?;
151 Ok(TokenRefreshResult::Valid(token))
152 }
153 }
154 }
155 }
156
157 /// Result of attempting to get a valid token.
158 #[derive(Debug)]
159 pub enum TokenRefreshResult {
160 /// The existing token is still valid.
161 Valid(String),
162 /// The token was refreshed; caller should persist the new values.
163 Refreshed {
164 access_token: String,
165 refresh_token: Option<String>,
166 expires_at: chrono::DateTime<Utc>,
167 },
168 }
169
170 impl TokenRefreshResult {
171 /// Returns the access token (either existing or newly refreshed).
172 pub fn access_token(&self) -> &str {
173 match self {
174 TokenRefreshResult::Valid(token) => token,
175 TokenRefreshResult::Refreshed { access_token, .. } => access_token,
176 }
177 }
178
179 /// Returns true if the token was refreshed (caller should persist new values).
180 pub fn was_refreshed(&self) -> bool {
181 matches!(self, TokenRefreshResult::Refreshed { .. })
182 }
183 }
184