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#[derive(Debug, thiserror::Error)]
11pub enum OAuthError {
12 #[error("OAuth provider unavailable: {0:?}")]
14 Unavailable(CloudProvider),
15 #[error("authorization state mismatch")]
17 StateMismatch,
18 #[error("provider denied sign-in")]
20 Denied(#[source] OAuthRedirectError),
21 #[error("authorization callback omitted its code")]
23 MissingCode,
24 #[error("sign-in cancelled")]
26 Cancelled,
27 #[error("sign-in timed out")]
29 Timeout,
30 #[error("{0:?} requires a new sign-in")]
32 Reauthorize(CloudProvider),
33 #[error("invalid OAuth redirect")]
35 InvalidRedirect,
36 #[error("OAuth browser or listener: {0}")]
38 Io(#[from] std::io::Error),
39 #[error("authorization presentation: {0}")]
41 Presentation(#[source] Box<dyn std::error::Error + Send + Sync>),
42 #[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#[derive(Debug)]
60pub struct OAuthRedirectError {
61 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 pub fn callback(&self) -> &str {
81 self.callback.as_str()
82 }
83}
84
85pub(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#[async_trait::async_trait]
110pub trait OAuthPresenter: Send + Sync {
111 fn redirect_uri(&self, provider: CloudProvider) -> &str;
113 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#[derive(Clone)]
170pub struct OAuthFlow {
171 exchange: OAuthExchange,
172 presenter: Arc<dyn OAuthPresenter>,
173}
174
175impl OAuthFlow {
176 pub fn new(exchange: OAuthExchange, presenter: Arc<dyn OAuthPresenter>) -> Self {
178 Self { exchange, presenter }
179 }
180
181 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#[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#[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 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 #[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 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 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#[derive(Clone)]
349pub enum DropboxClients {
350 AppFolder {
352 app_key: String,
354 },
355 FullDropbox {
357 app_key: String,
359 },
360 Both {
362 app_folder_key: String,
364 full_dropbox_key: String,
366 },
367}
368
369impl OAuthClients {
370 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 #[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 #[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
493pub trait OAuthTokenRefresh: Sized {
495 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;