Skip to main content

max / goingson

16.9 KB · 495 lines History Blame Raw
1 //! OAuth2 authentication commands.
2 //!
3 //! Provides Tauri commands for OAuth2 flows with various email providers.
4 //! Supports PKCE-based flows for both JMAP and IMAP/XOAUTH2 authentication.
5
6 use chrono::{Duration, Utc};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::{EmailAccountId, EmailAuthType};
13
14 use crate::jmap::session::discover_session;
15 use crate::oauth::{CredentialStore, OAuthCallbackServer, OAuthCredentials, TokenManager};
16 use crate::state::{AppState, DESKTOP_USER_ID};
17 use super::{ApiError, OptionApiError, OptionNotFound, ResultApiError};
18
19 // ============ Types ============
20
21 /// Available OAuth providers response.
22 #[derive(Debug, Serialize)]
23 #[serde(rename_all = "camelCase")]
24 pub struct AvailableProvidersResponse {
25 pub providers: Vec<ProviderInfo>,
26 }
27
28 /// Provider information for UI display.
29 #[derive(Debug, Serialize)]
30 #[serde(rename_all = "camelCase")]
31 pub struct ProviderInfo {
32 pub id: String,
33 pub name: String,
34 pub uses_jmap: bool,
35 }
36
37 /// OAuth start response.
38 #[derive(Debug, Serialize)]
39 #[serde(rename_all = "camelCase")]
40 pub struct OAuthStartResponse {
41 /// URL to open in browser
42 pub auth_url: String,
43 /// State token for CSRF verification
44 pub state: String,
45 /// Provider ID
46 pub provider: String,
47 /// Port of local callback server
48 pub port: u16,
49 }
50
51 /// OAuth complete input.
52 #[derive(Debug, Deserialize)]
53 #[serde(rename_all = "camelCase")]
54 pub struct OAuthCompleteInput {
55 /// Authorization code from callback
56 pub code: String,
57 /// State token to verify (looked up server-side for CSRF validation)
58 pub state: String,
59 }
60
61 /// OAuth complete response.
62 #[derive(Debug, Serialize)]
63 #[serde(rename_all = "camelCase")]
64 pub struct OAuthCompleteResponse {
65 /// Created account ID
66 pub account_id: EmailAccountId,
67 /// Account name
68 pub account_name: String,
69 /// Email address
70 pub email_address: String,
71 /// Provider display name
72 pub provider_name: String,
73 }
74
75 /// Result of polling a pending OAuth callback.
76 ///
77 /// Mirrors the callback server's stored state. `status` is one of
78 /// `pending`, `success`, or `error`.
79 #[derive(Debug, Serialize)]
80 #[serde(rename_all = "camelCase")]
81 pub struct OAuthPollResponse {
82 pub status: String,
83 pub code: Option<String>,
84 pub state: Option<String>,
85 pub error: Option<String>,
86 pub description: Option<String>,
87 }
88
89 impl From<crate::oauth::StoredCallback> for OAuthPollResponse {
90 fn from(stored: crate::oauth::StoredCallback) -> Self {
91 use crate::oauth::StoredCallback;
92 match stored {
93 StoredCallback::Pending => OAuthPollResponse {
94 status: "pending".to_string(),
95 code: None,
96 state: None,
97 error: None,
98 description: None,
99 },
100 StoredCallback::Success { code, state } => OAuthPollResponse {
101 status: "success".to_string(),
102 code: Some(code),
103 state: Some(state),
104 error: None,
105 description: None,
106 },
107 StoredCallback::Error { error, description } => OAuthPollResponse {
108 status: "error".to_string(),
109 code: None,
110 state: None,
111 error: Some(error),
112 description,
113 },
114 }
115 }
116 }
117
118 // ============ Commands ============
119
120 /// Lists available OAuth providers.
121 ///
122 /// Returns providers that have been configured with client IDs.
123 #[tauri::command]
124 #[instrument(skip_all)]
125 pub async fn list_oauth_providers(
126 _state: State<'_, Arc<AppState>>,
127 ) -> Result<AvailableProvidersResponse, ApiError> {
128 let token_manager = TokenManager::from_env();
129 let providers = token_manager
130 .available_providers()
131 .iter()
132 .filter_map(|id| {
133 token_manager.provider(id).map(|p| ProviderInfo {
134 id: p.id().to_string(),
135 name: p.display_name().to_string(),
136 uses_jmap: p.config().uses_jmap,
137 })
138 })
139 .collect();
140
141 Ok(AvailableProvidersResponse { providers })
142 }
143
144 /// Starts an OAuth flow for a provider.
145 ///
146 /// Returns the authorization URL to open in the browser.
147 /// The frontend should call `complete_oauth` after the user authorizes.
148 ///
149 /// # Errors
150 ///
151 /// Returns `BAD_REQUEST` if the provider is not configured.
152 /// Returns `INTERNAL_ERROR` if the callback server fails to start.
153 #[tauri::command]
154 #[instrument(skip_all)]
155 pub async fn start_oauth(
156 state: State<'_, Arc<AppState>>,
157 provider_id: String,
158 ) -> Result<OAuthStartResponse, ApiError> {
159 let token_manager = TokenManager::from_env();
160 let provider = token_manager
161 .provider(&provider_id)
162 .or_api_err(|| ApiError::bad_request(format!("Provider '{}' not configured", provider_id)))?;
163
164 // Start callback server
165 let callback_server = OAuthCallbackServer::start()
166 .map_api_err("Failed to start callback server", ApiError::internal)?;
167 let port = callback_server.port();
168
169 // Generate auth URL
170 let start_result = provider.start_auth(port);
171
172 // Store PKCE verifier and flow details server-side (never sent to frontend)
173 {
174 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
175 flows.insert(start_result.state.clone(), crate::state::PendingOAuthFlow {
176 code_verifier: start_result.code_verifier,
177 provider_id: provider_id.clone(),
178 port,
179 });
180 }
181
182 // Keep the callback server alive so the frontend can poll its result over
183 // IPC (see `poll_oauth_result`).
184 {
185 let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
186 servers.insert(port, callback_server);
187 }
188
189 Ok(OAuthStartResponse {
190 auth_url: start_result.auth_url,
191 state: start_result.state,
192 provider: start_result.provider,
193 port,
194 })
195 }
196
197 /// Polls the local OAuth callback server for a result.
198 ///
199 /// The frontend calls this on an interval after opening the browser. Delivering
200 /// the authorization code over IPC (instead of an unauthenticated HTTP endpoint)
201 /// keeps it out of reach of other local processes on the loopback port.
202 ///
203 /// Returns `{status: "pending"}` until the redirect arrives, then a `success`
204 /// (with code/state) or `error` payload. An unknown port also reports `pending`.
205 #[tauri::command]
206 #[instrument(skip_all)]
207 pub async fn poll_oauth_result(
208 state: State<'_, Arc<AppState>>,
209 port: u16,
210 ) -> Result<OAuthPollResponse, ApiError> {
211 let servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
212 let stored = match servers.get(&port) {
213 Some(server) => server.poll(),
214 None => crate::oauth::StoredCallback::Pending,
215 };
216 Ok(stored.into())
217 }
218
219 /// Completes OAuth with an authorization code.
220 ///
221 /// Called after the browser redirects back with the code.
222 /// Exchanges the authorization code for tokens, discovers user email,
223 /// and creates the appropriate account type (JMAP or IMAP/XOAUTH2).
224 ///
225 /// # Errors
226 ///
227 /// Returns `BAD_REQUEST` if the provider is not configured or unknown.
228 /// Returns `EXTERNAL_SERVICE_ERROR` if token exchange or email discovery fails.
229 /// Returns `DATABASE_ERROR` if account creation fails.
230 #[tauri::command]
231 #[instrument(skip_all)]
232 pub async fn complete_oauth(
233 state: State<'_, Arc<AppState>>,
234 input: OAuthCompleteInput,
235 ) -> Result<OAuthCompleteResponse, ApiError> {
236 // Look up and consume the pending flow by state token (CSRF validation)
237 let flow = {
238 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
239 flows.remove(&input.state)
240 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
241
242 // The callback has been consumed; tear down its loopback server.
243 {
244 let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
245 servers.remove(&flow.port);
246 }
247
248 let token_manager = TokenManager::from_env();
249 let provider = token_manager
250 .provider(&flow.provider_id)
251 .or_api_err(|| ApiError::bad_request(format!("Provider '{}' not configured", flow.provider_id)))?;
252
253 // Exchange code for tokens using server-side PKCE verifier
254 let token_result = provider
255 .exchange_code(&input.code, &flow.code_verifier, flow.port)
256 .await
257 .map_api_err("Token exchange failed", ApiError::external_service)?;
258
259 // Get user's email address
260 let email_address = provider.get_user_email(&token_result.access_token).await
261 .map_api_err("Failed to get user email", ApiError::external_service)?;
262
263 // Calculate token expiration
264 let expires_at = Utc::now()
265 + Duration::seconds(token_result.expires_in.unwrap_or(3600) as i64);
266
267 // Create account based on provider type
268 let auth_type = EmailAuthType::from_provider_id(&flow.provider_id)
269 .or_api_err(|| ApiError::bad_request(format!("Unknown provider: {}", flow.provider_id)))?;
270
271 let account_name = format!("{} ({})", email_address, provider.display_name());
272
273 if auth_type.uses_jmap() {
274 // JMAP provider - discover session and create OAuth account
275 let session_url = provider
276 .config()
277 .jmap_session_url
278 .as_ref()
279 .or_api_err(|| ApiError::bad_request("Provider has no JMAP session URL"))?;
280
281 let session = discover_session(session_url, &token_result.access_token).await
282 .map_api_err("JMAP session discovery failed", ApiError::external_service)?;
283 let jmap_account_id = session
284 .primary_email_account()
285 .or_api_err(|| ApiError::external_service("No primary email account in JMAP session"))?
286 .to_string();
287
288 let account = state
289 .email_accounts
290 .create_oauth(
291 DESKTOP_USER_ID,
292 &account_name,
293 &email_address,
294 "", // Don't store access token in DB
295 "", // Don't store refresh token in DB
296 expires_at,
297 &session.api_url,
298 &jmap_account_id,
299 )
300 .await?;
301
302 // Store tokens securely in OS keychain
303 let credentials = OAuthCredentials {
304 access_token: token_result.access_token,
305 refresh_token: token_result.refresh_token,
306 };
307 CredentialStore::store_oauth(account.id.into(), &credentials)
308 .map_api_err("Failed to store credentials", ApiError::internal)?;
309
310 Ok(OAuthCompleteResponse {
311 account_id: account.id,
312 account_name: account.account_name,
313 email_address: account.email_address,
314 provider_name: provider.display_name().to_string(),
315 })
316 } else {
317 // IMAP/SMTP provider with XOAUTH2
318 let config = provider.config();
319 let imap_server = config
320 .imap_server
321 .as_ref()
322 .or_api_err(|| ApiError::bad_request("Provider has no IMAP server configured"))?;
323 let imap_port = config
324 .imap_port
325 .or_api_err(|| ApiError::bad_request("Provider has no IMAP port configured"))?;
326 let smtp_server = config
327 .smtp_server
328 .as_ref()
329 .or_api_err(|| ApiError::bad_request("Provider has no SMTP server configured"))?;
330 let smtp_port = config
331 .smtp_port
332 .or_api_err(|| ApiError::bad_request("Provider has no SMTP port configured"))?;
333
334 let account = state
335 .email_accounts
336 .create_oauth_imap(
337 DESKTOP_USER_ID,
338 &account_name,
339 &email_address,
340 auth_type,
341 "", // Don't store access token in DB
342 "", // Don't store refresh token in DB
343 expires_at,
344 imap_server,
345 imap_port as i32,
346 smtp_server,
347 smtp_port as i32,
348 )
349 .await?;
350
351 // Store tokens securely in OS keychain
352 let credentials = OAuthCredentials {
353 access_token: token_result.access_token,
354 refresh_token: token_result.refresh_token,
355 };
356 CredentialStore::store_oauth(account.id.into(), &credentials)
357 .map_api_err("Failed to store credentials", ApiError::internal)?;
358
359 Ok(OAuthCompleteResponse {
360 account_id: account.id,
361 account_name: account.account_name,
362 email_address: account.email_address,
363 provider_name: provider.display_name().to_string(),
364 })
365 }
366 }
367
368 /// Refreshes OAuth tokens for an account.
369 ///
370 /// Only refreshes if the token is expired or near expiration.
371 /// Returns true if tokens were refreshed, false if still valid.
372 ///
373 /// # Errors
374 ///
375 /// Returns `NOT_FOUND` if the account doesn't exist.
376 /// Returns `BAD_REQUEST` if the account doesn't use OAuth.
377 /// Returns `EXTERNAL_SERVICE_ERROR` if token refresh fails.
378 /// Returns `DATABASE_ERROR` if saving new tokens fails.
379 #[tauri::command]
380 #[instrument(skip_all)]
381 pub async fn refresh_oauth_tokens(
382 state: State<'_, Arc<AppState>>,
383 account_id: EmailAccountId,
384 ) -> Result<bool, ApiError> {
385 let account = state
386 .email_accounts
387 .get_by_id(account_id, DESKTOP_USER_ID)
388 .await?
389 .or_not_found("emailAccount", account_id)?;
390
391 if !account.is_oauth() {
392 return Err(ApiError::bad_request("Account does not use OAuth"));
393 }
394
395 let refresh_lock = state.token_refresh_lock(account.id.into());
396 let _guard = refresh_lock.lock().await;
397 let token_manager = TokenManager::from_env();
398 let result = token_manager.refresh_if_needed(&account).await
399 .map_api_err("Token refresh failed", ApiError::external_service)?;
400
401 match result {
402 Some((access_token, refresh_token, expires_at)) => {
403 // Update expiration in database (but not tokens)
404 state
405 .email_accounts
406 .update_oauth_tokens(
407 account_id,
408 DESKTOP_USER_ID,
409 "", // Don't update token in DB
410 None,
411 expires_at,
412 )
413 .await?;
414
415 // Store refreshed tokens in keychain
416 CredentialStore::update_oauth_tokens(
417 account_id.into(),
418 &access_token,
419 refresh_token.as_deref(),
420 )
421 .map_api_err("Failed to store refreshed tokens", ApiError::internal)?;
422
423 Ok(true)
424 }
425 None => Ok(false), // Token didn't need refresh
426 }
427 }
428
429 /// Disconnects an OAuth account (revokes tokens and deletes account).
430 ///
431 /// # Errors
432 ///
433 /// Returns `DATABASE_ERROR` if the delete fails.
434 #[tauri::command]
435 #[instrument(skip_all)]
436 pub async fn disconnect_oauth(
437 state: State<'_, Arc<AppState>>,
438 account_id: EmailAccountId,
439 ) -> Result<bool, ApiError> {
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
461 let _ = CredentialStore::delete_oauth(account_id.into());
462
463 // Delete the account from database
464 Ok(state.email_accounts.delete(account_id, DESKTOP_USER_ID).await?)
465 }
466
467 /// Reconnects an OAuth account that has lost authorization.
468 ///
469 /// This starts a new OAuth flow that will update the existing account.
470 ///
471 /// # Errors
472 ///
473 /// Returns `NOT_FOUND` if the account doesn't exist.
474 /// Returns `BAD_REQUEST` if the account doesn't use OAuth.
475 #[tauri::command]
476 #[instrument(skip_all)]
477 pub async fn reconnect_oauth(
478 state: State<'_, Arc<AppState>>,
479 account_id: EmailAccountId,
480 ) -> Result<OAuthStartResponse, ApiError> {
481 let account = state
482 .email_accounts
483 .get_by_id(account_id, DESKTOP_USER_ID)
484 .await?
485 .or_not_found("emailAccount", account_id)?;
486
487 let provider_id = account
488 .auth_type
489 .provider_id()
490 .or_api_err(|| ApiError::bad_request("Account does not use OAuth"))?;
491
492 // Start new OAuth flow
493 start_oauth(state, provider_id.to_string()).await
494 }
495