Skip to main content

coven_storage/providers/
oauth.rs

1use super::http;
2use crate::{CloudProvider, DropboxAccess, OAuthAccess, OAuthTokens, StorageError, StorageFailure};
3use coven_crypto::{SecretBytes, SecretText};
4use coven_foundation::clock::ClockRef;
5use oauth2::{CsrfToken, PkceCodeChallenge};
6use std::sync::Arc;
7use std::time::Duration;
8
9/// Sign-in failures distinguish cancellation, invalid redirects and provider refusal.
10#[derive(Debug, thiserror::Error)]
11pub enum OAuthError {
12    /// This provider has no OAuth client id or presenter configured.
13    #[error("OAuth provider unavailable: {0:?}")]
14    Unavailable(CloudProvider),
15    /// Redirect state is missing or different.
16    #[error("authorization state mismatch")]
17    StateMismatch,
18    /// The provider returned access_denied, retaining its checked callback.
19    #[error("provider denied sign-in")]
20    Denied(#[source] OAuthRedirectError),
21    /// The callback omitted its authorization code.
22    #[error("authorization callback omitted its code")]
23    MissingCode,
24    /// The caller cancelled sign-in.
25    #[error("sign-in cancelled")]
26    Cancelled,
27    /// No callback arrived before the deadline.
28    #[error("sign-in timed out")]
29    Timeout,
30    /// A new provider sign-in is required.
31    #[error("{0:?} requires a new sign-in")]
32    Reauthorize(CloudProvider),
33    /// The redirect URI or callback request is malformed.
34    #[error("invalid OAuth redirect")]
35    InvalidRedirect,
36    /// The browser or local redirect listener failed.
37    #[error("OAuth browser or listener: {0}")]
38    Io(#[from] std::io::Error),
39    /// The app's native sign-in sheet failed.
40    #[error("authorization presentation: {0}")]
41    Presentation(#[source] Box<dyn std::error::Error + Send + Sync>),
42    /// Network or provider failure, preserving its classification.
43    #[error(transparent)]
44    Storage(#[from] StorageError),
45}
46
47impl coven_foundation::task::CarriesBug for OAuthError {
48    fn bug(&self) -> Option<&coven_foundation::task::Bug> {
49        match self {
50            Self::Storage(error) => error.bug(),
51            _ => None,
52        }
53    }
54}
55
56/// The original checked error callback. Diagnostics show only its `error`
57/// code (RFC 6749 §4.1.2.1), such as `server_error`: the callback's other
58/// fields may contain secrets echoed by the provider.
59#[derive(Debug)]
60pub struct OAuthRedirectError {
61    /// The `error` field, when it is a code from a fixed vocabulary.
62    code: Option<String>,
63    callback: SecretText,
64}
65
66impl std::fmt::Display for OAuthRedirectError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.write_str("provider returned an OAuth error redirect")?;
69        match &self.code {
70            Some(code) => write!(f, " ({code})"),
71            None => Ok(()),
72        }
73    }
74}
75
76impl std::error::Error for OAuthRedirectError {}
77
78impl OAuthRedirectError {
79    /// Inspect the unmodified callback explicitly, including provider extensions.
80    pub fn callback(&self) -> &str {
81        self.callback.as_str()
82    }
83}
84
85/// OAuth error codes have the same meaning in token responses and checked
86/// authorization redirects. Unrecognized codes leave HTTP status classification
87/// to the caller; redirects without a recognized code are provider refusals.
88pub(crate) fn classify_error(code: &str) -> Option<StorageFailure> {
89    match code {
90        "invalid_grant" | "interaction_required" | "consent_required" => {
91            Some(StorageFailure::Authentication)
92        }
93        "invalid_client"
94        | "unauthorized_client"
95        | "deleted_client"
96        | "invalid_scope"
97        | "invalid_resource"
98        | "invalid_request"
99        | "unsupported_grant_type"
100        | "unsupported_response_type" => Some(StorageFailure::InvalidConfiguration),
101        "server_error" | "temporarily_unavailable" => Some(StorageFailure::Network),
102        "access_denied" => Some(StorageFailure::PermissionDenied),
103        _ => None,
104    }
105}
106
107/// Presents sign-in without handling tokens. The platform sheet must be dismissed
108/// when its future is dropped. Native cancellation returns `OAuthError::Cancelled`.
109#[async_trait::async_trait]
110pub trait OAuthPresenter: Send + Sync {
111    /// The registered redirect for this provider. No query or fragment is allowed.
112    fn redirect_uri(&self, provider: CloudProvider) -> &str;
113    /// Open the authorization URL and return the complete provider redirect.
114    async fn present(&self, authorization_url: &str) -> Result<SecretText, OAuthError>;
115}
116
117struct AuthorizeRequest {
118    auth_url: String,
119    verifier: SecretText,
120    state: SecretText,
121    redirect: String,
122}
123
124impl AuthorizeRequest {
125    #[expect(
126        clippy::map_err_ignore,
127        reason = "a redirect or endpoint that does not parse is InvalidRedirect"
128    )]
129    fn new(
130        client_id: &str,
131        endpoint: &str,
132        scope: &str,
133        redirect_uri: &str,
134        extra: &[(&str, &str)],
135    ) -> Result<Self, OAuthError> {
136        let redirect = url::Url::parse(redirect_uri).map_err(|_| OAuthError::InvalidRedirect)?;
137        if redirect.fragment().is_some()
138            || redirect.query().is_some()
139            || !redirect.username().is_empty()
140            || redirect.password().is_some()
141        {
142            return Err(OAuthError::InvalidRedirect);
143        }
144        let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
145        let state = CsrfToken::new_random();
146        let mut url = url::Url::parse(endpoint).map_err(|_| OAuthError::InvalidRedirect)?;
147        url.query_pairs_mut()
148            .extend_pairs([
149                ("response_type", "code"),
150                ("client_id", client_id),
151                ("redirect_uri", redirect_uri),
152                ("code_challenge", challenge.as_str()),
153                ("code_challenge_method", "S256"),
154                ("state", state.secret()),
155                ("scope", scope),
156            ])
157            .extend_pairs(extra.iter().copied());
158        Ok(Self {
159            auth_url: url.into(),
160            verifier: SecretText::new(verifier.secret().clone()),
161            state: SecretText::new(state.secret().clone()),
162            redirect: redirect_uri.to_owned(),
163        })
164    }
165}
166
167/// The sign-in service used by coven's composition roots. Apps configure
168/// `OAuthClients` and `OAuthPresenter`; only coven receives tokens.
169#[derive(Clone)]
170pub struct OAuthFlow {
171    exchange: OAuthExchange,
172    presenter: Arc<dyn OAuthPresenter>,
173}
174
175impl OAuthFlow {
176    /// Compose the provider protocol with the app's presentation capability.
177    pub fn new(exchange: OAuthExchange, presenter: Arc<dyn OAuthPresenter>) -> Self {
178        Self { exchange, presenter }
179    }
180
181    /// Exchange a checked redirect. Dropping this future cancels presentation;
182    /// no detached task survives it. The caller commits tokens to custody.
183    pub async fn authenticate(&self, access: OAuthAccess) -> Result<OAuthTokens, OAuthError> {
184        let provider = access.provider();
185        let request = self
186            .exchange
187            .clients
188            .build_authorize_request(access, self.presenter.redirect_uri(provider))?;
189        let flow = async {
190            let redirect = self.presenter.present(&request.auth_url).await?;
191            self.exchange.exchange_redirect(access, &request, redirect).await
192        };
193        tokio::select! {
194            result = flow => result,
195            _ = self.exchange.clock.deadline(Duration::from_secs(300)) => Err(OAuthError::Timeout),
196        }
197    }
198}
199
200/// The app's OAuth client ids (E10).
201#[derive(Clone)]
202pub struct OAuthClients {
203    dropbox: Option<DropboxClients>,
204    onedrive: Option<String>,
205    #[cfg(any(test, feature = "test-utils"))]
206    token_override: Option<String>,
207}
208
209/// The app's OAuth clients with what coven sends their token requests
210/// through: the store's clock and an HTTP client of its own (§20.2).
211/// Building the client fails only when TLS cannot be set up; each request
212/// then returns that failure.
213#[derive(Clone)]
214pub struct OAuthExchange {
215    clients: OAuthClients,
216    clock: ClockRef,
217    client: Result<super::http_client::HttpClient, Arc<super::http_client::TlsSetupError>>,
218}
219
220impl OAuthExchange {
221    /// Send `clients`' token requests timed by `clock`.
222    pub fn new(clients: OAuthClients, clock: ClockRef) -> Self {
223        Self {
224            clients,
225            client: super::http_client::HttpClient::new(clock.clone()).map_err(Arc::new),
226            clock,
227        }
228    }
229    /// Redirect token requests to an HTTP fake while retaining the real protocol.
230    #[cfg(any(test, feature = "test-utils"))]
231    pub fn with_token_endpoint(mut self, endpoint: String) -> Self {
232        self.clients = self.clients.with_token_endpoint(endpoint);
233        self
234    }
235    async fn exchange_redirect(
236        &self,
237        access: OAuthAccess,
238        request: &AuthorizeRequest,
239        callback: SecretText,
240    ) -> Result<OAuthTokens, OAuthError> {
241        let code = OAuthClients::redirect_code(access.provider(), request, callback)?;
242        let (client_id, _, _, _) = self.clients.config(access)?;
243        self.tokens(
244            access,
245            &[
246                ("grant_type", "authorization_code"),
247                ("code", code.as_str()),
248                ("redirect_uri", request.redirect.as_str()),
249                ("client_id", client_id),
250                ("code_verifier", request.verifier.as_str()),
251            ],
252            None,
253        )
254        .await
255    }
256    /// Obtain replacement tokens. Omitted refresh tokens retain the previous
257    /// token as specified by OAuth.
258    async fn refresh_grant(
259        &self,
260        client_id: &str,
261        access: OAuthAccess,
262        refresh_token: &SecretText,
263    ) -> Result<OAuthTokens, OAuthError> {
264        let (id, _, _, _) = self.clients.config(access)?;
265        if id != client_id {
266            return Err(StorageFailure::InvalidConfiguration
267                .with_source("refresh client differs from the held authorization")
268                .into());
269        }
270        self.tokens(
271            access,
272            &[
273                ("grant_type", "refresh_token"),
274                ("refresh_token", refresh_token.as_str()),
275                ("client_id", id),
276            ],
277            Some(refresh_token),
278        )
279        .await
280    }
281    /// Request tokens; a reply omitting its refresh token keeps `held`.
282    async fn tokens(
283        &self,
284        access: OAuthAccess,
285        params: &[(&str, &str)],
286        held: Option<&SecretText>,
287    ) -> Result<OAuthTokens, OAuthError> {
288        let provider = access.provider();
289        let (client_id, _, token, _) = self.clients.config(access)?;
290        #[derive(serde::Deserialize)]
291        struct Tokens {
292            access_token: SecretText,
293            refresh_token: Option<SecretText>,
294            token_type: String,
295            scope: Option<String>,
296        }
297        let tokens = self
298            .token_response(provider, token, params, |tokens: &Tokens| {
299                (&tokens.access_token, tokens.token_type.as_str())
300            })
301            .await?;
302        let refresh_token = tokens.refresh_token.or_else(|| held.cloned());
303        Ok(OAuthTokens {
304            client_id: client_id.to_owned(),
305            access: access.granted(tokens.scope.as_deref(), refresh_token.is_some())?,
306            access_token: tokens.access_token,
307            refresh_token,
308        })
309    }
310    async fn token_response<T: serde::de::DeserializeOwned>(
311        &self,
312        provider: CloudProvider,
313        token: &str,
314        params: &[(&str, &str)],
315        bearer: impl FnOnce(&T) -> (&SecretText, &str),
316    ) -> Result<T, OAuthError> {
317        #[cfg(any(test, feature = "test-utils"))]
318        let token = match &self.clients.token_override {
319            Some(url) => url.as_str(),
320            None => token,
321        };
322        let client = self
323            .client
324            .as_ref()
325            .map_err(|source| super::http_client::tls_failure(provider, source.clone()))?;
326        let response = http::checked(
327            provider,
328            client
329                .send(provider, client.request(reqwest::Method::POST, token).form(params))
330                .await?,
331        )
332        .await?;
333        let bytes = SecretBytes::new(
334            response.bytes().await.map_err(|e| http::transport(provider, e))?.to_vec(),
335        );
336        let tokens: T = crate::secret_json::decode(bytes.as_bytes())?;
337        let (access_token, token_type) = bearer(&tokens);
338        if access_token.as_str().is_empty() || !token_type.eq_ignore_ascii_case("bearer") {
339            return Err(StorageFailure::Protocol
340                .with_source("invalid OAuth token response")
341                .into());
342        }
343        Ok(tokens)
344    }
345}
346
347/// The app's Dropbox registrations, with an explicit access choice when both exist.
348#[derive(Clone)]
349pub enum DropboxClients {
350    /// The only registration permits not-shared stores in its app folder.
351    AppFolder {
352        /// The App-folder registration's key.
353        app_key: String,
354    },
355    /// The only registration permits shared folder namespaces.
356    FullDropbox {
357        /// The Full Dropbox registration's key.
358        app_key: String,
359    },
360    /// Creation must choose AppFolder or FullDropbox; neither is a default.
361    Both {
362        /// The key used for not-shared stores.
363        app_folder_key: String,
364        /// The key used for shared-capable stores.
365        full_dropbox_key: String,
366    },
367}
368
369impl OAuthClients {
370    /// Configure client ids, `None` for sign-ins the app does not offer.
371    pub fn new(dropbox: Option<DropboxClients>, onedrive_client_id: Option<String>) -> Self {
372        Self {
373            dropbox,
374            onedrive: onedrive_client_id,
375            #[cfg(any(test, feature = "test-utils"))]
376            token_override: None,
377        }
378    }
379    fn config(
380        &self,
381        access: OAuthAccess,
382    ) -> Result<(&str, &'static str, &'static str, &'static str), OAuthError> {
383        let (id, auth, token) = match access {
384            OAuthAccess::Dropbox(requested) => {
385                let client =
386                    self.dropbox.as_ref().ok_or(OAuthError::Unavailable(access.provider()))?;
387                let id = match (client, requested) {
388                    (DropboxClients::AppFolder { app_key }, DropboxAccess::AppFolder)
389                    | (DropboxClients::FullDropbox { app_key }, DropboxAccess::FullDropbox) => {
390                        app_key
391                    }
392                    (DropboxClients::Both { app_folder_key, .. }, DropboxAccess::AppFolder) => {
393                        app_folder_key
394                    }
395                    (DropboxClients::Both { full_dropbox_key, .. }, DropboxAccess::FullDropbox) => {
396                        full_dropbox_key
397                    }
398                    _ => {
399                        return Err(StorageError::from(StorageFailure::AccessNotRegistered {
400                            required: access,
401                        })
402                        .into())
403                    }
404                };
405                (
406                    Some(id.as_str()),
407                    "https://www.dropbox.com/oauth2/authorize",
408                    "https://api.dropboxapi.com/oauth2/token",
409                )
410            }
411            OAuthAccess::OneDrive => (
412                self.onedrive.as_deref(),
413                "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
414                "https://login.microsoftonline.com/common/oauth2/v2.0/token",
415            ),
416        };
417        let id =
418            id.filter(|id| !id.is_empty()).ok_or(OAuthError::Unavailable(access.provider()))?;
419        Ok((id, auth, token, access.scope()))
420    }
421    fn build_authorize_request(
422        &self,
423        access: OAuthAccess,
424        redirect_uri: &str,
425    ) -> Result<AuthorizeRequest, OAuthError> {
426        let (client_id, auth, _, scope) = self.config(access)?;
427        AuthorizeRequest::new(
428            client_id,
429            auth,
430            scope,
431            redirect_uri,
432            match access.provider() {
433                CloudProvider::Dropbox => &[("token_access_type", "offline")],
434                _ => &[],
435            },
436        )
437    }
438    /// The authorization code of a checked redirect: its origin and path, its
439    /// state, and either a code or the provider's error.
440    #[expect(clippy::map_err_ignore, reason = "a redirect that does not parse is InvalidRedirect")]
441    fn redirect_code(
442        provider: CloudProvider,
443        request: &AuthorizeRequest,
444        callback: SecretText,
445    ) -> Result<SecretText, OAuthError> {
446        let mut redirect =
447            url::Url::parse(callback.as_str()).map_err(|_| OAuthError::InvalidRedirect)?;
448        let mut fields = std::collections::BTreeMap::new();
449        for (key, value) in redirect.query_pairs() {
450            if fields.insert(key.into_owned(), SecretText::new(value.into_owned())).is_some() {
451                return Err(OAuthError::InvalidRedirect);
452            }
453        }
454        redirect.set_query(None);
455        if redirect
456            != url::Url::parse(&request.redirect).map_err(|_| OAuthError::InvalidRedirect)?
457        {
458            return Err(OAuthError::InvalidRedirect);
459        }
460        if fields.get("state").map(SecretText::as_str) != Some(request.state.as_str()) {
461            return Err(OAuthError::StateMismatch);
462        }
463        if let Some(error) = fields.get("error") {
464            if error.as_str().is_empty() || fields.contains_key("code") {
465                return Err(OAuthError::InvalidRedirect);
466            }
467            let source = OAuthRedirectError { code: http::fixed_word(error.as_str()), callback };
468            if error.as_str() == "access_denied" {
469                return Err(OAuthError::Denied(source));
470            }
471            let failure = match classify_error(error.as_str()) {
472                Some(failure) => failure,
473                None => StorageFailure::Refused,
474            };
475            return Err(
476                StorageError::Provider { provider, failure, source: Box::new(source) }.into()
477            );
478        }
479        fields
480            .remove("code")
481            .filter(|code| !code.as_str().is_empty())
482            .ok_or(OAuthError::MissingCode)
483    }
484
485    /// Redirect token requests to an HTTP fake while retaining the real protocol.
486    #[cfg(any(test, feature = "test-utils"))]
487    pub fn with_token_endpoint(mut self, endpoint: String) -> Self {
488        self.token_override = Some(endpoint);
489        self
490    }
491}
492
493/// Crate-to-crate operations for coven's owners; not re-exported by the app API.
494pub trait OAuthTokenRefresh: Sized {
495    /// One refresh of `tokens` after the provider rejected them (§20.2), for its
496    /// custody to commit before any connection uses it. Tokens without a
497    /// refresh token need a new sign-in; a missing client is the app's
498    /// configuration. Token-endpoint failures keep their classification.
499    fn refresh(
500        &self,
501        tokens: &OAuthTokens,
502    ) -> impl std::future::Future<Output = Result<OAuthTokens, StorageError>> + Send;
503}
504
505impl OAuthTokenRefresh for OAuthExchange {
506    async fn refresh(&self, tokens: &OAuthTokens) -> Result<OAuthTokens, StorageError> {
507        let provider = tokens.access.provider();
508        let refreshed = match &tokens.refresh_token {
509            Some(refresh_token) => {
510                self.refresh_grant(&tokens.client_id, tokens.access, refresh_token).await
511            }
512            None => Err(OAuthError::Reauthorize(provider)),
513        };
514        refreshed.map_err(|error| match error {
515            OAuthError::Storage(error) => error,
516            error => StorageError::Provider {
517                provider,
518                failure: if matches!(error, OAuthError::Unavailable(_)) {
519                    StorageFailure::InvalidConfiguration
520                } else {
521                    StorageFailure::Authentication
522                },
523                source: Box::new(error),
524            },
525        })
526    }
527}
528
529#[cfg(test)]
530#[path = "oauth_tests.rs"]
531mod tests;