Skip to main content

max / goingson

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