Skip to main content

max / goingson

12.0 KB · 343 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
185 #[cfg(test)]
186 mod tests {
187 use super::*;
188 use goingson_core::{EmailAccountId, UserId};
189
190 /// Build an EmailAccount with the auth/token fields under test and inert
191 /// defaults for everything else. `expires_at`/`access_token` drive the
192 /// refresh-decision and token-source paths.
193 fn account(
194 auth_type: EmailAuthType,
195 expires_at: Option<chrono::DateTime<Utc>>,
196 access_token: Option<&str>,
197 ) -> EmailAccount {
198 EmailAccount {
199 id: EmailAccountId::new(),
200 user_id: UserId::from_uuid(uuid::Uuid::from_u128(1)),
201 account_name: "Test".to_string(),
202 email_address: "me@example.com".to_string(),
203 imap_server: String::new(),
204 imap_port: 0,
205 smtp_server: String::new(),
206 smtp_port: 0,
207 username: String::new(),
208 password: String::new(),
209 use_tls: true,
210 last_sync_at: None,
211 created_at: Utc::now(),
212 archive_folder_name: None,
213 auth_type,
214 oauth2_access_token: access_token.map(str::to_string),
215 oauth2_refresh_token: None,
216 oauth2_token_expires_at: expires_at,
217 jmap_session_url: None,
218 jmap_account_id: None,
219 sync_interval_minutes: None,
220 email_signature: None,
221 notify_new_emails: false,
222 }
223 }
224
225 #[test]
226 fn needs_refresh_by_expiry() {
227 // Comfortably in the future -> no refresh needed.
228 let future = account(
229 EmailAuthType::OAuth2Fastmail,
230 Some(Utc::now() + Duration::hours(1)),
231 Some("tok"),
232 );
233 assert!(!TokenManager::needs_refresh(&future));
234
235 // Already expired -> needs refresh.
236 let past = account(
237 EmailAuthType::OAuth2Fastmail,
238 Some(Utc::now() - Duration::hours(1)),
239 Some("tok"),
240 );
241 assert!(TokenManager::needs_refresh(&past));
242
243 // Inside the 5-minute buffer -> needs refresh (refresh before it lapses).
244 let soon = account(
245 EmailAuthType::OAuth2Fastmail,
246 Some(Utc::now() + Duration::minutes(2)),
247 Some("tok"),
248 );
249 assert!(TokenManager::needs_refresh(&soon));
250 }
251
252 #[test]
253 fn needs_refresh_with_no_expiry_depends_on_auth_type() {
254 // OAuth account with no recorded expiry is assumed to need a refresh.
255 let oauth = account(EmailAuthType::OAuth2Google, None, None);
256 assert!(TokenManager::needs_refresh(&oauth));
257
258 // A password account never needs an OAuth refresh.
259 let password = account(EmailAuthType::Password, None, None);
260 assert!(!TokenManager::needs_refresh(&password));
261 }
262
263 #[test]
264 fn provider_id_maps_every_auth_type() {
265 assert_eq!(
266 TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Fastmail),
267 Some("fastmail")
268 );
269 assert_eq!(
270 TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Google),
271 Some("google")
272 );
273 assert_eq!(
274 TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Microsoft),
275 Some("microsoft")
276 );
277 assert_eq!(
278 TokenManager::provider_id_for_auth_type(&EmailAuthType::OAuth2Yahoo),
279 Some("yahoo")
280 );
281 assert_eq!(
282 TokenManager::provider_id_for_auth_type(&EmailAuthType::Password),
283 None
284 );
285 }
286
287 #[tokio::test]
288 async fn refresh_if_needed_is_noop_when_token_still_valid() {
289 // Future expiry -> no network call, returns Ok(None).
290 let tm = TokenManager::from_env();
291 let acct = account(
292 EmailAuthType::OAuth2Fastmail,
293 Some(Utc::now() + Duration::hours(1)),
294 Some("tok"),
295 );
296 let result = tm.refresh_if_needed(&acct).await.expect("no error");
297 assert!(result.is_none(), "a still-valid token must not refresh");
298 }
299
300 #[tokio::test]
301 async fn refresh_if_needed_rejects_non_oauth_account() {
302 // A password account whose (spurious) expiry has lapsed reaches the
303 // provider lookup, which must reject it rather than hit the network.
304 let tm = TokenManager::from_env();
305 let acct = account(
306 EmailAuthType::Password,
307 Some(Utc::now() - Duration::hours(1)),
308 None,
309 );
310 let err = tm.refresh_if_needed(&acct).await.expect_err("should reject");
311 assert!(err.contains("OAuth"), "got: {err}");
312 }
313
314 #[tokio::test]
315 async fn get_valid_token_returns_db_token_when_no_refresh_needed() {
316 // No keychain entry exists for this fresh random id, so get_valid_token
317 // falls back to the DB access-token column without any network call.
318 let tm = TokenManager::from_env();
319 let acct = account(
320 EmailAuthType::OAuth2Fastmail,
321 Some(Utc::now() + Duration::hours(1)),
322 Some("db-access-token"),
323 );
324 match tm.get_valid_token(&acct).await.expect("token") {
325 TokenRefreshResult::Valid(t) => assert_eq!(t, "db-access-token"),
326 other => panic!("expected Valid, got {other:?}"),
327 }
328 }
329
330 #[tokio::test]
331 async fn get_valid_token_errors_when_no_token_anywhere() {
332 // Valid (future) expiry so no refresh is attempted, keychain empty, and
333 // no DB token -> a clean error rather than a network call.
334 let tm = TokenManager::from_env();
335 let acct = account(
336 EmailAuthType::OAuth2Fastmail,
337 Some(Utc::now() + Duration::hours(1)),
338 None,
339 );
340 assert!(tm.get_valid_token(&acct).await.is_err());
341 }
342 }
343