Skip to main content

coven/
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
6use std::collections::HashMap;
7use std::sync::OnceLock;
8
9#[cfg(any(test, feature = "oauth-providers"))]
10use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
11#[cfg(any(test, feature = "oauth-providers"))]
12use rand::RngCore;
13use serde::{Deserialize, Serialize};
14#[cfg(any(test, feature = "oauth-providers"))]
15use sha2::{Digest, Sha256};
16#[cfg(any(test, feature = "oauth-providers"))]
17use thiserror::Error;
18// `info`/`warn` are used only by the localhost-callback `authorize`, which is
19// gated on `oauth-providers` too.
20#[cfg(feature = "oauth-providers")]
21use tracing::{info, warn};
22
23/// OAuth provider configuration.
24#[cfg(feature = "oauth-providers")]
25#[derive(Clone, Debug)]
26pub(crate) struct OAuthConfig {
27    pub client_id: String,
28    /// None for public clients (PKCE-only, no client secret needed).
29    pub client_secret: Option<String>,
30    pub auth_url: String,
31    pub token_url: String,
32    pub scopes: Vec<String>,
33    /// Localhost callback port. Default: 19284.
34    pub redirect_port: u16,
35    /// Extra params appended to the authorization URL (e.g. Google's
36    /// `access_type=offline` or Dropbox's `token_access_type=offline`).
37    pub extra_auth_params: Vec<(String, String)>,
38}
39
40/// OAuth client credentials for one provider — the consuming app's registered
41/// OAuth application. coven ships no app credentials of its own; the host
42/// registers them at startup via [`set_oauth_client_creds`].
43#[derive(Clone, Debug, Default, PartialEq, Eq)]
44pub struct OAuthClientCreds {
45    pub client_id: String,
46    /// None for public (PKCE-only) clients.
47    pub client_secret: Option<String>,
48}
49
50/// Registering OAuth client credentials a second time with values that differ
51/// from the first registration — a startup contradiction the host must resolve,
52/// not a value coven may silently pick between.
53#[derive(Debug, thiserror::Error)]
54#[error(
55    "OAuth client credentials are already registered; cannot re-register with different values"
56)]
57pub struct OAuthClientCredsConflict;
58
59static OAUTH_CLIENT_CREDS: OnceLock<HashMap<String, OAuthClientCreds>> = OnceLock::new();
60
61/// Register the host's OAuth client credentials, keyed by provider name
62/// (`"google_drive"`, `"dropbox"`, `"onedrive"`). Call once at startup, before
63/// any OAuth flow. Providers absent from the map get empty credentials.
64/// Re-registering the same map is a no-op; a differing map is a startup
65/// contradiction and returns [`OAuthClientCredsConflict`].
66pub fn set_oauth_client_creds(
67    creds: HashMap<String, OAuthClientCreds>,
68) -> Result<(), OAuthClientCredsConflict> {
69    if let Err(attempted) = OAUTH_CLIENT_CREDS.set(creds) {
70        let registered = OAUTH_CLIENT_CREDS
71            .get()
72            .expect("OnceLock::set failed only when a value is already stored");
73        if *registered != attempted {
74            return Err(OAuthClientCredsConflict);
75        }
76    }
77    Ok(())
78}
79
80/// A provider's OAuth client credentials were requested before the host
81/// registered them via [`set_oauth_client_creds`]: either no registration ran at
82/// all, or the registered map has no entry for this provider. Surfaced at the
83/// OAuth-flow boundary so a mis-configured host gets a typed error naming the
84/// startup step, not an empty `client_id` that fails deep inside a provider flow.
85#[cfg(feature = "oauth-providers")]
86#[derive(Debug, thiserror::Error)]
87pub enum OAuthClientCredsError {
88    #[error(
89        "no OAuth client credentials are registered; the host must call set_oauth_client_creds at startup before any OAuth flow"
90    )]
91    NotRegistered,
92    #[error(
93        "no OAuth client credentials registered for provider {0:?}; the host must include it in the set_oauth_client_creds map at startup"
94    )]
95    MissingProvider(String),
96}
97
98/// The credentials registered for a provider. `Err` when the host never ran
99/// [`set_oauth_client_creds`], or ran it without an entry for this provider.
100#[cfg(feature = "oauth-providers")]
101pub(crate) fn oauth_client_creds(
102    provider: &str,
103) -> Result<OAuthClientCreds, OAuthClientCredsError> {
104    let registered = OAUTH_CLIENT_CREDS
105        .get()
106        .ok_or(OAuthClientCredsError::NotRegistered)?;
107    registered
108        .get(provider)
109        .cloned()
110        .ok_or_else(|| OAuthClientCredsError::MissingProvider(provider.to_string()))
111}
112
113/// Register a consistent set of client credentials for the OAuth provider
114/// backends' tests, which construct provider homes and so read creds. Idempotent
115/// — every caller registers the same map, so the process-global `OnceLock` is set
116/// once and later calls are no-ops.
117#[cfg(all(test, feature = "oauth-providers"))]
118pub(crate) fn install_test_client_creds() {
119    let creds = HashMap::from([
120        (
121            "google_drive".to_string(),
122            OAuthClientCreds {
123                client_id: "test-client".to_string(),
124                client_secret: None,
125            },
126        ),
127        (
128            "dropbox".to_string(),
129            OAuthClientCreds {
130                client_id: "test-client".to_string(),
131                client_secret: None,
132            },
133        ),
134        (
135            "onedrive".to_string(),
136            OAuthClientCreds {
137                client_id: "test-client".to_string(),
138                client_secret: None,
139            },
140        ),
141    ]);
142    set_oauth_client_creds(creds).expect("register consistent test client credentials");
143}
144
145/// Tokens returned from an OAuth authorization or refresh.
146///
147/// `Debug` is hand-written: `access_token` and `refresh_token` are bearer
148/// credentials and print as `<redacted>` so `{:?}` in an error path cannot
149/// leak them.
150#[derive(Clone, Serialize, Deserialize)]
151pub struct OAuthTokens {
152    pub access_token: String,
153    pub refresh_token: Option<String>,
154    /// Unix timestamp when the access token expires. None if unknown.
155    pub expires_at: Option<i64>,
156}
157
158impl std::fmt::Debug for OAuthTokens {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("OAuthTokens")
161            .field("access_token", &"<redacted>")
162            // Presence (whether the session can refresh) is observable; the
163            // token itself is redacted.
164            .field(
165                "refresh_token",
166                &self.refresh_token.as_ref().map(|_| "<redacted>"),
167            )
168            .field("expires_at", &self.expires_at)
169            .finish()
170    }
171}
172
173#[cfg(any(test, feature = "oauth-providers"))]
174#[derive(Error, Debug)]
175pub enum OAuthError {
176    #[cfg(feature = "oauth-providers")]
177    #[error("failed to open browser: {0}")]
178    BrowserOpen(String),
179    #[cfg(feature = "oauth-providers")]
180    #[error("callback server error: {0}")]
181    Server(String),
182    #[error("token exchange error: {0}")]
183    TokenExchange(String),
184    #[cfg(feature = "oauth-providers")]
185    #[error("account email fetch error: {0}")]
186    AccountFetch(String),
187    #[cfg(feature = "oauth-providers")]
188    #[error("authorization denied: {0}")]
189    Denied(String),
190    #[cfg(feature = "oauth-providers")]
191    #[error("timeout waiting for authorization callback")]
192    Timeout,
193    /// The refresh token is no longer accepted (revoked, expired, password
194    /// changed, …). Only a fresh OAuth authorization flow recovers — there
195    /// is no point retrying the refresh.
196    #[error("re-authorization required: {0}")]
197    Reauthorize(String),
198    #[cfg(feature = "oauth-providers")]
199    #[error(transparent)]
200    ClientCreds(#[from] OAuthClientCredsError),
201}
202
203/// Guards the localhost OAuth-callback server task. [`authorize`] is the only
204/// function that spawns it; both are gated on `oauth-providers`.
205#[cfg(feature = "oauth-providers")]
206struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
207
208#[cfg(feature = "oauth-providers")]
209impl AbortOnDrop {
210    fn new(handle: tokio::task::JoinHandle<()>) -> Self {
211        Self(Some(handle))
212    }
213
214    /// Take the join handle for the success path, where the caller wants to
215    /// await its termination (e.g. with a timeout) so the listener's port is
216    /// released before another flow tries to bind it. Disarms the Drop.
217    fn take_handle(mut self) -> Option<tokio::task::JoinHandle<()>> {
218        self.0.take()
219    }
220}
221
222#[cfg(feature = "oauth-providers")]
223impl Drop for AbortOnDrop {
224    fn drop(&mut self) {
225        if let Some(h) = self.0.take() {
226            h.abort();
227        }
228    }
229}
230
231/// Token response from the OAuth provider (internal deserialization).
232///
233/// `access_token` is optional because error responses (`{"error": "invalid_grant", …}`)
234/// omit it — making it required forces parsing to fail before the typed
235/// error branch can classify the failure, surfacing every provider error as
236/// "parse response: missing field `access_token`".
237#[cfg(any(test, feature = "oauth-providers"))]
238#[derive(Deserialize)]
239struct TokenResponse {
240    #[serde(default)]
241    access_token: Option<String>,
242    refresh_token: Option<String>,
243    expires_in: Option<i64>,
244    #[serde(default)]
245    error: Option<String>,
246    #[serde(default)]
247    error_description: Option<String>,
248}
249
250#[cfg(any(test, feature = "oauth-providers"))]
251impl TokenResponse {
252    /// Convert a parsed response into typed `OAuthTokens`, classifying the
253    /// failure modes both exchange and refresh share: `invalid_grant` /
254    /// `unauthorized_client` (the refresh-token-no-longer-accepted family)
255    /// become `OAuthError::Reauthorize`; other provider errors become
256    /// `TokenExchange`; a missing `access_token` on a non-error response is
257    /// reported as a malformed success.
258    fn into_tokens(
259        self,
260        status: reqwest::StatusCode,
261        clock: &dyn crate::clock::Clock,
262    ) -> Result<OAuthTokens, OAuthError> {
263        if let Some(error) = self.error {
264            let detail = match self.error_description.as_deref() {
265                Some(d) => format!("{error}: {d}"),
266                None => error.clone(),
267            };
268            if matches!(error.as_str(), "invalid_grant" | "unauthorized_client") {
269                return Err(OAuthError::Reauthorize(detail));
270            }
271            return Err(OAuthError::TokenExchange(format!(
272                "provider error (HTTP {status}): {detail}"
273            )));
274        }
275
276        let access_token = self.access_token.ok_or_else(|| {
277            OAuthError::TokenExchange(format!(
278                "provider response missing access_token (HTTP {status})"
279            ))
280        })?;
281
282        let expires_at = self.expires_in.map(|secs| clock.now().timestamp() + secs);
283
284        Ok(OAuthTokens {
285            access_token,
286            refresh_token: self.refresh_token,
287            expires_at,
288        })
289    }
290}
291
292#[cfg(feature = "oauth-providers")]
293fn oauth_callback_html(title: &str, message: &str) -> String {
294    include_str!("oauth_success.html")
295        .replace("{{title}}", title)
296        .replace("{{message}}", message)
297}
298
299#[cfg(feature = "oauth-providers")]
300async fn post_token_request(
301    config: &OAuthConfig,
302    params: Vec<(&str, String)>,
303    clock: &dyn crate::clock::Clock,
304) -> Result<OAuthTokens, OAuthError> {
305    let client = reqwest::Client::new();
306    let resp = client
307        .post(&config.token_url)
308        .form(&params)
309        .send()
310        .await
311        .map_err(|e| OAuthError::TokenExchange(format!("request failed: {e}")))?;
312
313    let status = resp.status();
314    let body = resp
315        .text()
316        .await
317        .map_err(|e| OAuthError::TokenExchange(format!("read body: {e}")))?;
318
319    let token_resp: TokenResponse = serde_json::from_str(&body).map_err(|e| {
320        OAuthError::TokenExchange(format!("parse token response (HTTP {status}): {e}"))
321    })?;
322
323    token_resp.into_tokens(status, clock)
324}
325
326/// Generate a random PKCE code verifier (43-128 URL-safe characters).
327#[cfg(any(test, feature = "oauth-providers"))]
328pub(crate) fn generate_code_verifier() -> String {
329    let mut bytes = [0u8; 32];
330    rand::rng().fill_bytes(&mut bytes);
331    URL_SAFE_NO_PAD.encode(bytes)
332}
333
334/// Compute the S256 PKCE code challenge from a verifier.
335#[cfg(any(test, feature = "oauth-providers"))]
336pub(crate) fn code_challenge(verifier: &str) -> String {
337    let hash = Sha256::digest(verifier.as_bytes());
338    URL_SAFE_NO_PAD.encode(hash)
339}
340
341/// An authorization request the host drives itself: the URL to open plus the
342/// PKCE verifier and state value coven checks during exchange. For hosts that
343/// capture the redirect outside coven's localhost callback server — e.g. a
344/// mobile OS auth session (ASWebAuthenticationSession / Custom Tabs)
345/// redirecting to a custom URI scheme, where binding a localhost port and
346/// `open::that` don't apply.
347#[cfg(feature = "oauth-providers")]
348#[derive(Clone, Debug)]
349pub struct AuthorizeRequest {
350    pub auth_url: String,
351    verifier: String,
352    state: String,
353}
354
355#[cfg(feature = "oauth-providers")]
356impl AuthorizeRequest {
357    pub fn verify_callback_state(&self, callback_state: Option<&str>) -> Result<(), OAuthError> {
358        verify_callback_state(callback_state, &self.state).map_err(OAuthError::Denied)
359    }
360}
361
362/// Build the authorization URL + PKCE verifier for `config`, redirecting to
363/// `redirect_uri`. The caller opens the URL, captures the `code` and `state`
364/// from the redirect itself, then calls [`exchange_authorize_request`] with the
365/// same `redirect_uri` and returned request. [`authorize`] is this plus coven's
366/// localhost callback server for desktop.
367#[cfg(feature = "oauth-providers")]
368pub(crate) fn build_authorize_url(
369    config: &OAuthConfig,
370    redirect_uri: &str,
371) -> Result<AuthorizeRequest, OAuthError> {
372    let verifier = generate_code_verifier();
373    let state = generate_code_verifier();
374    let challenge = code_challenge(&verifier);
375
376    let mut auth_params = vec![
377        ("response_type", "code".to_string()),
378        ("client_id", config.client_id.clone()),
379        ("redirect_uri", redirect_uri.to_string()),
380        ("code_challenge", challenge),
381        ("code_challenge_method", "S256".to_string()),
382        ("state", state.clone()),
383    ];
384
385    for (k, v) in &config.extra_auth_params {
386        auth_params.push((k.as_str(), v.clone()));
387    }
388
389    if !config.scopes.is_empty() {
390        auth_params.push(("scope", config.scopes.join(" ")));
391    }
392
393    let auth_url = format!(
394        "{}?{}",
395        config.auth_url,
396        serde_urlencoded::to_string(&auth_params)
397            .map_err(|e| OAuthError::Server(format!("failed to encode params: {e}")))?
398    );
399
400    Ok(AuthorizeRequest {
401        auth_url,
402        verifier,
403        state,
404    })
405}
406
407#[cfg(feature = "oauth-providers")]
408fn callback_code(params: &std::collections::HashMap<String, String>) -> Result<String, String> {
409    if let Some(error) = params.get("error") {
410        let desc = match params.get("error_description") {
411            Some(desc) => desc.clone(),
412            None => {
413                warn!("OAuth callback error omitted error_description: {error}");
414                error.clone()
415            }
416        };
417        Err(desc)
418    } else if let Some(code) = params.get("code") {
419        Ok(code.clone())
420    } else {
421        Err("no code in callback".to_string())
422    }
423}
424
425#[cfg(feature = "oauth-providers")]
426fn verify_callback_state(callback_state: Option<&str>, expected_state: &str) -> Result<(), String> {
427    match callback_state {
428        Some(state) if state == expected_state => Ok(()),
429        Some(_) => Err("state mismatch in callback".to_string()),
430        None => Err("missing state in callback".to_string()),
431    }
432}
433
434/// Open the user's browser, wait for the OAuth callback, and exchange the
435/// authorization code for tokens.
436///
437/// Flow:
438/// 1. Generate PKCE verifier + challenge
439/// 2. Open browser to `auth_url` with the required parameters
440/// 3. Spawn a one-shot HTTP server on `localhost:{redirect_port}`
441/// 4. Wait for the callback with the authorization code
442/// 5. Exchange the code for tokens at `token_url`
443///
444/// Binds a localhost TCP port (`tokio::net`), serves the callback with axum, and
445/// opens the system browser (`open`). Gated on `oauth-providers`, which pulls in
446/// `open`.
447#[cfg(feature = "oauth-providers")]
448pub(crate) async fn authorize(
449    config: &OAuthConfig,
450    cancel: tokio::sync::watch::Receiver<bool>,
451    clock: &dyn crate::clock::Clock,
452) -> Result<OAuthTokens, OAuthError> {
453    let redirect_uri = format!("http://localhost:{}/callback", config.redirect_port);
454    let AuthorizeRequest {
455        auth_url,
456        verifier,
457        state,
458    } = build_authorize_url(config, &redirect_uri)?;
459
460    // Channel to receive the authorization code from the callback handler
461    let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
462    let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx)));
463
464    let tx_for_handler = tx.clone();
465    let expected_state = state.clone();
466    let app = axum::Router::new().route(
467        "/callback",
468        axum::routing::get(
469            move |axum::extract::Query(params): axum::extract::Query<
470                std::collections::HashMap<String, String>,
471            >| {
472                let tx = tx_for_handler.clone();
473                let expected_state = expected_state.clone();
474                async move {
475                    let mut guard = tx.lock().await;
476                    let callback =
477                        verify_callback_state(params.get("state").map(String::as_str), &expected_state)
478                            .and_then(|()| callback_code(&params));
479                    let is_error = callback.is_err();
480                    if let Some(sender) = guard.take() {
481                        if sender.send(callback).is_err() {
482                            warn!("OAuth callback receiver dropped before result delivery");
483                        }
484                    }
485                    let html = if is_error {
486                        oauth_callback_html(
487                            "Authorization denied",
488                            "Authorization was denied. You can close this window and try again in the app.",
489                        )
490                    } else {
491                        oauth_callback_html(
492                            "Authorization complete",
493                            "You can close this window and return to the app.",
494                        )
495                    };
496                    (
497                        [
498                            (axum::http::header::CACHE_CONTROL, "no-store"),
499                            (axum::http::header::CONNECTION, "close"),
500                        ],
501                        axum::response::Html(html),
502                    )
503                }
504            },
505        ),
506    );
507
508    let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", config.redirect_port))
509        .await
510        .map_err(|e| OAuthError::Server(format!("failed to bind port: {e}")))?;
511
512    // Spawn the server. The guard aborts the task on drop so future
513    // cancellation (parent .await dropped) tears the listener down too.
514    let server_guard = AbortOnDrop::new(tokio::spawn(async move {
515        if let Err(e) = axum::serve(listener, app)
516            .with_graceful_shutdown(async {
517                tokio::time::sleep(std::time::Duration::from_secs(300)).await;
518            })
519            .await
520        {
521            warn!("OAuth callback server exited with error: {e}");
522        }
523    }));
524
525    // Open the browser
526    open::that(&auth_url).map_err(|e| OAuthError::BrowserOpen(format!("{e}")))?;
527
528    info!("Opened browser for OAuth authorization, waiting for callback");
529
530    // Wait for the callback, cancellation, or timeout
531    let mut cancel = cancel;
532    let result = tokio::select! {
533        result = rx => {
534            result
535                .map_err(|_| OAuthError::Server("callback channel closed".to_string()))
536                .and_then(|r| r.map_err(OAuthError::Denied))
537        }
538        _ = cancel.wait_for(|&v| v) => {
539            Err(OAuthError::Denied("cancelled".to_string()))
540        }
541        _ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
542            Err(OAuthError::Timeout)
543        }
544    };
545
546    // Disarm the abort-on-drop and await the listener task's termination
547    // briefly. Bounded timeout because awaiting the abort could otherwise
548    // deadlock on a small thread pool with no idle worker. This matters
549    // for back-to-back sign-in flows: without the wait, the next bind on
550    // the same port can race the not-yet-released listener (no SO_REUSEADDR).
551    if let Some(handle) = server_guard.take_handle() {
552        handle.abort();
553        match tokio::time::timeout(std::time::Duration::from_millis(500), handle).await {
554            Ok(Ok(())) => {}
555            Ok(Err(e)) if e.is_cancelled() => {}
556            Ok(Err(e)) => {
557                warn!("OAuth callback server task panicked on shutdown: {e}");
558            }
559            Err(_) => {
560                warn!(
561                    "OAuth callback server did not exit within 500ms; \
562                     port {} may briefly remain in use",
563                    config.redirect_port
564                );
565            }
566        }
567    }
568
569    let code = result?;
570
571    info!("Received authorization code, exchanging for tokens");
572
573    // Exchange the code for tokens
574    exchange_code(config, &code, &verifier, &redirect_uri, clock).await
575}
576
577#[cfg(feature = "oauth-providers")]
578async fn exchange_code(
579    config: &OAuthConfig,
580    code: &str,
581    verifier: &str,
582    redirect_uri: &str,
583    clock: &dyn crate::clock::Clock,
584) -> Result<OAuthTokens, OAuthError> {
585    let mut params = vec![
586        ("grant_type", "authorization_code".to_string()),
587        ("code", code.to_string()),
588        ("redirect_uri", redirect_uri.to_string()),
589        ("client_id", config.client_id.clone()),
590        ("code_verifier", verifier.to_string()),
591    ];
592    if let Some(secret) = &config.client_secret {
593        params.push(("client_secret", secret.clone()));
594    }
595
596    post_token_request(config, params, clock).await
597}
598
599/// Verify the callback state from an [`AuthorizeRequest`] and exchange its code
600/// for tokens.
601#[cfg(feature = "oauth-providers")]
602pub(crate) async fn exchange_authorize_request(
603    config: &OAuthConfig,
604    code: &str,
605    callback_state: Option<&str>,
606    request: &AuthorizeRequest,
607    redirect_uri: &str,
608    clock: &dyn crate::clock::Clock,
609) -> Result<OAuthTokens, OAuthError> {
610    request.verify_callback_state(callback_state)?;
611    exchange_code(config, code, &request.verifier, redirect_uri, clock).await
612}
613
614/// Refresh an expired access token using a refresh token.
615#[cfg(feature = "oauth-providers")]
616pub(crate) async fn refresh(
617    config: &OAuthConfig,
618    refresh_token: &str,
619    clock: &dyn crate::clock::Clock,
620) -> Result<OAuthTokens, OAuthError> {
621    let mut params = vec![
622        ("grant_type", "refresh_token".to_string()),
623        ("refresh_token", refresh_token.to_string()),
624        ("client_id", config.client_id.clone()),
625    ];
626    if let Some(secret) = &config.client_secret {
627        params.push(("client_secret", secret.clone()));
628    }
629
630    let mut tokens = post_token_request(config, params, clock).await?;
631    // Provider didn't return a new refresh token (common — many providers
632    // only rotate it on the initial exchange). Reuse the existing one so the
633    // session can refresh again next cycle.
634    if tokens.refresh_token.is_none() {
635        tracing::debug!("provider did not return a new refresh_token; reusing existing token");
636        tokens.refresh_token = Some(refresh_token.to_string());
637    }
638    Ok(tokens)
639}
640
641/// The OAuth config for a provider that uses OAuth, or an error for providers
642/// (S3, CloudKit) that don't.
643///
644/// Reads each provider's config from its cloud backend; gated on
645/// `oauth-providers`.
646#[cfg(feature = "oauth-providers")]
647fn oauth_config_for_provider(
648    provider: crate::config::CloudProvider,
649) -> Result<OAuthConfig, OAuthError> {
650    use crate::config::CloudProvider;
651    use crate::storage::cloud::{dropbox, google_drive, onedrive};
652
653    match provider {
654        CloudProvider::GoogleDrive => Ok(google_drive::GoogleDriveCloudHome::oauth_config()?),
655        CloudProvider::Dropbox => Ok(dropbox::DropboxCloudHome::oauth_config()?),
656        CloudProvider::OneDrive => Ok(onedrive::OneDriveCloudHome::oauth_config()?),
657        other => Err(OAuthError::Denied(format!("{other:?} does not use OAuth"))),
658    }
659}
660
661/// Run an OAuth authorization flow for the given cloud provider (desktop: opens
662/// a browser and captures the redirect on coven's localhost callback server).
663///
664/// Returns tokens on success. Only Google Drive, Dropbox, and OneDrive support
665/// OAuth; other providers return an error.
666///
667/// Uses [`authorize`]'s localhost-callback flow; gated on `oauth-providers`.
668#[cfg(feature = "oauth-providers")]
669pub async fn authorize_provider(
670    provider: crate::config::CloudProvider,
671    cancel: tokio::sync::watch::Receiver<bool>,
672    clock: &dyn crate::clock::Clock,
673) -> Result<OAuthTokens, OAuthError> {
674    authorize(&oauth_config_for_provider(provider)?, cancel, clock).await
675}
676
677/// Build an OAuth authorization request for `provider` redirecting to
678/// `redirect_uri`, for hosts that capture the redirect themselves (a mobile OS
679/// auth session). Pair the returned request, callback `code`, callback `state`,
680/// and same `redirect_uri` with [`exchange_code_for_provider`].
681///
682/// The provider configuration and this function are gated on `oauth-providers`.
683#[cfg(feature = "oauth-providers")]
684pub fn build_authorize_request_for_provider(
685    provider: crate::config::CloudProvider,
686    redirect_uri: &str,
687) -> Result<AuthorizeRequest, OAuthError> {
688    build_authorize_url(&oauth_config_for_provider(provider)?, redirect_uri)
689}
690
691/// Exchange an authorization `code` captured by the host for `provider`'s
692/// tokens. `redirect_uri`, `request`, and `callback_state` must match the
693/// originating [`build_authorize_request_for_provider`] call.
694///
695/// Gated on `oauth-providers`.
696#[cfg(feature = "oauth-providers")]
697pub async fn exchange_code_for_provider(
698    provider: crate::config::CloudProvider,
699    code: &str,
700    callback_state: Option<&str>,
701    request: &AuthorizeRequest,
702    redirect_uri: &str,
703    clock: &dyn crate::clock::Clock,
704) -> Result<OAuthTokens, OAuthError> {
705    exchange_authorize_request(
706        &oauth_config_for_provider(provider)?,
707        code,
708        callback_state,
709        request,
710        redirect_uri,
711        clock,
712    )
713    .await
714}
715
716#[cfg(all(test, feature = "oauth-providers"))]
717pub(crate) mod test_support {
718    use super::OAuthConfig;
719    use axum::{extract::Form, routing::post, Router};
720    use std::{collections::HashMap, sync::Arc};
721    use tokio::sync::{oneshot, Mutex};
722
723    pub(crate) async fn serve_token_response(
724        response_body: &'static str,
725    ) -> (
726        String,
727        oneshot::Receiver<HashMap<String, String>>,
728        tokio::task::JoinHandle<()>,
729    ) {
730        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
731            .await
732            .expect("bind token server");
733        let url = format!(
734            "http://{}/token",
735            listener.local_addr().expect("local addr")
736        );
737
738        let (request_tx, request_rx) = oneshot::channel();
739        let request_tx = Arc::new(Mutex::new(Some(request_tx)));
740        let (shutdown_tx, shutdown_rx) = oneshot::channel();
741        let shutdown_tx = Arc::new(Mutex::new(Some(shutdown_tx)));
742
743        let app = Router::new().route(
744            "/token",
745            post(move |Form(params): Form<HashMap<String, String>>| {
746                let request_tx = request_tx.clone();
747                let shutdown_tx = shutdown_tx.clone();
748                async move {
749                    let request_tx = request_tx
750                        .lock()
751                        .await
752                        .take()
753                        .expect("token request sender available");
754                    request_tx.send(params).expect("send token request to test");
755                    let shutdown_tx = shutdown_tx
756                        .lock()
757                        .await
758                        .take()
759                        .expect("token server shutdown sender available");
760                    shutdown_tx.send(()).expect("send token server shutdown");
761                    ([("content-type", "application/json")], response_body)
762                }
763            }),
764        );
765        let server = tokio::spawn(async move {
766            axum::serve(listener, app)
767                .with_graceful_shutdown(async {
768                    shutdown_rx.await.expect("receive token server shutdown");
769                })
770                .await
771                .expect("serve token response");
772        });
773        (url, request_rx, server)
774    }
775
776    pub(crate) fn oauth_config(token_url: String) -> OAuthConfig {
777        OAuthConfig {
778            client_id: "client-id".to_string(),
779            client_secret: Some("client-secret".to_string()),
780            auth_url: "http://auth.example/authorize".to_string(),
781            token_url,
782            scopes: vec![],
783            redirect_port: 19284,
784            extra_auth_params: vec![],
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    #[cfg(feature = "oauth-providers")]
793    use test_support::{oauth_config, serve_token_response};
794
795    #[test]
796    fn pkce_verifier_is_url_safe() {
797        let verifier = generate_code_verifier();
798        assert!(verifier.len() >= 43);
799        assert!(verifier
800            .chars()
801            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
802    }
803
804    #[test]
805    fn pkce_challenge_is_deterministic() {
806        let verifier = "test-verifier-string";
807        let c1 = code_challenge(verifier);
808        let c2 = code_challenge(verifier);
809        assert_eq!(c1, c2);
810    }
811
812    #[test]
813    fn pkce_challenge_is_base64url() {
814        let verifier = generate_code_verifier();
815        let challenge = code_challenge(&verifier);
816        assert!(challenge
817            .chars()
818            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
819    }
820
821    #[test]
822    fn oauth_tokens_serialization_roundtrip() {
823        let tokens = OAuthTokens {
824            access_token: "at_123".to_string(),
825            refresh_token: Some("rt_456".to_string()),
826            expires_at: Some(1700000000),
827        };
828        let json = serde_json::to_string(&tokens).unwrap();
829        let parsed: OAuthTokens = serde_json::from_str(&json).unwrap();
830        assert_eq!(parsed.access_token, "at_123");
831        assert_eq!(parsed.refresh_token, Some("rt_456".to_string()));
832        assert_eq!(parsed.expires_at, Some(1700000000));
833    }
834
835    #[test]
836    fn debug_redacts_access_and_refresh_tokens() {
837        let tokens = OAuthTokens {
838            access_token: "access-token-do-not-print".to_string(),
839            refresh_token: Some("refresh-token-do-not-print".to_string()),
840            expires_at: Some(1700000000),
841        };
842        let debug = format!("{tokens:?}");
843
844        assert!(debug.contains("<redacted>"), "{debug}");
845        // Non-secret expiry stays visible.
846        assert!(debug.contains("1700000000"), "{debug}");
847        assert!(
848            !debug.contains("access-token-do-not-print"),
849            "access token leaked: {debug}"
850        );
851        assert!(
852            !debug.contains("refresh-token-do-not-print"),
853            "refresh token leaked: {debug}"
854        );
855        // refresh_token presence is still observable.
856        assert!(debug.contains("refresh_token: Some"), "{debug}");
857    }
858
859    fn parse_token_response(body: &str) -> TokenResponse {
860        serde_json::from_str(body).expect("parse TokenResponse")
861    }
862
863    #[cfg(feature = "oauth-providers")]
864    fn authorize_url_params(auth_url: &str) -> HashMap<String, String> {
865        let (_, query) = auth_url.split_once('?').expect("authorize URL has query");
866        serde_urlencoded::from_str(query).expect("parse authorize query")
867    }
868
869    #[cfg(feature = "oauth-providers")]
870    #[test]
871    fn oauth_callback_html_renders_result_text() {
872        let html = oauth_callback_html("Authorization denied", "Denied message");
873
874        assert!(html.contains("<h1>Authorization denied</h1>"));
875        assert!(html.contains("<p>Denied message</p>"));
876        assert!(!html.contains("{{title}}"));
877        assert!(!html.contains("{{message}}"));
878    }
879
880    #[cfg(feature = "oauth-providers")]
881    #[test]
882    fn build_authorize_url_includes_matching_random_state() {
883        let config = oauth_config("http://token.example/token".to_string());
884
885        let first = build_authorize_url(&config, "http://localhost/callback")
886            .expect("build first authorize URL");
887        let second = build_authorize_url(&config, "http://localhost/callback")
888            .expect("build second authorize URL");
889
890        let first_params = authorize_url_params(&first.auth_url);
891        let first_state = first_params
892            .get("state")
893            .expect("authorize URL includes state");
894        assert!(
895            first
896                .verify_callback_state(Some(first_state.as_str()))
897                .is_ok(),
898            "AuthorizeRequest verifies the state it put in the URL",
899        );
900        assert!(
901            !first_state.is_empty(),
902            "state must be present for callback validation",
903        );
904        let second_params = authorize_url_params(&second.auth_url);
905        let second_state = second_params
906            .get("state")
907            .expect("second authorize URL includes state");
908        assert_ne!(
909            first_state, second_state,
910            "separate OAuth flows must carry separate state values",
911        );
912    }
913
914    #[cfg(feature = "oauth-providers")]
915    #[test]
916    fn callback_rejects_missing_or_wrong_state() {
917        let expected_state = "expected-state";
918        let valid = HashMap::from([
919            ("code".to_string(), "auth-code".to_string()),
920            ("state".to_string(), expected_state.to_string()),
921        ]);
922        let wrong = HashMap::from([
923            ("code".to_string(), "auth-code".to_string()),
924            ("state".to_string(), "wrong-state".to_string()),
925        ]);
926        let missing = HashMap::from([("code".to_string(), "auth-code".to_string())]);
927
928        assert_eq!(
929            verify_callback_state(valid.get("state").map(String::as_str), expected_state)
930                .and_then(|()| callback_code(&valid))
931                .as_deref(),
932            Ok("auth-code"),
933        );
934        assert!(
935            verify_callback_state(wrong.get("state").map(String::as_str), expected_state).is_err(),
936            "wrong state must reject the callback",
937        );
938        assert!(
939            verify_callback_state(missing.get("state").map(String::as_str), expected_state)
940                .is_err(),
941            "missing state must reject the callback",
942        );
943    }
944
945    #[cfg(feature = "oauth-providers")]
946    #[tokio::test]
947    async fn exchange_authorize_request_rejects_wrong_state_before_token_request() {
948        let config = oauth_config("http://127.0.0.1:9/token".to_string());
949        let request = build_authorize_url(&config, "http://localhost/callback")
950            .expect("build authorize request");
951
952        let result = exchange_authorize_request(
953            &config,
954            "auth-code",
955            Some("wrong-state"),
956            &request,
957            "http://localhost/callback",
958            &crate::clock::SystemClock,
959        )
960        .await;
961
962        assert!(
963            matches!(result, Err(OAuthError::Denied(ref msg)) if msg.contains("state mismatch")),
964            "expected state rejection before token request, got {result:?}",
965        );
966    }
967
968    #[cfg(feature = "oauth-providers")]
969    #[tokio::test]
970    async fn exchange_code_posts_authorization_code_params() {
971        let (token_url, request_body, server) = serve_token_response(
972            r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}"#,
973        )
974        .await;
975        let config = oauth_config(token_url);
976
977        let tokens = exchange_code(
978            &config,
979            "auth-code",
980            "pkce-verifier",
981            "http://localhost/callback",
982            &crate::clock::SystemClock,
983        )
984        .await
985        .expect("exchange code");
986
987        let body = request_body.await.expect("request body");
988        server.await.expect("token server");
989        assert_eq!(
990            body.get("grant_type").map(String::as_str),
991            Some("authorization_code")
992        );
993        assert_eq!(body.get("code").map(String::as_str), Some("auth-code"));
994        assert_eq!(
995            body.get("redirect_uri").map(String::as_str),
996            Some("http://localhost/callback")
997        );
998        assert_eq!(body.get("client_id").map(String::as_str), Some("client-id"));
999        assert_eq!(
1000            body.get("code_verifier").map(String::as_str),
1001            Some("pkce-verifier")
1002        );
1003        assert_eq!(
1004            body.get("client_secret").map(String::as_str),
1005            Some("client-secret")
1006        );
1007        assert_eq!(tokens.access_token, "new-access");
1008        assert_eq!(tokens.refresh_token.as_deref(), Some("new-refresh"));
1009    }
1010
1011    #[cfg(feature = "oauth-providers")]
1012    #[tokio::test]
1013    async fn refresh_posts_refresh_token_params_and_reuses_existing_refresh_token() {
1014        let (token_url, request_body, server) =
1015            serve_token_response(r#"{"access_token":"refreshed-access","expires_in":3600}"#).await;
1016        let config = oauth_config(token_url);
1017
1018        let tokens = refresh(&config, "existing-refresh", &crate::clock::SystemClock)
1019            .await
1020            .expect("refresh");
1021
1022        let body = request_body.await.expect("request body");
1023        server.await.expect("token server");
1024        assert_eq!(
1025            body.get("grant_type").map(String::as_str),
1026            Some("refresh_token")
1027        );
1028        assert_eq!(
1029            body.get("refresh_token").map(String::as_str),
1030            Some("existing-refresh")
1031        );
1032        assert_eq!(body.get("client_id").map(String::as_str), Some("client-id"));
1033        assert_eq!(
1034            body.get("client_secret").map(String::as_str),
1035            Some("client-secret")
1036        );
1037        assert_eq!(tokens.access_token, "refreshed-access");
1038        assert_eq!(tokens.refresh_token.as_deref(), Some("existing-refresh"));
1039    }
1040
1041    #[test]
1042    fn into_tokens_classifies_invalid_grant_as_reauthorize() {
1043        let body =
1044            r#"{"error":"invalid_grant","error_description":"Token has been expired or revoked"}"#;
1045        let result = parse_token_response(body)
1046            .into_tokens(reqwest::StatusCode::BAD_REQUEST, &crate::clock::SystemClock);
1047        match result {
1048            Err(OAuthError::Reauthorize(detail)) => {
1049                assert!(
1050                    detail.contains("expired or revoked"),
1051                    "expected error_description in detail, got: {detail}",
1052                );
1053            }
1054            other => panic!("expected Reauthorize, got {other:?}"),
1055        }
1056    }
1057
1058    #[test]
1059    fn into_tokens_classifies_unauthorized_client_as_reauthorize() {
1060        let body =
1061            r#"{"error":"unauthorized_client","error_description":"Client has been revoked"}"#;
1062        let result = parse_token_response(body)
1063            .into_tokens(reqwest::StatusCode::BAD_REQUEST, &crate::clock::SystemClock);
1064        assert!(
1065            matches!(result, Err(OAuthError::Reauthorize(_))),
1066            "expected Reauthorize, got {result:?}",
1067        );
1068    }
1069
1070    #[test]
1071    fn into_tokens_leaves_other_provider_errors_as_token_exchange() {
1072        let body = r#"{"error":"invalid_request","error_description":"missing parameter"}"#;
1073        let result = parse_token_response(body)
1074            .into_tokens(reqwest::StatusCode::BAD_REQUEST, &crate::clock::SystemClock);
1075        match result {
1076            Err(OAuthError::TokenExchange(msg)) => {
1077                assert!(
1078                    msg.contains("missing parameter"),
1079                    "expected description in message, got: {msg}",
1080                );
1081            }
1082            other => panic!("expected TokenExchange, got {other:?}"),
1083        }
1084    }
1085
1086    #[test]
1087    fn into_tokens_returns_tokens_on_success() {
1088        let body = r#"{"access_token":"new_at","refresh_token":"new_rt","expires_in":3600}"#;
1089        let tokens = parse_token_response(body)
1090            .into_tokens(reqwest::StatusCode::OK, &crate::clock::SystemClock)
1091            .expect("into_tokens");
1092        assert_eq!(tokens.access_token, "new_at");
1093        assert_eq!(tokens.refresh_token.as_deref(), Some("new_rt"));
1094        assert!(tokens.expires_at.is_some());
1095    }
1096
1097    #[test]
1098    fn into_tokens_errors_when_success_response_missing_access_token() {
1099        let body = r#"{}"#;
1100        let result = parse_token_response(body)
1101            .into_tokens(reqwest::StatusCode::OK, &crate::clock::SystemClock);
1102        match result {
1103            Err(OAuthError::TokenExchange(msg)) => {
1104                assert!(
1105                    msg.contains("missing access_token"),
1106                    "expected access_token error, got: {msg}",
1107                );
1108            }
1109            other => panic!("expected TokenExchange, got {other:?}"),
1110        }
1111    }
1112
1113    #[test]
1114    fn into_tokens_missing_access_token_error_does_not_include_tokens() {
1115        let body = r#"{"refresh_token":"refresh-token-that-must-not-be-logged"}"#;
1116        let result = parse_token_response(body)
1117            .into_tokens(reqwest::StatusCode::OK, &crate::clock::SystemClock);
1118        match result {
1119            Err(OAuthError::TokenExchange(msg)) => {
1120                assert!(
1121                    msg.contains("missing access_token"),
1122                    "expected access_token error, got: {msg}",
1123                );
1124                assert!(
1125                    msg.contains("HTTP 200"),
1126                    "expected status in error, got: {msg}",
1127                );
1128                assert!(
1129                    !msg.contains("refresh-token-that-must-not-be-logged"),
1130                    "error included refresh token: {msg}",
1131                );
1132            }
1133            other => panic!("expected TokenExchange, got {other:?}"),
1134        }
1135    }
1136
1137    #[cfg(feature = "oauth-providers")]
1138    #[tokio::test]
1139    async fn parse_failure_error_does_not_include_tokens() {
1140        let (token_url, _request_body, server) =
1141            serve_token_response(r#"{"access_token":"access-token-that-must-not-be-logged""#).await;
1142        let config = oauth_config(token_url);
1143
1144        let result = exchange_code(
1145            &config,
1146            "auth-code",
1147            "pkce-verifier",
1148            "http://localhost/callback",
1149            &crate::clock::SystemClock,
1150        )
1151        .await;
1152
1153        server.await.expect("token server");
1154        match result {
1155            Err(OAuthError::TokenExchange(msg)) => {
1156                assert!(
1157                    msg.contains("parse token response"),
1158                    "expected parse error, got: {msg}",
1159                );
1160                assert!(
1161                    msg.contains("HTTP 200"),
1162                    "expected status in error, got: {msg}",
1163                );
1164                assert!(
1165                    !msg.contains("access-token-that-must-not-be-logged"),
1166                    "error included access token: {msg}",
1167                );
1168            }
1169            other => panic!("expected TokenExchange, got {other:?}"),
1170        }
1171    }
1172}