Skip to main content

max / goingson

26.7 KB · 774 lines History Blame Raw
1 //! Generic OAuth2 provider trait and types.
2 //!
3 //! Defines a common interface for OAuth2 authentication across different
4 //! email providers (Fastmail, Gmail, Outlook, etc.).
5 //!
6 //! Providers can use default implementations for `exchange_code`, `refresh_token`,
7 //! and `get_user_email` by implementing the simpler configuration methods, or
8 //! override them for provider-specific behavior.
9
10 use async_trait::async_trait;
11 use base64::{engine::general_purpose::{URL_SAFE_NO_PAD, STANDARD}, Engine};
12 use rand::Rng;
13 use serde::{Deserialize, Serialize};
14 use sha2::{Digest, Sha256};
15
16 /// Result of starting an OAuth flow.
17 #[derive(Debug, Clone, Serialize)]
18 #[serde(rename_all = "camelCase")]
19 pub struct OAuthStartResult {
20 /// URL to open in the user's browser.
21 pub auth_url: String,
22 /// CSRF state token to verify on callback.
23 pub state: String,
24 /// Local port for the callback server.
25 pub port: u16,
26 /// PKCE code verifier (frontend stores for token exchange).
27 pub code_verifier: String,
28 /// Provider identifier for routing.
29 pub provider: String,
30 }
31
32 /// Token response from OAuth2 token exchange.
33 #[derive(Debug, Clone, Serialize, Deserialize)]
34 pub struct TokenResult {
35 /// Access token for API calls.
36 pub access_token: String,
37 /// Refresh token for obtaining new access tokens.
38 pub refresh_token: Option<String>,
39 /// Token expiration in seconds.
40 pub expires_in: Option<u64>,
41 /// Token type (usually "Bearer").
42 pub token_type: String,
43 /// ID token (for OpenID Connect providers like Google/Microsoft).
44 pub id_token: Option<String>,
45 /// Email address (extracted from token or discovery).
46 #[serde(skip_deserializing)]
47 pub email: Option<String>,
48 }
49
50 /// Configuration for an OAuth2 provider.
51 #[derive(Debug, Clone)]
52 pub struct OAuthProviderConfig {
53 /// Authorization endpoint URL.
54 pub auth_url: String,
55 /// Token exchange endpoint URL.
56 pub token_url: String,
57 /// RFC 7009 token revocation endpoint (if the provider offers one).
58 /// Used to invalidate the refresh token when an account is disconnected.
59 pub revoke_url: Option<String>,
60 /// Scopes required for email access.
61 pub scopes: Vec<String>,
62 /// Whether this provider uses JMAP (vs IMAP with XOAUTH2).
63 pub uses_jmap: bool,
64 /// JMAP session discovery URL (if uses_jmap).
65 pub jmap_session_url: Option<String>,
66 /// IMAP server hostname (if not uses_jmap).
67 pub imap_server: Option<String>,
68 /// IMAP server port (if not uses_jmap).
69 pub imap_port: Option<u16>,
70 /// SMTP server hostname (if not uses_jmap).
71 pub smtp_server: Option<String>,
72 /// SMTP server port (if not uses_jmap).
73 pub smtp_port: Option<u16>,
74 /// URL for fetching user info (email address).
75 pub userinfo_url: Option<String>,
76 /// JSON path to email in userinfo response (e.g., "email", "mail", "username").
77 pub email_json_path: Vec<&'static str>,
78 }
79
80 /// How to send client credentials in token requests.
81 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
82 pub enum ClientAuthMethod {
83 /// Send client_id and client_secret in the request body (default).
84 #[default]
85 FormBody,
86 /// Send credentials via HTTP Basic auth header.
87 BasicAuth,
88 /// Only send client_id (no secret required, e.g., PKCE-only flows).
89 ClientIdOnly,
90 }
91
92 /// Trait for OAuth2 email providers.
93 ///
94 /// Provides default implementations for `exchange_code`, `refresh_token`, and
95 /// `get_user_email` that work for most OAuth2 providers. Override these methods
96 /// only when provider-specific behavior is needed.
97 #[async_trait]
98 pub trait OAuthProvider: Send + Sync + 'static {
99 /// Returns the provider's identifier (e.g., "fastmail", "google", "microsoft").
100 fn id(&self) -> &'static str;
101
102 /// Returns a human-readable name for the provider.
103 fn display_name(&self) -> &'static str;
104
105 /// Returns the provider configuration.
106 fn config(&self) -> &OAuthProviderConfig;
107
108 /// Returns the OAuth2 client ID.
109 fn client_id(&self) -> &str;
110
111 /// Returns the OAuth2 client secret (if required).
112 fn client_secret(&self) -> Option<&str> {
113 None
114 }
115
116 /// Returns how client credentials should be sent in token requests.
117 fn client_auth_method(&self) -> ClientAuthMethod {
118 ClientAuthMethod::FormBody
119 }
120
121 /// Starts the OAuth2 authorization flow.
122 ///
123 /// Returns the authorization URL to open in the browser and the data needed
124 /// to complete the flow after the user authorizes.
125 fn start_auth(&self, redirect_port: u16) -> OAuthStartResult {
126 let code_verifier = generate_code_verifier();
127 let code_challenge = generate_code_challenge(&code_verifier);
128 let state = generate_state();
129
130 let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port);
131 let config = self.config();
132
133 // Build authorization URL with PKCE
134 let mut auth_url = format!(
135 "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
136 config.auth_url,
137 urlencoding::encode(self.client_id()),
138 urlencoding::encode(&redirect_uri),
139 urlencoding::encode(&config.scopes.join(" ")),
140 urlencoding::encode(&state),
141 urlencoding::encode(&code_challenge),
142 );
143
144 // Add provider-specific parameters
145 self.customize_auth_url(&mut auth_url);
146
147 OAuthStartResult {
148 auth_url,
149 state,
150 port: redirect_port,
151 code_verifier,
152 provider: self.id().to_string(),
153 }
154 }
155
156 /// Hook to customize the authorization URL with provider-specific parameters.
157 fn customize_auth_url(&self, _url: &mut String) {}
158
159 /// Exchanges an authorization code for access and refresh tokens.
160 ///
161 /// Default implementation handles standard OAuth2 token exchange with PKCE.
162 /// Respects `client_auth_method()` for credential handling.
163 #[tracing::instrument(skip_all)]
164 async fn exchange_code(
165 &self,
166 code: &str,
167 code_verifier: &str,
168 redirect_port: u16,
169 ) -> Result<TokenResult, String> {
170 let redirect_uri = format!("http://127.0.0.1:{}/", redirect_port);
171 let config = self.config();
172
173 let client = reqwest::Client::builder()
174 .timeout(std::time::Duration::from_secs(15))
175 .connect_timeout(std::time::Duration::from_secs(10))
176 .build()
177 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
178 let mut request = client.post(&config.token_url);
179
180 // Build form params based on auth method
181 let mut form_params: Vec<(&str, &str)> = vec![
182 ("grant_type", "authorization_code"),
183 ("code", code),
184 ("redirect_uri", &redirect_uri),
185 ("code_verifier", code_verifier),
186 ];
187
188 match self.client_auth_method() {
189 ClientAuthMethod::BasicAuth => {
190 // Send credentials via Basic auth header
191 if let Some(secret) = self.client_secret() {
192 let credentials = format!("{}:{}", self.client_id(), secret);
193 request = request.header(
194 "Authorization",
195 format!("Basic {}", STANDARD.encode(credentials.as_bytes()))
196 );
197 }
198 }
199 ClientAuthMethod::FormBody => {
200 // Send credentials in form body
201 form_params.push(("client_id", self.client_id()));
202 if let Some(secret) = self.client_secret() {
203 form_params.push(("client_secret", secret));
204 }
205 }
206 ClientAuthMethod::ClientIdOnly => {
207 // Only client_id, no secret
208 form_params.push(("client_id", self.client_id()));
209 }
210 }
211
212 let response = request
213 .form(&form_params)
214 .send()
215 .await
216 .map_err(|e| format!("Token request failed: {}", e))?;
217
218 if !response.status().is_success() {
219 let status = response.status();
220 let body = response.text().await.unwrap_or_default();
221 return Err(format!("Token exchange failed ({}): {}", status, body));
222 }
223
224 response
225 .json()
226 .await
227 .map_err(|e| format!("Failed to parse token response: {}", e))
228 }
229
230 /// Refreshes an expired access token using a refresh token.
231 ///
232 /// Default implementation handles standard OAuth2 token refresh.
233 #[tracing::instrument(skip_all)]
234 async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResult, String> {
235 let config = self.config();
236
237 let client = reqwest::Client::builder()
238 .timeout(std::time::Duration::from_secs(15))
239 .connect_timeout(std::time::Duration::from_secs(10))
240 .build()
241 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
242 let mut request = client.post(&config.token_url);
243
244 let mut form_params: Vec<(&str, &str)> = vec![
245 ("grant_type", "refresh_token"),
246 ("refresh_token", refresh_token),
247 ];
248
249 match self.client_auth_method() {
250 ClientAuthMethod::BasicAuth => {
251 if let Some(secret) = self.client_secret() {
252 let credentials = format!("{}:{}", self.client_id(), secret);
253 request = request.header(
254 "Authorization",
255 format!("Basic {}", STANDARD.encode(credentials.as_bytes()))
256 );
257 }
258 }
259 ClientAuthMethod::FormBody => {
260 form_params.push(("client_id", self.client_id()));
261 if let Some(secret) = self.client_secret() {
262 form_params.push(("client_secret", secret));
263 }
264 }
265 ClientAuthMethod::ClientIdOnly => {
266 form_params.push(("client_id", self.client_id()));
267 }
268 }
269
270 let response = request
271 .form(&form_params)
272 .send()
273 .await
274 .map_err(|e| format!("Token refresh request failed: {}", e))?;
275
276 if !response.status().is_success() {
277 let status = response.status();
278 let body = response.text().await.unwrap_or_default();
279 return Err(format!("Token refresh failed ({}): {}", status, body));
280 }
281
282 response
283 .json()
284 .await
285 .map_err(|e| format!("Failed to parse token response: {}", e))
286 }
287
288 /// Revokes a refresh (or access) token at the provider (RFC 7009).
289 ///
290 /// Best-effort: a no-op success when the provider has no revocation
291 /// endpoint configured (e.g. Microsoft, which only supports session
292 /// logout). Used on account disconnect so a leaked refresh token can't be
293 /// used after the user removes the account locally.
294 #[tracing::instrument(skip_all)]
295 async fn revoke_token(&self, token: &str) -> Result<(), String> {
296 let Some(revoke_url) = self.config().revoke_url.as_ref() else {
297 return Ok(());
298 };
299
300 let client = reqwest::Client::builder()
301 .timeout(std::time::Duration::from_secs(15))
302 .connect_timeout(std::time::Duration::from_secs(10))
303 .build()
304 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
305
306 let mut form_params: Vec<(&str, &str)> = vec![("token", token)];
307 let mut request = client.post(revoke_url);
308 match self.client_auth_method() {
309 ClientAuthMethod::BasicAuth => {
310 if let Some(secret) = self.client_secret() {
311 let credentials = format!("{}:{}", self.client_id(), secret);
312 request = request.header(
313 "Authorization",
314 format!("Basic {}", STANDARD.encode(credentials.as_bytes())),
315 );
316 }
317 }
318 ClientAuthMethod::FormBody => {
319 form_params.push(("client_id", self.client_id()));
320 if let Some(secret) = self.client_secret() {
321 form_params.push(("client_secret", secret));
322 }
323 }
324 ClientAuthMethod::ClientIdOnly => {
325 form_params.push(("client_id", self.client_id()));
326 }
327 }
328
329 let response = request
330 .form(&form_params)
331 .send()
332 .await
333 .map_err(|e| format!("Token revocation request failed: {}", e))?;
334
335 if !response.status().is_success() {
336 let status = response.status();
337 let body = response.text().await.unwrap_or_default();
338 return Err(format!("Token revocation failed ({}): {}", status, body));
339 }
340 Ok(())
341 }
342
343 /// Extracts the user's email address from the token response or via API call.
344 ///
345 /// Default implementation fetches from `config.userinfo_url` and extracts
346 /// email using `config.email_json_path`. Override for custom behavior.
347 async fn get_user_email(&self, access_token: &str) -> Result<String, String> {
348 let config = self.config();
349 let userinfo_url = config.userinfo_url.as_ref()
350 .ok_or_else(|| "No userinfo URL configured".to_string())?;
351
352 let client = reqwest::Client::builder()
353 .timeout(std::time::Duration::from_secs(15))
354 .connect_timeout(std::time::Duration::from_secs(10))
355 .build()
356 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
357 let response = client
358 .get(userinfo_url)
359 .bearer_auth(access_token)
360 .send()
361 .await
362 .map_err(|e| format!("Userinfo request failed: {}", e))?;
363
364 if !response.status().is_success() {
365 let status = response.status();
366 let body = response.text().await.unwrap_or_default();
367 return Err(format!("Userinfo request failed ({}): {}", status, body));
368 }
369
370 let userinfo: serde_json::Value = response
371 .json()
372 .await
373 .map_err(|e| format!("Failed to parse userinfo response: {}", e))?;
374
375 // Try each path in order until we find an email
376 for path in &config.email_json_path {
377 if let Some(email) = userinfo[*path].as_str() {
378 return Ok(email.to_string());
379 }
380 }
381
382 Err("No email found in userinfo response".to_string())
383 }
384 }
385
386 // ============ Helper Functions ============
387
388 /// Generates a cryptographically secure random string for PKCE code verifier.
389 pub fn generate_code_verifier() -> String {
390 let mut rng = rand::rng();
391 let bytes: Vec<u8> = (0..32).map(|_| rng.random()).collect();
392 URL_SAFE_NO_PAD.encode(bytes)
393 }
394
395 /// Generates the PKCE code challenge from the verifier.
396 pub fn generate_code_challenge(verifier: &str) -> String {
397 let mut hasher = Sha256::new();
398 hasher.update(verifier.as_bytes());
399 let hash = hasher.finalize();
400 URL_SAFE_NO_PAD.encode(hash)
401 }
402
403 /// Generates a random state token for CSRF protection.
404 pub fn generate_state() -> String {
405 let mut rng = rand::rng();
406 let bytes: Vec<u8> = (0..16).map(|_| rng.random()).collect();
407 URL_SAFE_NO_PAD.encode(bytes)
408 }
409
410 /// URL encoding helper (minimal implementation for OAuth params).
411 pub mod urlencoding {
412 pub fn encode(s: &str) -> String {
413 let mut result = String::with_capacity(s.len() * 3);
414 for c in s.chars() {
415 match c {
416 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
417 _ => {
418 for b in c.to_string().as_bytes() {
419 result.push_str(&format!("%{:02X}", b));
420 }
421 }
422 }
423 }
424 result
425 }
426 }
427
428 #[cfg(test)]
429 mod tests {
430 use super::*;
431
432 // ============ PKCE Code Verifier Tests ============
433
434 #[test]
435 fn code_verifier_is_base64url_encoded() {
436 let verifier = generate_code_verifier();
437 // base64url-no-pad uses only: A-Z, a-z, 0-9, -, _
438 assert!(verifier.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
439 }
440
441 #[test]
442 fn code_verifier_has_expected_length() {
443 let verifier = generate_code_verifier();
444 // 32 random bytes -> base64url(32) = ceil(32*4/3) = 43 chars (no padding)
445 assert_eq!(verifier.len(), 43);
446 }
447
448 #[test]
449 fn code_verifier_is_unique() {
450 let v1 = generate_code_verifier();
451 let v2 = generate_code_verifier();
452 assert_ne!(v1, v2, "Two generated verifiers should be different");
453 }
454
455 // ============ PKCE Code Challenge Tests ============
456
457 #[test]
458 fn code_challenge_is_base64url_encoded() {
459 let verifier = generate_code_verifier();
460 let challenge = generate_code_challenge(&verifier);
461 assert!(challenge.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
462 }
463
464 #[test]
465 fn code_challenge_has_sha256_length() {
466 let verifier = generate_code_verifier();
467 let challenge = generate_code_challenge(&verifier);
468 // SHA-256 = 32 bytes -> base64url(32) = 43 chars
469 assert_eq!(challenge.len(), 43);
470 }
471
472 #[test]
473 fn code_challenge_is_deterministic() {
474 let verifier = "test_verifier_1234567890abcdef";
475 let c1 = generate_code_challenge(verifier);
476 let c2 = generate_code_challenge(verifier);
477 assert_eq!(c1, c2);
478 }
479
480 #[test]
481 fn code_challenge_differs_for_different_verifiers() {
482 let c1 = generate_code_challenge("verifier_a");
483 let c2 = generate_code_challenge("verifier_b");
484 assert_ne!(c1, c2);
485 }
486
487 #[test]
488 fn code_challenge_matches_known_value() {
489 // RFC 7636 Appendix B test vector:
490 // verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
491 // expected challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
492 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
493 let challenge = generate_code_challenge(verifier);
494 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
495 }
496
497 // ============ State Token Tests ============
498
499 #[test]
500 fn state_is_base64url_encoded() {
501 let state = generate_state();
502 assert!(state.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
503 }
504
505 #[test]
506 fn state_has_expected_length() {
507 let state = generate_state();
508 // 16 random bytes -> base64url(16) = ceil(16*4/3) = 22 chars
509 assert_eq!(state.len(), 22);
510 }
511
512 #[test]
513 fn state_is_unique() {
514 let s1 = generate_state();
515 let s2 = generate_state();
516 assert_ne!(s1, s2, "Two generated state tokens should be different");
517 }
518
519 // ============ URL Encoding Tests ============
520
521 #[test]
522 fn encode_plain_string() {
523 assert_eq!(urlencoding::encode("hello"), "hello");
524 }
525
526 #[test]
527 fn encode_spaces() {
528 assert_eq!(urlencoding::encode("hello world"), "hello%20world");
529 }
530
531 #[test]
532 fn encode_special_characters() {
533 assert_eq!(urlencoding::encode("a=b&c=d"), "a%3Db%26c%3Dd");
534 }
535
536 #[test]
537 fn encode_preserves_unreserved_characters() {
538 // RFC 3986: unreserved = A-Z a-z 0-9 - . _ ~
539 let unreserved = "abcXYZ012-._~";
540 assert_eq!(urlencoding::encode(unreserved), unreserved);
541 }
542
543 #[test]
544 fn encode_colons_and_slashes() {
545 assert_eq!(
546 urlencoding::encode("http://example.com"),
547 "http%3A%2F%2Fexample.com"
548 );
549 }
550
551 #[test]
552 fn encode_empty_string() {
553 assert_eq!(urlencoding::encode(""), "");
554 }
555
556 #[test]
557 fn encode_scope_string() {
558 let scopes = "urn:ietf:params:jmap:core urn:ietf:params:jmap:mail";
559 let encoded = urlencoding::encode(scopes);
560 assert!(encoded.contains("%3A"));
561 assert!(encoded.contains("%20"));
562 assert!(!encoded.contains(' '));
563 assert!(!encoded.contains(':'));
564 }
565
566 // ============ start_auth URL Construction Tests ============
567
568 /// Minimal provider implementation for testing start_auth.
569 struct TestProvider {
570 id: &'static str,
571 client_id: String,
572 config: OAuthProviderConfig,
573 }
574
575 impl TestProvider {
576 fn new() -> Self {
577 Self {
578 id: "test",
579 client_id: "test_client_id".to_string(),
580 config: OAuthProviderConfig {
581 auth_url: "https://auth.example.com/authorize".to_string(),
582 token_url: "https://auth.example.com/token".to_string(),
583 revoke_url: None,
584 scopes: vec!["scope1".to_string(), "scope2".to_string()],
585 uses_jmap: false,
586 jmap_session_url: None,
587 imap_server: Some("imap.example.com".to_string()),
588 imap_port: Some(993),
589 smtp_server: Some("smtp.example.com".to_string()),
590 smtp_port: Some(587),
591 userinfo_url: Some("https://auth.example.com/userinfo".to_string()),
592 email_json_path: vec!["email"],
593 },
594 }
595 }
596 }
597
598 #[async_trait]
599 impl OAuthProvider for TestProvider {
600 fn id(&self) -> &'static str {
601 self.id
602 }
603
604 fn display_name(&self) -> &'static str {
605 "Test Provider"
606 }
607
608 fn config(&self) -> &OAuthProviderConfig {
609 &self.config
610 }
611
612 fn client_id(&self) -> &str {
613 &self.client_id
614 }
615 }
616
617 #[test]
618 fn start_auth_returns_correct_provider() {
619 let provider = TestProvider::new();
620 let result = provider.start_auth(12345);
621 assert_eq!(result.provider, "test");
622 }
623
624 #[test]
625 fn start_auth_returns_correct_port() {
626 let provider = TestProvider::new();
627 let result = provider.start_auth(12345);
628 assert_eq!(result.port, 12345);
629 }
630
631 #[test]
632 fn start_auth_url_contains_auth_endpoint() {
633 let provider = TestProvider::new();
634 let result = provider.start_auth(12345);
635 assert!(result.auth_url.starts_with("https://auth.example.com/authorize?"));
636 }
637
638 #[test]
639 fn start_auth_url_contains_client_id() {
640 let provider = TestProvider::new();
641 let result = provider.start_auth(12345);
642 assert!(result.auth_url.contains("client_id=test_client_id"));
643 }
644
645 #[test]
646 fn start_auth_url_contains_redirect_uri() {
647 let provider = TestProvider::new();
648 let result = provider.start_auth(12345);
649 // redirect_uri=http://127.0.0.1:12345/ (URL-encoded)
650 let encoded_redirect = urlencoding::encode("http://127.0.0.1:12345/");
651 assert!(
652 result.auth_url.contains(&format!("redirect_uri={}", encoded_redirect)),
653 "Auth URL should contain redirect_uri with correct port. URL: {}",
654 result.auth_url
655 );
656 }
657
658 #[test]
659 fn start_auth_url_contains_response_type_code() {
660 let provider = TestProvider::new();
661 let result = provider.start_auth(12345);
662 assert!(result.auth_url.contains("response_type=code"));
663 }
664
665 #[test]
666 fn start_auth_url_contains_scopes() {
667 let provider = TestProvider::new();
668 let result = provider.start_auth(12345);
669 // "scope1 scope2" URL-encoded as "scope1%20scope2"
670 assert!(result.auth_url.contains("scope=scope1%20scope2"));
671 }
672
673 #[test]
674 fn start_auth_url_contains_pkce_challenge() {
675 let provider = TestProvider::new();
676 let result = provider.start_auth(12345);
677 assert!(result.auth_url.contains("code_challenge="));
678 assert!(result.auth_url.contains("code_challenge_method=S256"));
679 }
680
681 #[test]
682 fn start_auth_url_contains_state() {
683 let provider = TestProvider::new();
684 let result = provider.start_auth(12345);
685 assert!(result.auth_url.contains(&format!("state={}", urlencoding::encode(&result.state))));
686 }
687
688 #[test]
689 fn start_auth_code_verifier_is_nonempty() {
690 let provider = TestProvider::new();
691 let result = provider.start_auth(12345);
692 assert!(!result.code_verifier.is_empty());
693 }
694
695 #[test]
696 fn start_auth_state_is_nonempty() {
697 let provider = TestProvider::new();
698 let result = provider.start_auth(12345);
699 assert!(!result.state.is_empty());
700 }
701
702 #[test]
703 fn start_auth_challenge_matches_verifier() {
704 let provider = TestProvider::new();
705 let result = provider.start_auth(12345);
706
707 // The code_challenge in the URL should match SHA256(code_verifier)
708 let expected_challenge = generate_code_challenge(&result.code_verifier);
709 assert!(
710 result.auth_url.contains(&format!("code_challenge={}", urlencoding::encode(&expected_challenge))),
711 "code_challenge in URL should match SHA256 of code_verifier"
712 );
713 }
714
715 // ============ ClientAuthMethod Tests ============
716
717 #[test]
718 fn client_auth_method_default_is_form_body() {
719 let method = ClientAuthMethod::default();
720 assert_eq!(method, ClientAuthMethod::FormBody);
721 }
722
723 // ============ OAuthStartResult Tests ============
724
725 #[test]
726 fn oauth_start_result_fields() {
727 let result = OAuthStartResult {
728 auth_url: "https://example.com/auth".to_string(),
729 state: "abc123".to_string(),
730 port: 8080,
731 code_verifier: "verifier_xyz".to_string(),
732 provider: "test".to_string(),
733 };
734 assert_eq!(result.auth_url, "https://example.com/auth");
735 assert_eq!(result.state, "abc123");
736 assert_eq!(result.port, 8080);
737 assert_eq!(result.code_verifier, "verifier_xyz");
738 assert_eq!(result.provider, "test");
739 }
740
741 // ============ TokenResult Tests ============
742
743 #[test]
744 fn token_result_deserialization() {
745 let json = r#"{
746 "access_token": "ya29.xxx",
747 "refresh_token": "1//xxx",
748 "expires_in": 3600,
749 "token_type": "Bearer",
750 "id_token": null
751 }"#;
752 let result: TokenResult = serde_json::from_str(json).unwrap();
753 assert_eq!(result.access_token, "ya29.xxx");
754 assert_eq!(result.refresh_token.as_deref(), Some("1//xxx"));
755 assert_eq!(result.expires_in, Some(3600));
756 assert_eq!(result.token_type, "Bearer");
757 assert!(result.id_token.is_none());
758 // email is skip_deserializing, so always None from JSON
759 assert!(result.email.is_none());
760 }
761
762 #[test]
763 fn token_result_minimal_deserialization() {
764 let json = r#"{
765 "access_token": "tok",
766 "token_type": "Bearer"
767 }"#;
768 let result: TokenResult = serde_json::from_str(json).unwrap();
769 assert_eq!(result.access_token, "tok");
770 assert!(result.refresh_token.is_none());
771 assert!(result.expires_in.is_none());
772 }
773 }
774