Skip to main content

coven_storage/
oauth.rs

1//! OAuth 2.0 helper for consumer cloud provider authentication.
2//!
3//! Provides PKCE-based authorization code flow with a localhost callback server.
4//! Used by Google Drive, Dropbox, and OneDrive cloud home backends.
5
6#[cfg(feature = "oauth-providers")]
7use std::collections::HashMap;
8
9#[cfg(any(test, feature = "oauth-providers"))]
10use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
11#[cfg(feature = "oauth-providers")]
12use rand::RngCore;
13#[cfg(any(test, feature = "oauth-providers"))]
14use serde::Deserialize;
15#[cfg(any(test, feature = "oauth-providers"))]
16use sha2::{Digest, Sha256};
17#[cfg(any(test, feature = "oauth-providers"))]
18use thiserror::Error;
19// `info`/`warn` are used only by the localhost-callback `authorize`, which is
20// gated on `oauth-providers` too.
21#[cfg(feature = "oauth-providers")]
22use tracing::{info, warn};
23
24/// OAuth provider configuration.
25#[cfg(feature = "oauth-providers")]
26#[derive(Clone, Debug)]
27pub struct OAuthConfig {
28    pub client_id: String,
29    /// None for public clients (PKCE-only, no client secret needed).
30    pub client_secret: Option<String>,
31    pub auth_url: String,
32    pub token_url: String,
33    pub scopes: Vec<String>,
34    /// Localhost callback port. Default: 19284.
35    pub redirect_port: u16,
36    /// Extra params appended to the authorization URL (e.g. Google's
37    /// `access_type=offline` or Dropbox's `token_access_type=offline`).
38    pub extra_auth_params: Vec<(String, String)>,
39}
40
41/// OAuth client credentials for one provider — the consuming app's registered
42/// OAuth application. coven ships no app credentials of its own.
43#[cfg(feature = "oauth-providers")]
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct OAuthClientCreds {
46    pub client_id: String,
47    /// None for public (PKCE-only) clients.
48    pub client_secret: Option<String>,
49}
50
51/// The consuming app's OAuth clients. Each Coven builder retains its own
52/// value, so unrelated apps in one process never share credentials.
53#[derive(Clone, Debug)]
54pub struct OAuthClients {
55    #[cfg(feature = "oauth-providers")]
56    credentials: HashMap<coven_foundation::config::CloudProvider, OAuthClientCreds>,
57    #[cfg(feature = "oauth-providers")]
58    client: reqwest::Client,
59}
60
61#[cfg(feature = "oauth-providers")]
62pub(crate) struct AuthorizedOAuthCloudHome {
63    pub(crate) tokens: OAuthTokens,
64    pub(crate) location: OAuthCloudHomeLocation,
65}
66
67#[cfg(feature = "oauth-providers")]
68pub(crate) enum OAuthCloudHomeLocation {
69    GoogleDrive { folder_id: String },
70    Dropbox { folder_path: String },
71    OneDrive { drive_id: String, folder_id: String },
72}
73
74/// An OAuth client set is missing a provider or names a provider that does not
75/// use OAuth.
76#[cfg(feature = "oauth-providers")]
77#[derive(Debug, thiserror::Error)]
78pub enum OAuthClientCredsError {
79    #[error("no OAuth client credentials configured for provider {0:?}")]
80    MissingProvider(coven_foundation::config::CloudProvider),
81    #[error("provider {0:?} does not use OAuth")]
82    UnsupportedProvider(coven_foundation::config::CloudProvider),
83}
84
85impl OAuthClients {
86    /// Construct the OAuth clients this app can use.
87    #[cfg(feature = "oauth-providers")]
88    pub fn new(
89        credentials: HashMap<coven_foundation::config::CloudProvider, OAuthClientCreds>,
90    ) -> Result<Self, OAuthClientCredsError> {
91        if let Some(provider) = credentials.keys().find(|provider| !provider.needs_oauth()) {
92            return Err(OAuthClientCredsError::UnsupportedProvider(
93                (*provider).clone(),
94            ));
95        }
96        Ok(Self {
97            credentials,
98            client: reqwest::Client::new(),
99        })
100    }
101
102    /// No OAuth providers configured. Suitable for apps using only S3,
103    /// CloudKit, or local storage.
104    pub fn empty() -> Self {
105        Self {
106            #[cfg(feature = "oauth-providers")]
107            credentials: HashMap::new(),
108            #[cfg(feature = "oauth-providers")]
109            client: reqwest::Client::new(),
110        }
111    }
112
113    #[cfg(feature = "oauth-providers")]
114    fn credentials_for(
115        &self,
116        provider: &coven_foundation::config::CloudProvider,
117    ) -> Result<OAuthClientCreds, OAuthClientCredsError> {
118        if !provider.needs_oauth() {
119            return Err(OAuthClientCredsError::UnsupportedProvider(provider.clone()));
120        }
121        self.credentials
122            .get(provider)
123            .cloned()
124            .ok_or_else(|| OAuthClientCredsError::MissingProvider(provider.clone()))
125    }
126
127    #[cfg(feature = "oauth-providers")]
128    pub fn config_for(
129        &self,
130        provider: coven_foundation::config::CloudProvider,
131    ) -> Result<OAuthConfig, OAuthClientCredsError> {
132        use crate::cloud::{dropbox, google_drive, onedrive};
133        use coven_foundation::config::CloudProvider;
134
135        let credentials = self.credentials_for(&provider)?;
136        match provider {
137            CloudProvider::GoogleDrive => Ok(google_drive::GoogleDriveCloudHome::oauth_config(
138                credentials,
139            )),
140            CloudProvider::Dropbox => Ok(dropbox::DropboxCloudHome::oauth_config(credentials)),
141            CloudProvider::OneDrive => Ok(onedrive::OneDriveCloudHome::oauth_config(credentials)),
142            provider => Err(OAuthClientCredsError::UnsupportedProvider(provider)),
143        }
144    }
145
146    #[cfg(all(any(test, feature = "test-utils"), feature = "oauth-providers"))]
147    pub fn for_tests() -> Self {
148        Self::new(HashMap::from([
149            (
150                coven_foundation::config::CloudProvider::GoogleDrive,
151                OAuthClientCreds {
152                    client_id: "test-client".to_string(),
153                    client_secret: None,
154                },
155            ),
156            (
157                coven_foundation::config::CloudProvider::Dropbox,
158                OAuthClientCreds {
159                    client_id: "test-client".to_string(),
160                    client_secret: None,
161                },
162            ),
163            (
164                coven_foundation::config::CloudProvider::OneDrive,
165                OAuthClientCreds {
166                    client_id: "test-client".to_string(),
167                    client_secret: None,
168                },
169            ),
170        ]))
171        .expect("test clients contain only OAuth providers")
172    }
173
174    #[cfg(feature = "oauth-providers")]
175    pub async fn authorize(
176        &self,
177        provider: coven_foundation::config::CloudProvider,
178        cancel: tokio::sync::watch::Receiver<bool>,
179        clock: &dyn coven_foundation::clock::Clock,
180    ) -> Result<OAuthTokens, OAuthError> {
181        let config = self.config_for(provider)?;
182        let client = &self.client;
183        let redirect_uri = format!("http://localhost:{}/callback", config.redirect_port);
184        let mut entropy = [0_u8; 64];
185        rand::rng().fill_bytes(&mut entropy);
186        let AuthorizeRequest {
187            auth_url,
188            verifier,
189            state,
190        } = AuthorizeRequest::from_entropy(&config, &redirect_uri, entropy)?;
191
192        // Channel to receive the authorization code from the callback handler
193        let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, OAuthCallbackError>>();
194        let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx)));
195
196        let tx_for_handler = tx.clone();
197        let expected_state = state.clone();
198        let app = axum::Router::new().route(
199        "/callback",
200        axum::routing::get(
201            move |axum::extract::Query(params): axum::extract::Query<
202                std::collections::HashMap<String, String>,
203            >| {
204                let tx = tx_for_handler.clone();
205                let expected_state = expected_state.clone();
206                async move {
207                    let mut guard = tx.lock().await;
208                    let callback =
209                        verify_callback_state(params.get("state").map(String::as_str), &expected_state)
210                            .and_then(|()| callback_code(&params));
211                    let is_error = callback.is_err();
212                    if let Some(sender) = guard.take() {
213                        if sender.send(callback).is_err() {
214                            warn!("OAuth callback receiver dropped before result delivery");
215                        }
216                    }
217                    let html = if is_error {
218                        oauth_callback_html(
219                            "Authorization denied",
220                            "Authorization was denied. You can close this window and try again in the app.",
221                        )
222                    } else {
223                        oauth_callback_html(
224                            "Authorization complete",
225                            "You can close this window and return to the app.",
226                        )
227                    };
228                    (
229                        [
230                            (axum::http::header::CACHE_CONTROL, "no-store"),
231                            (axum::http::header::CONNECTION, "close"),
232                        ],
233                        axum::response::Html(html),
234                    )
235                }
236            },
237        ),
238    );
239
240        let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", config.redirect_port))
241            .await
242            .map_err(OAuthError::ServerBind)?;
243
244        // Spawn the server. The guard aborts the task on drop so future
245        // cancellation (parent .await dropped) tears the listener down too.
246        let server_guard = AbortOnDrop::new(tokio::spawn(async move {
247            if let Err(e) = axum::serve(listener, app)
248                .with_graceful_shutdown(async {
249                    tokio::time::sleep(std::time::Duration::from_secs(300)).await;
250                })
251                .await
252            {
253                warn!("OAuth callback server exited with error: {e}");
254            }
255        }));
256
257        // Open the browser
258        open::that(&auth_url).map_err(OAuthError::BrowserOpen)?;
259
260        info!("Opened browser for OAuth authorization, waiting for callback");
261
262        // Wait for the callback, cancellation, or timeout
263        let mut cancel = cancel;
264        let result = tokio::select! {
265            result = rx => {
266                result
267                    .map_err(OAuthError::CallbackChannel)
268                    .and_then(|r| r.map_err(OAuthError::Denied))
269            }
270            _ = cancel.wait_for(|&v| v) => {
271                Err(OAuthError::Denied(OAuthCallbackError::Cancelled))
272            }
273            _ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
274                Err(OAuthError::Timeout)
275            }
276        };
277
278        // Disarm the abort-on-drop and await the listener task's termination
279        // briefly. Bounded timeout because awaiting the abort could otherwise
280        // deadlock on a small thread pool with no idle worker. This matters
281        // for back-to-back sign-in flows: without the wait, the next bind on
282        // the same port can race the not-yet-released listener (no SO_REUSEADDR).
283        if let Some(handle) = server_guard.take_handle() {
284            handle.abort();
285            match tokio::time::timeout(std::time::Duration::from_millis(500), handle).await {
286                Ok(Ok(())) => {}
287                Ok(Err(e)) if e.is_cancelled() => {}
288                Ok(Err(e)) => {
289                    warn!("OAuth callback server task panicked on shutdown: {e}");
290                }
291                Err(_) => {
292                    warn!(
293                        "OAuth callback server did not exit within 500ms; \
294                     port {} may briefly remain in use",
295                        config.redirect_port
296                    );
297                }
298            }
299        }
300
301        let code = result?;
302
303        info!("Received authorization code, exchanging for tokens");
304
305        // Exchange the code for tokens
306        exchange_code(client, &config, &code, &verifier, &redirect_uri, clock).await
307    }
308
309    #[cfg(feature = "oauth-providers")]
310    /// Build an authorization request for a host-managed redirect flow.
311    pub fn build_authorize_request(
312        &self,
313        provider: coven_foundation::config::CloudProvider,
314        redirect_uri: &str,
315    ) -> Result<AuthorizeRequest, OAuthError> {
316        let mut entropy = [0_u8; 64];
317        rand::rng().fill_bytes(&mut entropy);
318        AuthorizeRequest::from_entropy(&self.config_for(provider)?, redirect_uri, entropy)
319    }
320
321    #[cfg(feature = "oauth-providers")]
322    /// Exchange the result of [`Self::build_authorize_request`] for tokens.
323    pub async fn exchange_code(
324        &self,
325        provider: coven_foundation::config::CloudProvider,
326        code: &str,
327        callback_state: Option<&str>,
328        request: &AuthorizeRequest,
329        redirect_uri: &str,
330        clock: &dyn coven_foundation::clock::Clock,
331    ) -> Result<OAuthTokens, OAuthError> {
332        request.verify_callback_state(callback_state)?;
333        exchange_code(
334            &self.client,
335            &self.config_for(provider)?,
336            code,
337            &request.verifier,
338            redirect_uri,
339            clock,
340        )
341        .await
342    }
343
344    #[cfg(feature = "oauth-providers")]
345    /// Authorize Google Drive and prepare this store's folder.
346    pub(crate) async fn prepare_google_drive(
347        &self,
348        store_name: &str,
349        cancel: tokio::sync::watch::Receiver<bool>,
350        clock: &dyn coven_foundation::clock::Clock,
351    ) -> Result<AuthorizedOAuthCloudHome, crate::cloud::SetupError> {
352        let tokens = self
353            .authorize(
354                coven_foundation::config::CloudProvider::GoogleDrive,
355                cancel,
356                clock,
357            )
358            .await
359            .map_err(|source| crate::cloud::SetupError::authorization("Google Drive", source))?;
360        let folder_name = format!("your-app - {store_name}");
361        let search_query = crate::cloud::folder_search_query(&folder_name);
362        let search_resp = crate::cloud::supports_all_drives(
363            self.client.get("https://www.googleapis.com/drive/v3/files"),
364        )
365        .bearer_auth(&tokens.access_token)
366        .query(&[
367            ("q", search_query.as_str()),
368            ("fields", "files(id)"),
369            ("includeItemsFromAllDrives", "true"),
370        ])
371        .send()
372        .await
373        .map_err(|source| {
374            crate::cloud::SetupError::http("search for existing Google Drive folder", source)
375        })?;
376        if !search_resp.status().is_success() {
377            let status = search_resp.status();
378            let body = search_resp.text().await.map_err(|source| {
379                crate::cloud::SetupError::http("read Google Drive folder search error", source)
380            })?;
381            return Err(crate::cloud::SetupError::provider_response(
382                "search for existing Google Drive folder",
383                status,
384                body,
385            ));
386        }
387        let search_json: serde_json::Value = search_resp.json().await.map_err(|source| {
388            crate::cloud::SetupError::http("parse Google Drive search response", source)
389        })?;
390        let existing_folder_id = search_json["files"][0]["id"].as_str().map(str::to_string);
391        let folder_id = if let Some(id) = existing_folder_id {
392            id
393        } else {
394            let create_body = serde_json::json!({
395                "name": folder_name,
396                "mimeType": "application/vnd.google-apps.folder",
397            });
398            let response = crate::cloud::supports_all_drives(
399                self.client
400                    .post("https://www.googleapis.com/drive/v3/files"),
401            )
402            .bearer_auth(&tokens.access_token)
403            .json(&create_body)
404            .send()
405            .await
406            .map_err(|source| {
407                crate::cloud::SetupError::http("create Google Drive folder", source)
408            })?;
409            if !response.status().is_success() {
410                let status = response.status();
411                let body = response.text().await.map_err(|source| {
412                    crate::cloud::SetupError::http(
413                        "read Google Drive folder creation error",
414                        source,
415                    )
416                })?;
417                return Err(crate::cloud::SetupError::provider_response(
418                    "create Google Drive folder",
419                    status,
420                    body,
421                ));
422            }
423            let folder: serde_json::Value = response.json().await.map_err(|source| {
424                crate::cloud::SetupError::http("parse Google Drive folder response", source)
425            })?;
426            folder["id"]
427                .as_str()
428                .ok_or_else(|| {
429                    crate::cloud::SetupError::Configuration(
430                        "Google Drive folder response missing 'id'".to_string(),
431                    )
432                })?
433                .to_string()
434        };
435        info!("Authorized Google Drive; folder ready");
436        Ok(AuthorizedOAuthCloudHome {
437            tokens,
438            location: OAuthCloudHomeLocation::GoogleDrive { folder_id },
439        })
440    }
441
442    #[cfg(feature = "oauth-providers")]
443    /// Authorize Dropbox and prepare this store's folder.
444    pub(crate) async fn prepare_dropbox(
445        &self,
446        store_name: &str,
447        cancel: tokio::sync::watch::Receiver<bool>,
448        clock: &dyn coven_foundation::clock::Clock,
449    ) -> Result<AuthorizedOAuthCloudHome, crate::cloud::SetupError> {
450        let tokens = self
451            .authorize(
452                coven_foundation::config::CloudProvider::Dropbox,
453                cancel,
454                clock,
455            )
456            .await
457            .map_err(|source| crate::cloud::SetupError::authorization("Dropbox", source))?;
458        let folder_path = format!("/Apps/your-app/{store_name}");
459        let response = self
460            .client
461            .post("https://api.dropboxapi.com/2/files/create_folder_v2")
462            .bearer_auth(&tokens.access_token)
463            .json(&serde_json::json!({
464                "path": folder_path,
465                "autorename": false,
466            }))
467            .send()
468            .await
469            .map_err(|source| crate::cloud::SetupError::http("create Dropbox folder", source))?;
470        let status = response.status();
471        if !status.is_success() {
472            let body = response.text().await.map_err(|source| {
473                crate::cloud::SetupError::http("read Dropbox folder creation error", source)
474            })?;
475            if !(status == reqwest::StatusCode::CONFLICT && body.contains("conflict")) {
476                return Err(crate::cloud::SetupError::provider_response(
477                    "create Dropbox folder",
478                    status,
479                    body,
480                ));
481            }
482        }
483        info!("Authorized Dropbox; folder ready");
484        Ok(AuthorizedOAuthCloudHome {
485            tokens,
486            location: OAuthCloudHomeLocation::Dropbox { folder_path },
487        })
488    }
489
490    #[cfg(feature = "oauth-providers")]
491    /// Authorize OneDrive and prepare this store's folder.
492    pub(crate) async fn prepare_onedrive(
493        &self,
494        cancel: tokio::sync::watch::Receiver<bool>,
495        clock: &dyn coven_foundation::clock::Clock,
496    ) -> Result<AuthorizedOAuthCloudHome, crate::cloud::SetupError> {
497        let tokens = self
498            .authorize(
499                coven_foundation::config::CloudProvider::OneDrive,
500                cancel,
501                clock,
502            )
503            .await
504            .map_err(|source| crate::cloud::SetupError::authorization("OneDrive", source))?;
505        let drive_response = self
506            .client
507            .get("https://graph.microsoft.com/v1.0/me/drive")
508            .bearer_auth(&tokens.access_token)
509            .send()
510            .await
511            .map_err(|source| crate::cloud::SetupError::http("get OneDrive info", source))?;
512        if !drive_response.status().is_success() {
513            let status = drive_response.status();
514            let body = drive_response.text().await.map_err(|source| {
515                crate::cloud::SetupError::http("read OneDrive info error", source)
516            })?;
517            return Err(crate::cloud::SetupError::provider_response(
518                "get OneDrive info",
519                status,
520                body,
521            ));
522        }
523        let drive: serde_json::Value = drive_response
524            .json()
525            .await
526            .map_err(|source| crate::cloud::SetupError::http("parse OneDrive response", source))?;
527        let drive_id = drive["id"]
528            .as_str()
529            .ok_or_else(|| {
530                crate::cloud::SetupError::Configuration(
531                    "OneDrive response missing 'id' field".to_string(),
532                )
533            })?
534            .to_string();
535        let folder_response = self
536            .client
537            .post(format!(
538                "https://graph.microsoft.com/v1.0/drives/{drive_id}/root/children"
539            ))
540            .bearer_auth(&tokens.access_token)
541            .json(&serde_json::json!({
542                "name": "your-app",
543                "folder": {},
544                "@microsoft.graph.conflictBehavior": "useExisting",
545            }))
546            .send()
547            .await
548            .map_err(|source| crate::cloud::SetupError::http("create OneDrive folder", source))?;
549        if !folder_response.status().is_success() {
550            let status = folder_response.status();
551            let body = folder_response.text().await.map_err(|source| {
552                crate::cloud::SetupError::http("read OneDrive folder creation error", source)
553            })?;
554            return Err(crate::cloud::SetupError::provider_response(
555                "create OneDrive folder",
556                status,
557                body,
558            ));
559        }
560        let folder: serde_json::Value = folder_response.json().await.map_err(|source| {
561            crate::cloud::SetupError::http("parse OneDrive folder response", source)
562        })?;
563        let folder_id = folder["id"]
564            .as_str()
565            .ok_or_else(|| {
566                crate::cloud::SetupError::Configuration(
567                    "OneDrive folder response missing 'id' field".to_string(),
568                )
569            })?
570            .to_string();
571        info!("Authorized OneDrive; folder ready");
572        Ok(AuthorizedOAuthCloudHome {
573            tokens,
574            location: OAuthCloudHomeLocation::OneDrive {
575                drive_id,
576                folder_id,
577            },
578        })
579    }
580}
581
582pub use coven_keys::keys::OAuthTokens;
583
584#[cfg(any(test, feature = "oauth-providers"))]
585#[derive(Error, Debug)]
586pub enum OAuthError {
587    #[cfg(feature = "oauth-providers")]
588    #[error("failed to open browser: {0}")]
589    BrowserOpen(#[source] std::io::Error),
590    #[cfg(feature = "oauth-providers")]
591    #[error("callback server could not bind: {0}")]
592    ServerBind(#[source] std::io::Error),
593    #[cfg(feature = "oauth-providers")]
594    #[error("callback channel closed: {0}")]
595    CallbackChannel(#[source] tokio::sync::oneshot::error::RecvError),
596    #[cfg(feature = "oauth-providers")]
597    #[error("OAuth request parameters could not be encoded: {0}")]
598    EncodeParameters(#[source] serde_urlencoded::ser::Error),
599    #[error("token request failed while {operation}: {source}")]
600    TokenRequest {
601        operation: &'static str,
602        #[source]
603        source: reqwest::Error,
604    },
605    #[error("parse token response (HTTP {status}): {source}")]
606    TokenResponseJson {
607        status: reqwest::StatusCode,
608        #[source]
609        source: serde_json::Error,
610    },
611    #[error("token exchange error: {0}")]
612    TokenExchange(String),
613    #[cfg(feature = "oauth-providers")]
614    #[error("account email fetch error: {0}")]
615    AccountFetch(#[source] crate::cloud::CloudHomeError),
616    #[cfg(feature = "oauth-providers")]
617    #[error("provider {0:?} does not use OAuth")]
618    UnsupportedProvider(coven_foundation::config::CloudProvider),
619    #[cfg(feature = "oauth-providers")]
620    #[error("authorization denied: {0}")]
621    Denied(#[source] OAuthCallbackError),
622    #[cfg(feature = "oauth-providers")]
623    #[error("timeout waiting for authorization callback")]
624    Timeout,
625    /// The refresh token is no longer accepted (revoked, expired, password
626    /// changed, …). Only a fresh OAuth authorization flow recovers — there
627    /// is no point retrying the refresh.
628    #[error("re-authorization required: {0}")]
629    Reauthorize(String),
630    #[cfg(feature = "oauth-providers")]
631    #[error(transparent)]
632    ClientCreds(#[from] OAuthClientCredsError),
633}
634
635#[cfg(feature = "oauth-providers")]
636#[derive(Debug, thiserror::Error, PartialEq, Eq)]
637pub enum OAuthCallbackError {
638    #[error("provider denied authorization: {0}")]
639    ProviderDenied(String),
640    #[error("authorization callback did not contain a code")]
641    MissingCode,
642    #[error("authorization callback state did not match the request")]
643    StateMismatch,
644    #[error("authorization callback omitted its state")]
645    MissingState,
646    #[error("authorization was cancelled")]
647    Cancelled,
648}
649
650/// Guards the localhost OAuth-callback server task.
651/// [`OAuthClients::authorize`] is the only operation that spawns it; both are
652/// gated on `oauth-providers`.
653#[cfg(feature = "oauth-providers")]
654struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
655
656#[cfg(feature = "oauth-providers")]
657impl AbortOnDrop {
658    fn new(handle: tokio::task::JoinHandle<()>) -> Self {
659        Self(Some(handle))
660    }
661
662    /// Take the join handle for the success path, where the caller wants to
663    /// await its termination (e.g. with a timeout) so the listener's port is
664    /// released before another flow tries to bind it. Disarms the Drop.
665    fn take_handle(mut self) -> Option<tokio::task::JoinHandle<()>> {
666        self.0.take()
667    }
668}
669
670#[cfg(feature = "oauth-providers")]
671impl Drop for AbortOnDrop {
672    fn drop(&mut self) {
673        if let Some(h) = self.0.take() {
674            h.abort();
675        }
676    }
677}
678
679/// Token response from the OAuth provider (internal deserialization).
680///
681/// `access_token` is optional because error responses (`{"error": "invalid_grant", …}`)
682/// omit it — making it required forces parsing to fail before the typed
683/// error branch can classify the failure, surfacing every provider error as
684/// "parse response: missing field `access_token`".
685#[cfg(any(test, feature = "oauth-providers"))]
686#[derive(Deserialize)]
687struct TokenResponse {
688    access_token: Option<String>,
689    refresh_token: Option<String>,
690    expires_in: Option<i64>,
691    error: Option<String>,
692    error_description: Option<String>,
693}
694
695#[cfg(any(test, feature = "oauth-providers"))]
696impl TokenResponse {
697    /// Convert a parsed response into typed `OAuthTokens`, classifying the
698    /// failure modes both exchange and refresh share: `invalid_grant` /
699    /// `unauthorized_client` (the refresh-token-no-longer-accepted family)
700    /// become `OAuthError::Reauthorize`; other provider errors become
701    /// `TokenExchange`; a missing `access_token` on a non-error response is
702    /// reported as a malformed success.
703    fn into_tokens(
704        self,
705        status: reqwest::StatusCode,
706        clock: &dyn coven_foundation::clock::Clock,
707    ) -> Result<OAuthTokens, OAuthError> {
708        if let Some(error) = self.error {
709            let detail = match self.error_description.as_deref() {
710                Some(d) => format!("{error}: {d}"),
711                None => error.clone(),
712            };
713            if matches!(error.as_str(), "invalid_grant" | "unauthorized_client") {
714                return Err(OAuthError::Reauthorize(detail));
715            }
716            return Err(OAuthError::TokenExchange(format!(
717                "provider error (HTTP {status}): {detail}"
718            )));
719        }
720
721        let access_token = self.access_token.ok_or_else(|| {
722            OAuthError::TokenExchange(format!(
723                "provider response missing access_token (HTTP {status})"
724            ))
725        })?;
726
727        let expires_at = self.expires_in.map(|secs| clock.now().timestamp() + secs);
728
729        Ok(OAuthTokens {
730            access_token,
731            refresh_token: self.refresh_token,
732            expires_at,
733        })
734    }
735}
736
737#[cfg(feature = "oauth-providers")]
738fn oauth_callback_html(title: &str, message: &str) -> String {
739    include_str!("oauth_success.html")
740        .replace("{{title}}", title)
741        .replace("{{message}}", message)
742}
743
744#[cfg(feature = "oauth-providers")]
745async fn post_token_request(
746    client: &reqwest::Client,
747    config: &OAuthConfig,
748    params: Vec<(&str, String)>,
749    clock: &dyn coven_foundation::clock::Clock,
750) -> Result<OAuthTokens, OAuthError> {
751    let resp = client
752        .post(&config.token_url)
753        .form(&params)
754        .send()
755        .await
756        .map_err(|source| OAuthError::TokenRequest {
757            operation: "send token request",
758            source,
759        })?;
760
761    let status = resp.status();
762    let body = resp
763        .text()
764        .await
765        .map_err(|source| OAuthError::TokenRequest {
766            operation: "read token response body",
767            source,
768        })?;
769
770    let token_resp: TokenResponse = serde_json::from_str(&body)
771        .map_err(|source| OAuthError::TokenResponseJson { status, source })?;
772
773    token_resp.into_tokens(status, clock)
774}
775
776/// Compute the S256 PKCE code challenge from a verifier.
777#[cfg(any(test, feature = "oauth-providers"))]
778pub fn code_challenge(verifier: &str) -> String {
779    let hash = Sha256::digest(verifier.as_bytes());
780    URL_SAFE_NO_PAD.encode(hash)
781}
782
783/// An authorization request the host drives itself: the URL to open plus the
784/// PKCE verifier and state value coven checks during exchange. For hosts that
785/// capture the redirect outside coven's localhost callback server — e.g. a
786/// mobile OS auth session (ASWebAuthenticationSession / Custom Tabs)
787/// redirecting to a custom URI scheme, where binding a localhost port and
788/// `open::that` don't apply.
789#[cfg(feature = "oauth-providers")]
790#[derive(Clone, Debug)]
791pub struct AuthorizeRequest {
792    pub auth_url: String,
793    verifier: String,
794    state: String,
795}
796
797#[cfg(feature = "oauth-providers")]
798impl AuthorizeRequest {
799    fn from_entropy(
800        config: &OAuthConfig,
801        redirect_uri: &str,
802        entropy: [u8; 64],
803    ) -> Result<Self, OAuthError> {
804        let verifier = URL_SAFE_NO_PAD.encode(&entropy[..32]);
805        let state = URL_SAFE_NO_PAD.encode(&entropy[32..]);
806        let challenge = code_challenge(&verifier);
807
808        let mut auth_params = vec![
809            ("response_type", "code".to_string()),
810            ("client_id", config.client_id.clone()),
811            ("redirect_uri", redirect_uri.to_string()),
812            ("code_challenge", challenge),
813            ("code_challenge_method", "S256".to_string()),
814            ("state", state.clone()),
815        ];
816
817        for (key, value) in &config.extra_auth_params {
818            auth_params.push((key.as_str(), value.clone()));
819        }
820
821        if !config.scopes.is_empty() {
822            auth_params.push(("scope", config.scopes.join(" ")));
823        }
824
825        let auth_url = format!(
826            "{}?{}",
827            config.auth_url,
828            serde_urlencoded::to_string(&auth_params).map_err(OAuthError::EncodeParameters)?
829        );
830
831        Ok(Self {
832            auth_url,
833            verifier,
834            state,
835        })
836    }
837
838    pub fn verify_callback_state(&self, callback_state: Option<&str>) -> Result<(), OAuthError> {
839        verify_callback_state(callback_state, &self.state).map_err(OAuthError::Denied)
840    }
841}
842
843#[cfg(feature = "oauth-providers")]
844fn callback_code(
845    params: &std::collections::HashMap<String, String>,
846) -> Result<String, OAuthCallbackError> {
847    if let Some(error) = params.get("error") {
848        let desc = match params.get("error_description") {
849            Some(desc) => desc.clone(),
850            None => {
851                warn!("OAuth callback error omitted error_description: {error}");
852                error.clone()
853            }
854        };
855        Err(OAuthCallbackError::ProviderDenied(desc))
856    } else if let Some(code) = params.get("code") {
857        Ok(code.clone())
858    } else {
859        Err(OAuthCallbackError::MissingCode)
860    }
861}
862
863#[cfg(feature = "oauth-providers")]
864fn verify_callback_state(
865    callback_state: Option<&str>,
866    expected_state: &str,
867) -> Result<(), OAuthCallbackError> {
868    match callback_state {
869        Some(state) if state == expected_state => Ok(()),
870        Some(_) => Err(OAuthCallbackError::StateMismatch),
871        None => Err(OAuthCallbackError::MissingState),
872    }
873}
874
875#[cfg(feature = "oauth-providers")]
876async fn exchange_code(
877    client: &reqwest::Client,
878    config: &OAuthConfig,
879    code: &str,
880    verifier: &str,
881    redirect_uri: &str,
882    clock: &dyn coven_foundation::clock::Clock,
883) -> Result<OAuthTokens, OAuthError> {
884    let mut params = vec![
885        ("grant_type", "authorization_code".to_string()),
886        ("code", code.to_string()),
887        ("redirect_uri", redirect_uri.to_string()),
888        ("client_id", config.client_id.clone()),
889        ("code_verifier", verifier.to_string()),
890    ];
891    if let Some(secret) = &config.client_secret {
892        params.push(("client_secret", secret.clone()));
893    }
894
895    post_token_request(client, config, params, clock).await
896}
897
898/// Refresh an expired access token using a refresh token.
899#[cfg(feature = "oauth-providers")]
900pub async fn refresh(
901    client: &reqwest::Client,
902    config: &OAuthConfig,
903    refresh_token: &str,
904    clock: &dyn coven_foundation::clock::Clock,
905) -> Result<OAuthTokens, OAuthError> {
906    let mut params = vec![
907        ("grant_type", "refresh_token".to_string()),
908        ("refresh_token", refresh_token.to_string()),
909        ("client_id", config.client_id.clone()),
910    ];
911    if let Some(secret) = &config.client_secret {
912        params.push(("client_secret", secret.clone()));
913    }
914
915    let mut tokens = post_token_request(client, config, params, clock).await?;
916    // Provider didn't return a new refresh token (common — many providers
917    // only rotate it on the initial exchange). Reuse the existing one so the
918    // session can refresh again next cycle.
919    if tokens.refresh_token.is_none() {
920        tracing::debug!("provider did not return a new refresh_token; reusing existing token");
921        tokens.refresh_token = Some(refresh_token.to_string());
922    }
923    Ok(tokens)
924}
925
926#[cfg(all(test, feature = "oauth-providers"))]
927#[path = "oauth/test_support.rs"]
928pub(crate) mod test_support;
929
930#[cfg(test)]
931#[path = "oauth_tests.rs"]
932mod tests;