//! OAuth2 authentication commands. //! //! Provides Tauri commands for OAuth2 flows with various email providers. //! Supports PKCE-based flows for both JMAP and IMAP/XOAUTH2 authentication. use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{EmailAccountId, EmailAuthType}; use crate::jmap::session::discover_session; use crate::oauth::{CredentialStore, OAuthCallbackServer, OAuthCredentials, TokenManager}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionApiError, OptionNotFound, ResultApiError}; // ============ Types ============ /// Available OAuth providers response. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AvailableProvidersResponse { pub providers: Vec, } /// Provider information for UI display. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderInfo { pub id: String, pub name: String, pub uses_jmap: bool, } /// OAuth start response. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct OAuthStartResponse { /// URL to open in browser pub auth_url: String, /// State token for CSRF verification pub state: String, /// Provider ID pub provider: String, /// Port of local callback server pub port: u16, } /// OAuth complete input. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OAuthCompleteInput { /// Authorization code from callback pub code: String, /// State token to verify (looked up server-side for CSRF validation) pub state: String, } /// OAuth complete response. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct OAuthCompleteResponse { /// Created account ID pub account_id: EmailAccountId, /// Account name pub account_name: String, /// Email address pub email_address: String, /// Provider display name pub provider_name: String, } // ============ Commands ============ /// Lists available OAuth providers. /// /// Returns providers that have been configured with client IDs. #[tauri::command] #[instrument(skip_all)] pub async fn list_oauth_providers( _state: State<'_, Arc>, ) -> Result { let token_manager = TokenManager::from_env(); let providers = token_manager .available_providers() .iter() .filter_map(|id| { token_manager.provider(id).map(|p| ProviderInfo { id: p.id().to_string(), name: p.display_name().to_string(), uses_jmap: p.config().uses_jmap, }) }) .collect(); Ok(AvailableProvidersResponse { providers }) } /// Starts an OAuth flow for a provider. /// /// Returns the authorization URL to open in the browser. /// The frontend should call `complete_oauth` after the user authorizes. /// /// # Errors /// /// Returns `BAD_REQUEST` if the provider is not configured. /// Returns `INTERNAL_ERROR` if the callback server fails to start. #[tauri::command] #[instrument(skip_all)] pub async fn start_oauth( state: State<'_, Arc>, provider_id: String, ) -> Result { let token_manager = TokenManager::from_env(); let provider = token_manager .provider(&provider_id) .or_api_err(|| ApiError::bad_request(format!("Provider '{}' not configured", provider_id)))?; // Start callback server let callback_server = OAuthCallbackServer::start() .map_api_err("Failed to start callback server", ApiError::internal)?; let port = callback_server.port(); // Generate auth URL let start_result = provider.start_auth(port); // Store PKCE verifier and flow details server-side (never sent to frontend) { let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); flows.insert(start_result.state.clone(), crate::state::PendingOAuthFlow { code_verifier: start_result.code_verifier, provider_id: provider_id.clone(), port, }); } Ok(OAuthStartResponse { auth_url: start_result.auth_url, state: start_result.state, provider: start_result.provider, port, }) } /// Completes OAuth with an authorization code. /// /// Called after the browser redirects back with the code. /// Exchanges the authorization code for tokens, discovers user email, /// and creates the appropriate account type (JMAP or IMAP/XOAUTH2). /// /// # Errors /// /// Returns `BAD_REQUEST` if the provider is not configured or unknown. /// Returns `EXTERNAL_SERVICE_ERROR` if token exchange or email discovery fails. /// Returns `DATABASE_ERROR` if account creation fails. #[tauri::command] #[instrument(skip_all)] pub async fn complete_oauth( state: State<'_, Arc>, input: OAuthCompleteInput, ) -> Result { // Look up and consume the pending flow by state token (CSRF validation) let flow = { let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); flows.remove(&input.state) }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?; let token_manager = TokenManager::from_env(); let provider = token_manager .provider(&flow.provider_id) .or_api_err(|| ApiError::bad_request(format!("Provider '{}' not configured", flow.provider_id)))?; // Exchange code for tokens using server-side PKCE verifier let token_result = provider .exchange_code(&input.code, &flow.code_verifier, flow.port) .await .map_api_err("Token exchange failed", ApiError::external_service)?; // Get user's email address let email_address = provider.get_user_email(&token_result.access_token).await .map_api_err("Failed to get user email", ApiError::external_service)?; // Calculate token expiration let expires_at = Utc::now() + Duration::seconds(token_result.expires_in.unwrap_or(3600) as i64); // Create account based on provider type let auth_type = EmailAuthType::from_provider_id(&flow.provider_id) .or_api_err(|| ApiError::bad_request(format!("Unknown provider: {}", flow.provider_id)))?; let account_name = format!("{} ({})", email_address, provider.display_name()); if auth_type.uses_jmap() { // JMAP provider - discover session and create OAuth account let session_url = provider .config() .jmap_session_url .as_ref() .or_api_err(|| ApiError::bad_request("Provider has no JMAP session URL"))?; let session = discover_session(session_url, &token_result.access_token).await .map_api_err("JMAP session discovery failed", ApiError::external_service)?; let jmap_account_id = session .primary_email_account() .or_api_err(|| ApiError::external_service("No primary email account in JMAP session"))? .to_string(); let account = state .email_accounts .create_oauth( DESKTOP_USER_ID, &account_name, &email_address, "", // Don't store access token in DB "", // Don't store refresh token in DB expires_at, &session.api_url, &jmap_account_id, ) .await?; // Store tokens securely in OS keychain let credentials = OAuthCredentials { access_token: token_result.access_token, refresh_token: token_result.refresh_token, }; CredentialStore::store_oauth(account.id.into(), &credentials) .map_api_err("Failed to store credentials", ApiError::internal)?; Ok(OAuthCompleteResponse { account_id: account.id, account_name: account.account_name, email_address: account.email_address, provider_name: provider.display_name().to_string(), }) } else { // IMAP/SMTP provider with XOAUTH2 let config = provider.config(); let imap_server = config .imap_server .as_ref() .or_api_err(|| ApiError::bad_request("Provider has no IMAP server configured"))?; let imap_port = config .imap_port .or_api_err(|| ApiError::bad_request("Provider has no IMAP port configured"))?; let smtp_server = config .smtp_server .as_ref() .or_api_err(|| ApiError::bad_request("Provider has no SMTP server configured"))?; let smtp_port = config .smtp_port .or_api_err(|| ApiError::bad_request("Provider has no SMTP port configured"))?; let account = state .email_accounts .create_oauth_imap( DESKTOP_USER_ID, &account_name, &email_address, auth_type, "", // Don't store access token in DB "", // Don't store refresh token in DB expires_at, imap_server, imap_port as i32, smtp_server, smtp_port as i32, ) .await?; // Store tokens securely in OS keychain let credentials = OAuthCredentials { access_token: token_result.access_token, refresh_token: token_result.refresh_token, }; CredentialStore::store_oauth(account.id.into(), &credentials) .map_api_err("Failed to store credentials", ApiError::internal)?; Ok(OAuthCompleteResponse { account_id: account.id, account_name: account.account_name, email_address: account.email_address, provider_name: provider.display_name().to_string(), }) } } /// Refreshes OAuth tokens for an account. /// /// Only refreshes if the token is expired or near expiration. /// Returns true if tokens were refreshed, false if still valid. /// /// # Errors /// /// Returns `NOT_FOUND` if the account doesn't exist. /// Returns `BAD_REQUEST` if the account doesn't use OAuth. /// Returns `EXTERNAL_SERVICE_ERROR` if token refresh fails. /// Returns `DATABASE_ERROR` if saving new tokens fails. #[tauri::command] #[instrument(skip_all)] pub async fn refresh_oauth_tokens( state: State<'_, Arc>, account_id: EmailAccountId, ) -> Result { let account = state .email_accounts .get_by_id(account_id, DESKTOP_USER_ID) .await? .or_not_found("emailAccount", account_id)?; if !account.is_oauth() { return Err(ApiError::bad_request("Account does not use OAuth")); } let refresh_lock = state.token_refresh_lock(account.id.into()); let _guard = refresh_lock.lock().await; let token_manager = TokenManager::from_env(); let result = token_manager.refresh_if_needed(&account).await .map_api_err("Token refresh failed", ApiError::external_service)?; match result { Some((access_token, refresh_token, expires_at)) => { // Update expiration in database (but not tokens) state .email_accounts .update_oauth_tokens( account_id, DESKTOP_USER_ID, "", // Don't update token in DB None, expires_at, ) .await?; // Store refreshed tokens in keychain CredentialStore::update_oauth_tokens( account_id.into(), &access_token, refresh_token.as_deref(), ) .map_api_err("Failed to store refreshed tokens", ApiError::internal)?; Ok(true) } None => Ok(false), // Token didn't need refresh } } /// Disconnects an OAuth account (revokes tokens and deletes account). /// /// # Errors /// /// Returns `DATABASE_ERROR` if the delete fails. #[tauri::command] #[instrument(skip_all)] pub async fn disconnect_oauth( state: State<'_, Arc>, account_id: EmailAccountId, ) -> Result { // Delete credentials from keychain first let _ = CredentialStore::delete_oauth(account_id.into()); // Delete the account from database // In the future, we could also revoke the token with the provider Ok(state.email_accounts.delete(account_id, DESKTOP_USER_ID).await?) } /// Reconnects an OAuth account that has lost authorization. /// /// This starts a new OAuth flow that will update the existing account. /// /// # Errors /// /// Returns `NOT_FOUND` if the account doesn't exist. /// Returns `BAD_REQUEST` if the account doesn't use OAuth. #[tauri::command] #[instrument(skip_all)] pub async fn reconnect_oauth( state: State<'_, Arc>, account_id: EmailAccountId, ) -> Result { let account = state .email_accounts .get_by_id(account_id, DESKTOP_USER_ID) .await? .or_not_found("emailAccount", account_id)?; let provider_id = account .auth_type .provider_id() .or_api_err(|| ApiError::bad_request("Account does not use OAuth"))?; // Start new OAuth flow start_oauth(state, provider_id.to_string()).await }