max / goingson
11 files changed,
+297 insertions,
-32 deletions
| @@ -437,11 +437,30 @@ pub async fn disconnect_oauth( | |||
| 437 | 437 | state: State<'_, Arc<AppState>>, | |
| 438 | 438 | account_id: EmailAccountId, | |
| 439 | 439 | ) -> Result<bool, ApiError> { | |
| 440 | - | // Delete credentials from keychain first | |
| 440 | + | // Best-effort: revoke the refresh token at the provider before we drop our | |
| 441 | + | // copy, so a leaked token can't outlive the disconnect. Providers without a | |
| 442 | + | // revocation endpoint (Microsoft, Fastmail) no-op. Failures here must not | |
| 443 | + | // block the local disconnect, so they are logged and swallowed. | |
| 444 | + | if let Ok(Some(account)) = state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await { | |
| 445 | + | let refresh_token = CredentialStore::get_oauth(account_id.into()) | |
| 446 | + | .and_then(|c| c.refresh_token) | |
| 447 | + | .or_else(|| account.oauth2_refresh_token.clone()); | |
| 448 | + | if let Some(token) = refresh_token { | |
| 449 | + | let token_manager = TokenManager::from_env(); | |
| 450 | + | if let Some(provider_id) = | |
| 451 | + | TokenManager::provider_id_for_auth_type(&account.auth_type) | |
| 452 | + | && let Some(provider) = token_manager.provider(provider_id) | |
| 453 | + | && let Err(e) = provider.revoke_token(&token).await | |
| 454 | + | { | |
| 455 | + | tracing::warn!("OAuth token revocation failed on disconnect: {e}"); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // Delete credentials from keychain | |
| 441 | 461 | let _ = CredentialStore::delete_oauth(account_id.into()); | |
| 442 | 462 | ||
| 443 | 463 | // Delete the account from database | |
| 444 | - | // In the future, we could also revoke the token with the provider | |
| 445 | 464 | Ok(state.email_accounts.delete(account_id, DESKTOP_USER_ID).await?) | |
| 446 | 465 | } | |
| 447 | 466 |
| @@ -803,8 +803,6 @@ impl async_imap::Authenticator for XOAuth2Authenticator { | |||
| 803 | 803 | ||
| 804 | 804 | #[cfg(test)] | |
| 805 | 805 | mod tests { | |
| 806 | - | use super::ImapClient; | |
| 807 | - | ||
| 808 | 806 | // strip_tags_simple, extract_href, decode_html_entities, and strip_html | |
| 809 | 807 | // tests removed — these functions were replaced by pter::convert(). | |
| 810 | 808 |
| @@ -69,11 +69,30 @@ fn format_flowed(body: &str) -> String { | |||
| 69 | 69 | break; | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | - | // Find the last space within budget for a clean word break | |
| 73 | - | let break_at = remaining[..budget] | |
| 72 | + | // `budget` is a byte count, but `remaining` may contain multibyte | |
| 73 | + | // characters, so slicing at `budget` can land mid-character and | |
| 74 | + | // panic. Back off to the largest char boundary at or below budget. | |
| 75 | + | let mut boundary = budget; | |
| 76 | + | while boundary > 0 && !remaining.is_char_boundary(boundary) { | |
| 77 | + | boundary -= 1; | |
| 78 | + | } | |
| 79 | + | // A single character wider than the budget would leave boundary at | |
| 80 | + | // 0 and stall the loop; take that whole first character instead so | |
| 81 | + | // we always make forward progress. | |
| 82 | + | if boundary == 0 { | |
| 83 | + | boundary = remaining | |
| 84 | + | .char_indices() | |
| 85 | + | .nth(1) | |
| 86 | + | .map_or(remaining.len(), |(i, _)| i); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Find the last space within the boundary for a clean word break. | |
| 90 | + | // `rfind(' ')` returns an ASCII byte index, so `+ 1` stays on a | |
| 91 | + | // char boundary; the fallback `boundary` is a boundary by construction. | |
| 92 | + | let break_at = remaining[..boundary] | |
| 74 | 93 | .rfind(' ') | |
| 75 | 94 | .map(|i| i + 1) // include the space in this line | |
| 76 | - | .unwrap_or(budget); // no space found, hard break at budget | |
| 95 | + | .unwrap_or(boundary); // no space found, hard break at boundary | |
| 77 | 96 | ||
| 78 | 97 | let (chunk, rest) = remaining.split_at(break_at); | |
| 79 | 98 | out.push_str(prefix); | |
| @@ -303,3 +322,64 @@ impl SmtpClient { | |||
| 303 | 322 | Ok(()) | |
| 304 | 323 | } | |
| 305 | 324 | } | |
| 325 | + | ||
| 326 | + | #[cfg(test)] | |
| 327 | + | mod tests { | |
| 328 | + | use super::format_flowed; | |
| 329 | + | ||
| 330 | + | #[test] | |
| 331 | + | fn short_ascii_line_unchanged() { | |
| 332 | + | assert_eq!(format_flowed("hello world"), "hello world"); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | #[test] | |
| 336 | + | fn signature_separator_preserved() { | |
| 337 | + | assert_eq!(format_flowed("body\n-- \nsig"), "body\n-- \nsig"); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn long_ascii_line_wraps_with_soft_break() { | |
| 342 | + | let line = "word ".repeat(20); // 100 chars, spaces every 5 | |
| 343 | + | let out = format_flowed(line.trim_end()); | |
| 344 | + | // Content per wrapped segment stays within 72; format=flowed may append | |
| 345 | + | // one trailing soft-break space, so the emitted line is content + 1. | |
| 346 | + | for seg in out.split('\n') { | |
| 347 | + | assert!(seg.len() <= 73, "segment too long: {:?}", seg); | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn long_multibyte_line_does_not_panic() { | |
| 353 | + | // A line of CJK characters (3 bytes each) well over 72 bytes with no | |
| 354 | + | // ASCII space: the old byte-index slice panicked on the char boundary. | |
| 355 | + | let line = "の".repeat(60); // 180 bytes | |
| 356 | + | let out = format_flowed(&line); | |
| 357 | + | // Output must reconstruct to the same characters (sans soft-break spaces | |
| 358 | + | // and newlines) and never split a character. | |
| 359 | + | let stripped: String = out.chars().filter(|c| *c != '\n' && *c != ' ').collect(); | |
| 360 | + | assert_eq!(stripped, line); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn long_emoji_line_does_not_panic() { | |
| 365 | + | // Emoji are 4 bytes; a long run with no space is the worst case. | |
| 366 | + | let line = "😀".repeat(40); // 160 bytes | |
| 367 | + | let out = format_flowed(&line); | |
| 368 | + | let stripped: String = out.chars().filter(|c| *c != '\n' && *c != ' ').collect(); | |
| 369 | + | assert_eq!(stripped, line); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn mixed_multibyte_with_spaces_word_breaks() { | |
| 374 | + | let line = format!("{} {} {}", "あ".repeat(30), "い".repeat(30), "う".repeat(30)); | |
| 375 | + | let out = format_flowed(&line); | |
| 376 | + | // Content per segment <= 72 bytes; allow up to 2 extra bytes for a | |
| 377 | + | // trailing soft-break space plus the word-break space included in chunk. | |
| 378 | + | for seg in out.split('\n') { | |
| 379 | + | assert!(seg.len() <= 74, "segment too long: {} bytes", seg.len()); | |
| 380 | + | } | |
| 381 | + | // No character was split: reconstruction yields the original text. | |
| 382 | + | let stripped: String = out.replace('\n', ""); | |
| 383 | + | assert!(stripped.contains(&"あ".repeat(24))); | |
| 384 | + | } | |
| 385 | + | } |
| @@ -59,11 +59,14 @@ fn parse_vevent(event: &IcalEvent) -> Option<ParsedEvent> { | |||
| 59 | 59 | let start_time = parse_datetime_property(&event.properties, "DTSTART")?; | |
| 60 | 60 | ||
| 61 | 61 | // Parse end time: DTEND takes precedence, then compute from DURATION | |
| 62 | + | // `start_time + dur` panics in chrono on overflow, so add it checked: a | |
| 63 | + | // duration that would push the end time out of range yields no end time | |
| 64 | + | // rather than crashing the whole import. | |
| 62 | 65 | let end_time = parse_datetime_property(&event.properties, "DTEND") | |
| 63 | 66 | .or_else(|| { | |
| 64 | 67 | get_property_value(&event.properties, "DURATION") | |
| 65 | 68 | .and_then(|d| parse_duration(&d)) | |
| 66 | - | .map(|dur| start_time + dur) | |
| 69 | + | .and_then(|dur| start_time.checked_add_signed(dur)) | |
| 67 | 70 | }); | |
| 68 | 71 | ||
| 69 | 72 | // Parse recurrence | |
| @@ -201,39 +204,40 @@ fn parse_duration(s: &str) -> Option<chrono::Duration> { | |||
| 201 | 204 | } | |
| 202 | 205 | let s = &s[1..]; | |
| 203 | 206 | ||
| 207 | + | // A hostile or malformed .ics can specify enormous component values | |
| 208 | + | // (e.g. "P9999999999999999999D"). Every step uses checked arithmetic so an | |
| 209 | + | // overflow yields None rather than panicking in debug / wrapping in release; | |
| 210 | + | // the event then simply falls back to no computed end time. | |
| 204 | 211 | let mut total_seconds: i64 = 0; | |
| 205 | 212 | let mut in_time = false; | |
| 206 | 213 | let mut num_buf = String::new(); | |
| 207 | 214 | ||
| 215 | + | let mut add = |buf: &mut String, unit_secs: i64| -> Option<()> { | |
| 216 | + | // An unparseable / overflowing number contributes 0, matching the | |
| 217 | + | // previous lenient behaviour for non-overflow garbage. | |
| 218 | + | let n = buf.parse::<i64>().unwrap_or(0); | |
| 219 | + | buf.clear(); | |
| 220 | + | total_seconds = total_seconds.checked_add(n.checked_mul(unit_secs)?)?; | |
| 221 | + | Some(()) | |
| 222 | + | }; | |
| 223 | + | ||
| 208 | 224 | for ch in s.chars() { | |
| 209 | 225 | match ch { | |
| 210 | 226 | 'T' => in_time = true, | |
| 211 | 227 | '0'..='9' => num_buf.push(ch), | |
| 212 | - | 'D' if !in_time => { | |
| 213 | - | total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 86400; | |
| 214 | - | num_buf.clear(); | |
| 215 | - | } | |
| 216 | - | 'W' if !in_time => { | |
| 217 | - | total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 604800; | |
| 218 | - | num_buf.clear(); | |
| 219 | - | } | |
| 220 | - | 'H' if in_time => { | |
| 221 | - | total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 3600; | |
| 222 | - | num_buf.clear(); | |
| 223 | - | } | |
| 224 | - | 'M' if in_time => { | |
| 225 | - | total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 60; | |
| 226 | - | num_buf.clear(); | |
| 227 | - | } | |
| 228 | - | 'S' if in_time => { | |
| 229 | - | total_seconds += num_buf.parse::<i64>().unwrap_or(0); | |
| 230 | - | num_buf.clear(); | |
| 231 | - | } | |
| 228 | + | 'D' if !in_time => add(&mut num_buf, 86400)?, | |
| 229 | + | 'W' if !in_time => add(&mut num_buf, 604800)?, | |
| 230 | + | 'H' if in_time => add(&mut num_buf, 3600)?, | |
| 231 | + | 'M' if in_time => add(&mut num_buf, 60)?, | |
| 232 | + | 'S' if in_time => add(&mut num_buf, 1)?, | |
| 232 | 233 | _ => {} | |
| 233 | 234 | } | |
| 234 | 235 | } | |
| 235 | 236 | ||
| 236 | - | Some(chrono::Duration::seconds(total_seconds)) | |
| 237 | + | // `Duration::seconds` panics on values outside chrono's millisecond range; | |
| 238 | + | // `try_seconds` returns None instead, so an absurd total degrades to "no | |
| 239 | + | // duration" rather than crashing. | |
| 240 | + | chrono::Duration::try_seconds(total_seconds) | |
| 237 | 241 | } | |
| 238 | 242 | ||
| 239 | 243 | /// Parse an RRULE into a GO Recurrence. Only simple rules are mapped; | |
| @@ -289,6 +293,7 @@ fn unescape_ical(s: &str) -> String { | |||
| 289 | 293 | #[cfg(test)] | |
| 290 | 294 | mod tests { | |
| 291 | 295 | use super::*; | |
| 296 | + | use chrono::Timelike; | |
| 292 | 297 | ||
| 293 | 298 | #[test] | |
| 294 | 299 | fn test_parse_simple_event() { | |
| @@ -449,11 +454,32 @@ END:VCALENDAR\r\n"; | |||
| 449 | 454 | } | |
| 450 | 455 | ||
| 451 | 456 | #[test] | |
| 457 | + | fn test_parse_duration_overflow_is_none_not_panic() { | |
| 458 | + | // Values that parse as i64 but overflow when scaled to seconds, or that | |
| 459 | + | // exceed chrono's Duration range, must yield None rather than panicking. | |
| 460 | + | assert_eq!(parse_duration("P9223372036854775807D"), None); // i64::MAX days | |
| 461 | + | assert_eq!(parse_duration("PT9223372036854775807H"), None); // i64::MAX hours | |
| 462 | + | assert_eq!(parse_duration("P106751991167301D"), None); // overflows *86400 | |
| 463 | + | // Unparseable (too many digits for i64) degrades to zero, never panics. | |
| 464 | + | assert_eq!(parse_duration("P9999999999999999999D").map(|d| d.num_seconds()), Some(0)); | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | #[test] | |
| 468 | + | fn test_event_with_overflowing_duration_does_not_panic() { | |
| 469 | + | // A duration that fits chrono::Duration but pushes the end time past | |
| 470 | + | // chrono's date range exercises the checked_add_signed guard at the | |
| 471 | + | // caller: end_time stays unset rather than panicking the import. | |
| 472 | + | let ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Overflow\r\n\ | |
| 473 | + | DTSTART:20260101T120000Z\r\nDURATION:P100000000000D\r\n\ | |
| 474 | + | END:VEVENT\r\nEND:VCALENDAR\r\n"; | |
| 475 | + | let events = parse_ics(ics).unwrap(); | |
| 476 | + | assert_eq!(events.len(), 1); | |
| 477 | + | assert!(events[0].end_time.is_none()); | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | #[test] | |
| 452 | 481 | fn test_unescape_ical() { | |
| 453 | 482 | assert_eq!(unescape_ical("Hello\\nWorld"), "Hello\nWorld"); | |
| 454 | 483 | assert_eq!(unescape_ical("A\\,B\\;C"), "A,B;C"); | |
| 455 | 484 | } | |
| 456 | 485 | } | |
| 457 | - | ||
| 458 | - | #[cfg(test)] | |
| 459 | - | use chrono::Timelike; |
| @@ -99,6 +99,9 @@ impl JmapClient { | |||
| 99 | 99 | /// Executes a JMAP request and returns the response. | |
| 100 | 100 | pub async fn execute(&mut self, request: JmapRequest) -> Result<JmapResponse, String> { | |
| 101 | 101 | let api_url = self.api_url().await?; | |
| 102 | + | // `apiUrl` is server-supplied; never POST the bearer token to a | |
| 103 | + | // cleartext endpoint. | |
| 104 | + | super::session::require_https(&api_url)?; | |
| 102 | 105 | ||
| 103 | 106 | let response = self | |
| 104 | 107 | .client |
| @@ -57,11 +57,26 @@ pub struct SessionAccount { | |||
| 57 | 57 | pub account_capabilities: HashMap<String, serde_json::Value>, | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | /// Rejects any URL that would carry the bearer token over cleartext. | |
| 61 | + | /// | |
| 62 | + | /// The session URL comes from app config and the `apiUrl` comes from the | |
| 63 | + | /// server's own discovery response; enforcing `https://` here stops a | |
| 64 | + | /// malicious or compromised JMAP server from downgrading the bearer token to | |
| 65 | + | /// plaintext by returning an `http://` endpoint. | |
| 66 | + | pub fn require_https(url: &str) -> Result<(), String> { | |
| 67 | + | if url.trim_start().to_ascii_lowercase().starts_with("https://") { | |
| 68 | + | Ok(()) | |
| 69 | + | } else { | |
| 70 | + | Err(format!("Refusing to send JMAP credentials to a non-HTTPS URL: {url}")) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 60 | 74 | /// Discovers the JMAP session for an account. | |
| 61 | 75 | pub async fn discover_session( | |
| 62 | 76 | session_url: &str, | |
| 63 | 77 | access_token: &str, | |
| 64 | 78 | ) -> Result<JmapSession, String> { | |
| 79 | + | require_https(session_url)?; | |
| 65 | 80 | let client = reqwest::Client::builder() | |
| 66 | 81 | .timeout(std::time::Duration::from_secs(30)) | |
| 67 | 82 | .connect_timeout(std::time::Duration::from_secs(10)) |
| @@ -54,6 +54,9 @@ pub struct OAuthProviderConfig { | |||
| 54 | 54 | pub auth_url: String, | |
| 55 | 55 | /// Token exchange endpoint URL. | |
| 56 | 56 | pub token_url: String, | |
| 57 | + | /// RFC 7009 token revocation endpoint (if the provider offers one). | |
| 58 | + | /// Used to invalidate the refresh token when an account is disconnected. | |
| 59 | + | pub revoke_url: Option<String>, | |
| 57 | 60 | /// Scopes required for email access. | |
| 58 | 61 | pub scopes: Vec<String>, | |
| 59 | 62 | /// Whether this provider uses JMAP (vs IMAP with XOAUTH2). | |
| @@ -280,6 +283,60 @@ pub trait OAuthProvider: Send + Sync + 'static { | |||
| 280 | 283 | .map_err(|e| format!("Failed to parse token response: {}", e)) | |
| 281 | 284 | } | |
| 282 | 285 | ||
| 286 | + | /// Revokes a refresh (or access) token at the provider (RFC 7009). | |
| 287 | + | /// | |
| 288 | + | /// Best-effort: a no-op success when the provider has no revocation | |
| 289 | + | /// endpoint configured (e.g. Microsoft, which only supports session | |
| 290 | + | /// logout). Used on account disconnect so a leaked refresh token can't be | |
| 291 | + | /// used after the user removes the account locally. | |
| 292 | + | async fn revoke_token(&self, token: &str) -> Result<(), String> { | |
| 293 | + | let Some(revoke_url) = self.config().revoke_url.as_ref() else { | |
| 294 | + | return Ok(()); | |
| 295 | + | }; | |
| 296 | + | ||
| 297 | + | let client = reqwest::Client::builder() | |
| 298 | + | .timeout(std::time::Duration::from_secs(15)) | |
| 299 | + | .connect_timeout(std::time::Duration::from_secs(10)) | |
| 300 | + | .build() | |
| 301 | + | .map_err(|e| format!("Failed to build HTTP client: {}", e))?; | |
| 302 | + | ||
| 303 | + | let mut form_params: Vec<(&str, &str)> = vec![("token", token)]; | |
| 304 | + | let mut request = client.post(revoke_url); | |
| 305 | + | match self.client_auth_method() { | |
| 306 | + | ClientAuthMethod::BasicAuth => { | |
| 307 | + | if let Some(secret) = self.client_secret() { | |
| 308 | + | let credentials = format!("{}:{}", self.client_id(), secret); | |
| 309 | + | request = request.header( | |
| 310 | + | "Authorization", | |
| 311 | + | format!("Basic {}", STANDARD.encode(credentials.as_bytes())), | |
| 312 | + | ); | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ClientAuthMethod::FormBody => { | |
| 316 | + | form_params.push(("client_id", self.client_id())); | |
| 317 | + | if let Some(secret) = self.client_secret() { | |
| 318 | + | form_params.push(("client_secret", secret)); | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ClientAuthMethod::ClientIdOnly => { | |
| 322 | + | form_params.push(("client_id", self.client_id())); | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | let response = request | |
| 327 | + | .form(&form_params) | |
| 328 | + | .send() | |
| 329 | + | .await | |
| 330 | + | .map_err(|e| format!("Token revocation request failed: {}", e))?; | |
| 331 | + | ||
| 332 | + | if !response.status().is_success() { | |
| 333 | + | let status = response.status(); | |
| 334 | + | let body = response.text().await.unwrap_or_default(); | |
| 335 | + | return Err(format!("Token revocation failed ({}): {}", status, body)); | |
| 336 | + | } | |
| 337 | + | Ok(()) | |
| 338 | + | } | |
| 339 | + | ||
| 283 | 340 | /// Extracts the user's email address from the token response or via API call. | |
| 284 | 341 | /// | |
| 285 | 342 | /// Default implementation fetches from `config.userinfo_url` and extracts | |
| @@ -520,6 +577,7 @@ mod tests { | |||
| 520 | 577 | config: OAuthProviderConfig { | |
| 521 | 578 | auth_url: "https://auth.example.com/authorize".to_string(), | |
| 522 | 579 | token_url: "https://auth.example.com/token".to_string(), | |
| 580 | + | revoke_url: None, | |
| 523 | 581 | scopes: vec!["scope1".to_string(), "scope2".to_string()], | |
| 524 | 582 | uses_jmap: false, | |
| 525 | 583 | jmap_session_url: None, |
| @@ -24,6 +24,9 @@ impl FastmailProvider { | |||
| 24 | 24 | config: OAuthProviderConfig { | |
| 25 | 25 | auth_url: "https://api.fastmail.com/oauth/authorize".to_string(), | |
| 26 | 26 | token_url: "https://api.fastmail.com/oauth/refresh".to_string(), | |
| 27 | + | // No documented RFC 7009 revocation endpoint; disconnect drops | |
| 28 | + | // the local tokens without a provider-side revoke (best-effort). | |
| 29 | + | revoke_url: None, | |
| 27 | 30 | scopes: vec![ | |
| 28 | 31 | "urn:ietf:params:jmap:core".to_string(), | |
| 29 | 32 | "urn:ietf:params:jmap:mail".to_string(), |
| @@ -27,6 +27,7 @@ impl GoogleProvider { | |||
| 27 | 27 | config: OAuthProviderConfig { | |
| 28 | 28 | auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(), | |
| 29 | 29 | token_url: "https://oauth2.googleapis.com/token".to_string(), | |
| 30 | + | revoke_url: Some("https://oauth2.googleapis.com/revoke".to_string()), | |
| 30 | 31 | scopes: vec![ | |
| 31 | 32 | "https://mail.google.com/".to_string(), // Full Gmail access (IMAP/SMTP) | |
| 32 | 33 | "openid".to_string(), |
| @@ -29,6 +29,9 @@ impl MicrosoftProvider { | |||
| 29 | 29 | config: OAuthProviderConfig { | |
| 30 | 30 | auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".to_string(), | |
| 31 | 31 | token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token".to_string(), | |
| 32 | + | // Microsoft identity platform has no RFC 7009 revocation endpoint | |
| 33 | + | // (only interactive session logout), so disconnect can't revoke. | |
| 34 | + | revoke_url: None, | |
| 32 | 35 | scopes: vec![ | |
| 33 | 36 | "https://outlook.office.com/IMAP.AccessAsUser.All".to_string(), | |
| 34 | 37 | "https://outlook.office.com/SMTP.Send".to_string(), |
| @@ -219,6 +219,13 @@ impl AppState { | |||
| 219 | 219 | /// store secrets keychain-only (the column is `""`); this cleans up accounts | |
| 220 | 220 | /// created by older versions so plaintext secrets don't linger in the DB file. | |
| 221 | 221 | async fn scrub_legacy_email_passwords(pool: &SqlitePool) { | |
| 222 | + | scrub_legacy_passwords(pool).await; | |
| 223 | + | scrub_legacy_oauth_tokens(pool).await; | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// Migrate plaintext IMAP/SMTP passwords from the `password` column into the | |
| 227 | + | /// keychain, then blank the column. | |
| 228 | + | async fn scrub_legacy_passwords(pool: &SqlitePool) { | |
| 222 | 229 | let rows: Vec<(String, String)> = | |
| 223 | 230 | match sqlx::query_as("SELECT id, password FROM email_accounts WHERE password != ''") | |
| 224 | 231 | .fetch_all(pool) | |
| @@ -255,6 +262,58 @@ async fn scrub_legacy_email_passwords(pool: &SqlitePool) { | |||
| 255 | 262 | } | |
| 256 | 263 | } | |
| 257 | 264 | ||
| 265 | + | /// Migrate plaintext OAuth2 access/refresh tokens from the | |
| 266 | + | /// `oauth2_access_token` / `oauth2_refresh_token` columns into the keychain, | |
| 267 | + | /// then blank the columns. Older versions wrote these in cleartext and the | |
| 268 | + | /// token manager still reads them as a fallback, so without this they would | |
| 269 | + | /// linger unencrypted in the SQLite file forever. | |
| 270 | + | async fn scrub_legacy_oauth_tokens(pool: &SqlitePool) { | |
| 271 | + | let rows: Vec<(String, Option<String>, Option<String>)> = match sqlx::query_as( | |
| 272 | + | "SELECT id, oauth2_access_token, oauth2_refresh_token FROM email_accounts \ | |
| 273 | + | WHERE (oauth2_access_token IS NOT NULL AND oauth2_access_token != '') \ | |
| 274 | + | OR (oauth2_refresh_token IS NOT NULL AND oauth2_refresh_token != '')", | |
| 275 | + | ) | |
| 276 | + | .fetch_all(pool) | |
| 277 | + | .await | |
| 278 | + | { | |
| 279 | + | Ok(rows) => rows, | |
| 280 | + | Err(e) => { | |
| 281 | + | warn!("Could not scan for legacy OAuth tokens: {e}"); | |
| 282 | + | return; | |
| 283 | + | } | |
| 284 | + | }; | |
| 285 | + | ||
| 286 | + | for (id, access_token, refresh_token) in rows { | |
| 287 | + | let Ok(uuid) = uuid::Uuid::parse_str(&id) else { | |
| 288 | + | continue; | |
| 289 | + | }; | |
| 290 | + | // Don't clobber fresher keychain tokens (a refresh may have already | |
| 291 | + | // written newer ones); only migrate when the keychain has none. If the | |
| 292 | + | // store fails, leave the columns intact and retry on a future launch. | |
| 293 | + | if crate::oauth::CredentialStore::get_oauth(uuid).is_none() { | |
| 294 | + | let credentials = crate::oauth::OAuthCredentials { | |
| 295 | + | access_token: access_token.unwrap_or_default(), | |
| 296 | + | refresh_token: refresh_token.filter(|t| !t.is_empty()), | |
| 297 | + | }; | |
| 298 | + | if let Err(e) = crate::oauth::CredentialStore::store_oauth(uuid, &credentials) { | |
| 299 | + | warn!("Failed to migrate OAuth tokens to keychain for {id}: {e}"); | |
| 300 | + | continue; | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | if let Err(e) = sqlx::query( | |
| 304 | + | "UPDATE email_accounts SET oauth2_access_token = '', oauth2_refresh_token = '' WHERE id = ?", | |
| 305 | + | ) | |
| 306 | + | .bind(&id) | |
| 307 | + | .execute(pool) | |
| 308 | + | .await | |
| 309 | + | { | |
| 310 | + | warn!("Failed to blank legacy OAuth token columns for {id}: {e}"); | |
| 311 | + | } else { | |
| 312 | + | info!("Migrated legacy OAuth tokens to the keychain"); | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 258 | 317 | /// Load a SyncKit API key from the keychain, migrating from plaintext file if needed. | |
| 259 | 318 | fn load_api_key(data_dir: &std::path::Path) -> Option<String> { | |
| 260 | 319 | // Migrate plaintext file to keychain (one-time) |