coven/storage/cloud/
oauth_session.rs

1//! Shared OAuth token lifecycle for the consumer-cloud backends.
2//!
3//! Google Drive, Dropbox, and OneDrive all cache an access token, refresh it on
4//! expiry (persisting the new tokens to the keyring), and retry a request once
5//! on a 401. This holds that logic in one place; each backend owns an
6//! `OAuthSession` and routes its requests through `api_call`.
7
8use tokio::sync::RwLock;
9use tracing::{info, warn};
10
11use super::CloudHomeError;
12use crate::clock::ClockRef;
13use crate::keys::{CloudHomeCredentials, KeyService};
14use crate::oauth::{self, OAuthConfig, OAuthTokens};
15
16/// Owns a provider's OAuth tokens and refreshes them as needed.
17pub struct OAuthSession {
18    tokens: RwLock<OAuthTokens>,
19    key_service: KeyService,
20    clock: ClockRef,
21    config: OAuthConfig,
22    /// Human-readable provider name, used only in log lines.
23    provider_label: &'static str,
24}
25
26impl OAuthSession {
27    pub fn new(
28        tokens: OAuthTokens,
29        key_service: KeyService,
30        clock: ClockRef,
31        config: OAuthConfig,
32        provider_label: &'static str,
33    ) -> Self {
34        Self {
35            tokens: RwLock::new(tokens),
36            key_service,
37            clock,
38            config,
39            provider_label,
40        }
41    }
42
43    /// The current access token, refreshing if it's expired or about to expire.
44    async fn access_token(&self) -> Result<String, CloudHomeError> {
45        let tokens = self.tokens.read().await;
46        match tokens.expires_at {
47            Some(expires_at) if self.clock.now().timestamp() < expires_at - 60 => {
48                return Ok(tokens.access_token.clone());
49            }
50            // No expiry info: assume valid.
51            None => return Ok(tokens.access_token.clone()),
52            _ => {}
53        }
54        drop(tokens);
55        self.refresh().await
56    }
57
58    /// Refresh the tokens and persist them to the keyring.
59    async fn refresh(&self) -> Result<String, CloudHomeError> {
60        let mut tokens = self.tokens.write().await;
61
62        // Another task may have refreshed while we waited for the write lock.
63        if let Some(expires_at) = tokens.expires_at {
64            if self.clock.now().timestamp() < expires_at - 60 {
65                return Ok(tokens.access_token.clone());
66            }
67        }
68
69        let refresh_token = tokens.refresh_token.as_deref().ok_or_else(|| {
70            CloudHomeError::Storage(
71                "no refresh token available, re-authorization needed".to_string(),
72            )
73        })?;
74
75        let new_tokens = oauth::refresh(&self.config, refresh_token, self.clock.as_ref())
76            .await
77            .map_err(|e| CloudHomeError::Storage(format!("OAuth refresh failed: {e}")))?;
78
79        let json = serde_json::to_string(&new_tokens)
80            .map_err(|e| CloudHomeError::Storage(format!("serialize tokens: {e}")))?;
81        if let Err(e) = self
82            .key_service
83            .set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json: json })
84        {
85            warn!("Failed to persist refreshed OAuth tokens: {e}");
86        }
87
88        let access_token = new_tokens.access_token.clone();
89        *tokens = new_tokens;
90
91        info!("Refreshed {} OAuth tokens", self.provider_label);
92        Ok(access_token)
93    }
94
95    /// Build and send a request with the current token, refreshing and retrying
96    /// once on a 401.
97    pub async fn api_call(
98        &self,
99        build_request: impl Fn(&str) -> reqwest::RequestBuilder,
100    ) -> Result<reqwest::Response, CloudHomeError> {
101        let token = self.access_token().await?;
102        let resp = build_request(&token)
103            .send()
104            .await
105            .map_err(|e| CloudHomeError::Storage(format!("request failed: {e}")))?;
106
107        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
108            let new_token = self.refresh().await?;
109            build_request(&new_token)
110                .send()
111                .await
112                .map_err(|e| CloudHomeError::Storage(format!("retry request failed: {e}")))
113        } else {
114            Ok(resp)
115        }
116    }
117}