1#[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#[cfg(feature = "oauth-providers")]
22use tracing::{info, warn};
23
24#[cfg(feature = "oauth-providers")]
26#[derive(Clone, Debug)]
27pub struct OAuthConfig {
28 pub client_id: String,
29 pub client_secret: Option<String>,
31 pub auth_url: String,
32 pub token_url: String,
33 pub scopes: Vec<String>,
34 pub redirect_port: u16,
36 pub extra_auth_params: Vec<(String, String)>,
39}
40
41#[cfg(feature = "oauth-providers")]
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct OAuthClientCreds {
46 pub client_id: String,
47 pub client_secret: Option<String>,
49}
50
51#[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#[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 #[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 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 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(¶ms));
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 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::that(&auth_url).map_err(OAuthError::BrowserOpen)?;
259
260 info!("Opened browser for OAuth authorization, waiting for callback");
261
262 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 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_code(client, &config, &code, &verifier, &redirect_uri, clock).await
307 }
308
309 #[cfg(feature = "oauth-providers")]
310 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 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 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 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 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 #[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#[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 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#[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 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(¶ms)
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#[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#[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#[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 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;