1use 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#[cfg(feature = "oauth-providers")]
21use tracing::{info, warn};
22
23#[cfg(feature = "oauth-providers")]
25#[derive(Clone, Debug)]
26pub(crate) struct OAuthConfig {
27 pub client_id: String,
28 pub client_secret: Option<String>,
30 pub auth_url: String,
31 pub token_url: String,
32 pub scopes: Vec<String>,
33 pub redirect_port: u16,
35 pub extra_auth_params: Vec<(String, String)>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
44pub struct OAuthClientCreds {
45 pub client_id: String,
46 pub client_secret: Option<String>,
48}
49
50#[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
61pub 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#[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#[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#[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#[derive(Clone, Serialize, Deserialize)]
151pub struct OAuthTokens {
152 pub access_token: String,
153 pub refresh_token: Option<String>,
154 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 .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 #[error("re-authorization required: {0}")]
197 Reauthorize(String),
198 #[cfg(feature = "oauth-providers")]
199 #[error(transparent)]
200 ClientCreds(#[from] OAuthClientCredsError),
201}
202
203#[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 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#[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 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(¶ms)
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#[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#[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#[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#[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#[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 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(¶ms));
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 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::that(&auth_url).map_err(|e| OAuthError::BrowserOpen(format!("{e}")))?;
527
528 info!("Opened browser for OAuth authorization, waiting for callback");
529
530 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 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_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#[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#[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 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#[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#[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#[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#[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 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 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}