Skip to main content

max / goingson

13.2 KB · 398 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 // ============ Commands ============
76
77 /// Lists available OAuth providers.
78 ///
79 /// Returns providers that have been configured with client IDs.
80 #[tauri::command]
81 #[instrument(skip_all)]
82 pub async fn list_oauth_providers(
83 _state: State<'_, Arc<AppState>>,
84 ) -> Result<AvailableProvidersResponse, ApiError> {
85 let token_manager = TokenManager::from_env();
86 let providers = token_manager
87 .available_providers()
88 .iter()
89 .filter_map(|id| {
90 token_manager.provider(id).map(|p| ProviderInfo {
91 id: p.id().to_string(),
92 name: p.display_name().to_string(),
93 uses_jmap: p.config().uses_jmap,
94 })
95 })
96 .collect();
97
98 Ok(AvailableProvidersResponse { providers })
99 }
100
101 /// Starts an OAuth flow for a provider.
102 ///
103 /// Returns the authorization URL to open in the browser.
104 /// The frontend should call `complete_oauth` after the user authorizes.
105 ///
106 /// # Errors
107 ///
108 /// Returns `BAD_REQUEST` if the provider is not configured.
109 /// Returns `INTERNAL_ERROR` if the callback server fails to start.
110 #[tauri::command]
111 #[instrument(skip_all)]
112 pub async fn start_oauth(
113 state: State<'_, Arc<AppState>>,
114 provider_id: String,
115 ) -> Result<OAuthStartResponse, ApiError> {
116 let token_manager = TokenManager::from_env();
117 let provider = token_manager
118 .provider(&provider_id)
119 .or_api_err(|| ApiError::bad_request(format!("Provider '{}' not configured", provider_id)))?;
120
121 // Start callback server
122 let callback_server = OAuthCallbackServer::start()
123 .map_api_err("Failed to start callback server", ApiError::internal)?;
124 let port = callback_server.port();
125
126 // Generate auth URL
127 let start_result = provider.start_auth(port);
128
129 // Store PKCE verifier and flow details server-side (never sent to frontend)
130 {
131 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
132 flows.insert(start_result.state.clone(), crate::state::PendingOAuthFlow {
133 code_verifier: start_result.code_verifier,
134 provider_id: provider_id.clone(),
135 port,
136 });
137 }
138
139 Ok(OAuthStartResponse {
140 auth_url: start_result.auth_url,
141 state: start_result.state,
142 provider: start_result.provider,
143 port,
144 })
145 }
146
147 /// Completes OAuth with an authorization code.
148 ///
149 /// Called after the browser redirects back with the code.
150 /// Exchanges the authorization code for tokens, discovers user email,
151 /// and creates the appropriate account type (JMAP or IMAP/XOAUTH2).
152 ///
153 /// # Errors
154 ///
155 /// Returns `BAD_REQUEST` if the provider is not configured or unknown.
156 /// Returns `EXTERNAL_SERVICE_ERROR` if token exchange or email discovery fails.
157 /// Returns `DATABASE_ERROR` if account creation fails.
158 #[tauri::command]
159 #[instrument(skip_all)]
160 pub async fn complete_oauth(
161 state: State<'_, Arc<AppState>>,
162 input: OAuthCompleteInput,
163 ) -> Result<OAuthCompleteResponse, ApiError> {
164 // Look up and consume the pending flow by state token (CSRF validation)
165 let flow = {
166 let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
167 flows.remove(&input.state)
168 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
169
170 let token_manager = TokenManager::from_env();
171 let provider = token_manager
172 .provider(&flow.provider_id)
173 .or_api_err(|| ApiError::bad_request(format!("Provider '{}' not configured", flow.provider_id)))?;
174
175 // Exchange code for tokens using server-side PKCE verifier
176 let token_result = provider
177 .exchange_code(&input.code, &flow.code_verifier, flow.port)
178 .await
179 .map_api_err("Token exchange failed", ApiError::external_service)?;
180
181 // Get user's email address
182 let email_address = provider.get_user_email(&token_result.access_token).await
183 .map_api_err("Failed to get user email", ApiError::external_service)?;
184
185 // Calculate token expiration
186 let expires_at = Utc::now()
187 + Duration::seconds(token_result.expires_in.unwrap_or(3600) as i64);
188
189 // Create account based on provider type
190 let auth_type = EmailAuthType::from_provider_id(&flow.provider_id)
191 .or_api_err(|| ApiError::bad_request(format!("Unknown provider: {}", flow.provider_id)))?;
192
193 let account_name = format!("{} ({})", email_address, provider.display_name());
194
195 if auth_type.uses_jmap() {
196 // JMAP provider - discover session and create OAuth account
197 let session_url = provider
198 .config()
199 .jmap_session_url
200 .as_ref()
201 .or_api_err(|| ApiError::bad_request("Provider has no JMAP session URL"))?;
202
203 let session = discover_session(session_url, &token_result.access_token).await
204 .map_api_err("JMAP session discovery failed", ApiError::external_service)?;
205 let jmap_account_id = session
206 .primary_email_account()
207 .or_api_err(|| ApiError::external_service("No primary email account in JMAP session"))?
208 .to_string();
209
210 let account = state
211 .email_accounts
212 .create_oauth(
213 DESKTOP_USER_ID,
214 &account_name,
215 &email_address,
216 "", // Don't store access token in DB
217 "", // Don't store refresh token in DB
218 expires_at,
219 &session.api_url,
220 &jmap_account_id,
221 )
222 .await?;
223
224 // Store tokens securely in OS keychain
225 let credentials = OAuthCredentials {
226 access_token: token_result.access_token,
227 refresh_token: token_result.refresh_token,
228 };
229 CredentialStore::store_oauth(account.id.into(), &credentials)
230 .map_api_err("Failed to store credentials", ApiError::internal)?;
231
232 Ok(OAuthCompleteResponse {
233 account_id: account.id,
234 account_name: account.account_name,
235 email_address: account.email_address,
236 provider_name: provider.display_name().to_string(),
237 })
238 } else {
239 // IMAP/SMTP provider with XOAUTH2
240 let config = provider.config();
241 let imap_server = config
242 .imap_server
243 .as_ref()
244 .or_api_err(|| ApiError::bad_request("Provider has no IMAP server configured"))?;
245 let imap_port = config
246 .imap_port
247 .or_api_err(|| ApiError::bad_request("Provider has no IMAP port configured"))?;
248 let smtp_server = config
249 .smtp_server
250 .as_ref()
251 .or_api_err(|| ApiError::bad_request("Provider has no SMTP server configured"))?;
252 let smtp_port = config
253 .smtp_port
254 .or_api_err(|| ApiError::bad_request("Provider has no SMTP port configured"))?;
255
256 let account = state
257 .email_accounts
258 .create_oauth_imap(
259 DESKTOP_USER_ID,
260 &account_name,
261 &email_address,
262 auth_type,
263 "", // Don't store access token in DB
264 "", // Don't store refresh token in DB
265 expires_at,
266 imap_server,
267 imap_port as i32,
268 smtp_server,
269 smtp_port as i32,
270 )
271 .await?;
272
273 // Store tokens securely in OS keychain
274 let credentials = OAuthCredentials {
275 access_token: token_result.access_token,
276 refresh_token: token_result.refresh_token,
277 };
278 CredentialStore::store_oauth(account.id.into(), &credentials)
279 .map_api_err("Failed to store credentials", ApiError::internal)?;
280
281 Ok(OAuthCompleteResponse {
282 account_id: account.id,
283 account_name: account.account_name,
284 email_address: account.email_address,
285 provider_name: provider.display_name().to_string(),
286 })
287 }
288 }
289
290 /// Refreshes OAuth tokens for an account.
291 ///
292 /// Only refreshes if the token is expired or near expiration.
293 /// Returns true if tokens were refreshed, false if still valid.
294 ///
295 /// # Errors
296 ///
297 /// Returns `NOT_FOUND` if the account doesn't exist.
298 /// Returns `BAD_REQUEST` if the account doesn't use OAuth.
299 /// Returns `EXTERNAL_SERVICE_ERROR` if token refresh fails.
300 /// Returns `DATABASE_ERROR` if saving new tokens fails.
301 #[tauri::command]
302 #[instrument(skip_all)]
303 pub async fn refresh_oauth_tokens(
304 state: State<'_, Arc<AppState>>,
305 account_id: EmailAccountId,
306 ) -> Result<bool, ApiError> {
307 let account = state
308 .email_accounts
309 .get_by_id(account_id, DESKTOP_USER_ID)
310 .await?
311 .or_not_found("emailAccount", account_id)?;
312
313 if !account.is_oauth() {
314 return Err(ApiError::bad_request("Account does not use OAuth"));
315 }
316
317 let refresh_lock = state.token_refresh_lock(account.id.into());
318 let _guard = refresh_lock.lock().await;
319 let token_manager = TokenManager::from_env();
320 let result = token_manager.refresh_if_needed(&account).await
321 .map_api_err("Token refresh failed", ApiError::external_service)?;
322
323 match result {
324 Some((access_token, refresh_token, expires_at)) => {
325 // Update expiration in database (but not tokens)
326 state
327 .email_accounts
328 .update_oauth_tokens(
329 account_id,
330 DESKTOP_USER_ID,
331 "", // Don't update token in DB
332 None,
333 expires_at,
334 )
335 .await?;
336
337 // Store refreshed tokens in keychain
338 CredentialStore::update_oauth_tokens(
339 account_id.into(),
340 &access_token,
341 refresh_token.as_deref(),
342 )
343 .map_api_err("Failed to store refreshed tokens", ApiError::internal)?;
344
345 Ok(true)
346 }
347 None => Ok(false), // Token didn't need refresh
348 }
349 }
350
351 /// Disconnects an OAuth account (revokes tokens and deletes account).
352 ///
353 /// # Errors
354 ///
355 /// Returns `DATABASE_ERROR` if the delete fails.
356 #[tauri::command]
357 #[instrument(skip_all)]
358 pub async fn disconnect_oauth(
359 state: State<'_, Arc<AppState>>,
360 account_id: EmailAccountId,
361 ) -> Result<bool, ApiError> {
362 // Delete credentials from keychain first
363 let _ = CredentialStore::delete_oauth(account_id.into());
364
365 // Delete the account from database
366 // In the future, we could also revoke the token with the provider
367 Ok(state.email_accounts.delete(account_id, DESKTOP_USER_ID).await?)
368 }
369
370 /// Reconnects an OAuth account that has lost authorization.
371 ///
372 /// This starts a new OAuth flow that will update the existing account.
373 ///
374 /// # Errors
375 ///
376 /// Returns `NOT_FOUND` if the account doesn't exist.
377 /// Returns `BAD_REQUEST` if the account doesn't use OAuth.
378 #[tauri::command]
379 #[instrument(skip_all)]
380 pub async fn reconnect_oauth(
381 state: State<'_, Arc<AppState>>,
382 account_id: EmailAccountId,
383 ) -> Result<OAuthStartResponse, ApiError> {
384 let account = state
385 .email_accounts
386 .get_by_id(account_id, DESKTOP_USER_ID)
387 .await?
388 .or_not_found("emailAccount", account_id)?;
389
390 let provider_id = account
391 .auth_type
392 .provider_id()
393 .or_api_err(|| ApiError::bad_request("Account does not use OAuth"))?;
394
395 // Start new OAuth flow
396 start_oauth(state, provider_id.to_string()).await
397 }
398