1mod discord;
2mod generic;
3mod github;
4mod gitlab;
5mod google;
6pub(super) mod remaining_profile;
7pub use generic::{
8 GenericOAuthConfig, GenericOAuthError, GenericOAuthMetadata, GenericOAuthResolved,
9};
10mod roblox;
11pub use roblox::RobloxOptions;
12mod salesforce;
13pub use salesforce::{SalesforceEnvironment, SalesforceOptions};
14mod slack;
15pub use slack::SlackOptions;
16mod spotify;
17pub use spotify::SpotifyOptions;
18mod tiktok;
19pub use tiktok::TikTokOptions;
20mod twitch;
21pub use twitch::TwitchOptions;
22mod twitter;
23pub use twitter::TwitterOptions;
24mod vercel;
25pub use vercel::VercelOptions;
26mod vk;
27pub use vk::VkOptions;
28mod wechat;
29pub use wechat::{WeChatLanguage, WeChatOptions};
30mod zoom;
31pub use zoom::ZoomOptions;
32
33mod reddit;
34pub use reddit::RedditOptions;
35mod railway;
36pub use railway::RailwayOptions;
37mod paypal;
38pub use paypal::{PayPalEnvironment, PayPalOptions};
39
40mod paybin;
41pub use paybin::PaybinOptions;
42mod polar;
43pub use polar::PolarOptions;
44
45mod notion;
46pub use notion::NotionOptions;
47
48mod cloudflare;
49pub use cloudflare::CloudflareOptions;
50
51mod cognito;
52pub use cognito::CognitoOptions;
53
54mod dropbox;
55pub use dropbox::{DropboxAccessType, DropboxOptions};
56mod figma;
57pub use figma::FigmaOptions;
58
59mod facebook;
60pub use facebook::FacebookOptions;
61
62mod atlassian;
63pub use atlassian::AtlassianOptions;
64
65mod apple;
66pub use apple::AppleOptions;
67
68mod microsoft;
69use async_trait::async_trait;
70use chrono::{DateTime, Utc};
71pub use microsoft::{MicrosoftOptions, MicrosoftProfilePhotoSize};
72use serde::Deserialize;
73use serde::de::DeserializeOwned;
74use serde_json::Value;
75use std::collections::HashMap;
76use std::sync::Arc;
77
78#[derive(Debug, Clone)]
80pub struct OAuthEndSessionConfig {
81 pub endpoint: String,
82 pub post_logout_redirect_uri: Option<String>,
83}
84
85#[derive(Clone, Default)]
87pub struct OAuthConfig {
88 pub providers: HashMap<String, OAuthProvider>,
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct OAuthTokenSet {
93 pub token_type: Option<String>,
94 pub access_token: Option<String>,
95 pub refresh_token: Option<String>,
96 pub access_token_expires_at: Option<DateTime<Utc>>,
97 pub refresh_token_expires_at: Option<DateTime<Utc>>,
98 pub scopes: Vec<String>,
99 pub id_token: Option<String>,
100 pub raw: Option<Value>,
101}
102
103#[derive(Debug, Clone)]
105pub struct OAuthUserInfo {
106 pub additional_fields: alibi_core::field_policy::FieldOutput,
108 pub id: String,
109 pub email: String,
110 pub name: Option<String>,
111 pub image: Option<String>,
112 pub email_verified: bool,
113}
114
115impl OAuthUserInfo {
116 #[must_use]
118 pub fn public_profile(&self, include_id: bool) -> alibi_core::field_policy::FieldOutput {
119 let mut output = self.additional_fields.clone();
120 _ = output.insert("email".into(), Value::String(self.email.clone()));
121 _ = output.insert("emailVerified".into(), Value::Bool(self.email_verified));
122 if include_id {
123 _ = output.insert("id".into(), Value::String(self.id.clone()));
124 }
125 if let Some(name) = &self.name {
126 _ = output.insert("name".into(), Value::String(name.clone()));
127 }
128 if let Some(image) = &self.image {
129 _ = output.insert("image".into(), Value::String(image.clone()));
130 }
131 output
132 }
133}
134
135#[derive(Debug, Clone, Default, Deserialize)]
136pub struct OAuthCallbackUserPayload {
137 pub name: Option<OAuthCallbackUserName>,
138 pub email: Option<String>,
139}
140
141#[derive(Debug, Clone, Default, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct OAuthCallbackUserName {
144 pub first_name: Option<String>,
145 pub last_name: Option<String>,
146}
147
148#[derive(Debug, Clone, Default)]
149pub struct OAuthUserInfoRequest {
150 pub token_type: Option<String>,
151 pub access_token: Option<String>,
152 pub refresh_token: Option<String>,
153 pub access_token_expires_at: Option<DateTime<Utc>>,
154 pub refresh_token_expires_at: Option<DateTime<Utc>>,
155 pub scopes: Vec<String>,
156 pub id_token: Option<String>,
157 pub raw: Option<Value>,
158 pub user: Option<OAuthCallbackUserPayload>,
159}
160
161#[derive(Debug, Clone)]
162pub struct OAuthUserInfoResponse {
163 pub user_output: Option<alibi_core::field_policy::FieldOutput>,
165 pub user: OAuthUserInfo,
166 pub data: Value,
167}
168
169pub type OAuthAccountSubject = fn(&Value) -> Result<String, String>;
171
172#[async_trait]
173pub trait OAuthUserInfoHandler: Send + Sync {
174 fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthUserInfoHandler>> {
176 None
177 }
178
179 fn errors_are_exceptions(&self) -> bool {
182 true
183 }
184
185 fn mapped_handler(
188 &self,
189 _mapper: Arc<dyn OAuthProfileMapper>,
190 ) -> Option<Arc<dyn OAuthUserInfoHandler>> {
191 None
192 }
193
194 async fn get_user_info(
195 &self,
196 request: OAuthUserInfoRequest,
197 ) -> Result<OAuthUserInfoResponse, String>;
198}
199
200#[async_trait]
204pub trait OAuthProfileMapper: Send + Sync {
205 async fn map_profile(
206 &self,
207 profile: Value,
208 ) -> Result<alibi_core::field_policy::FieldOutput, String>;
209}
210
211pub(super) fn apply_application_mapping(
212 response: &mut OAuthUserInfoResponse,
213 mapped: alibi_core::field_policy::FieldOutput,
214) -> Result<(), String> {
215 let output = response
216 .user_output
217 .get_or_insert_with(|| response.user.public_profile(true));
218 output.extend(mapped.clone());
219 for (key, value) in mapped {
220 match key.as_str() {
221 "id" => response.user.id = remaining_profile::js_string(&value)?,
222 "email" => response.user.email = value.as_str().unwrap_or_default().into(),
223 "emailVerified" => response.user.email_verified = remaining_profile::truthy(&value),
224 "name" => {
225 response.user.name = remaining_profile::scalar(
226 Some(&value).filter(|value| remaining_profile::truthy(value)),
227 )?;
228 }
229 "image" => response.user.image = remaining_profile::scalar(Some(&value))?,
230 _ => {
231 _ = response.user.additional_fields.insert(key, value);
232 }
233 }
234 }
235 Ok(())
236}
237
238#[derive(Clone, Copy)]
242pub struct OAuthRefreshContext<'a> {
243 pub request: &'a alibi_core::AuthRequest,
244}
245
246#[async_trait]
248pub trait OAuthRefreshTokenParamsResolver: Send + Sync {
249 async fn resolve(
250 &self,
251 context: Option<OAuthRefreshContext<'_>>,
252 ) -> Result<Option<std::collections::BTreeMap<String, String>>, String>;
253}
254
255#[derive(Clone)]
256pub struct OAuthRefreshTokenParams(pub Arc<dyn OAuthRefreshTokenParamsResolver>);
257impl std::fmt::Debug for OAuthRefreshTokenParams {
258 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 formatter.write_str("OAuthRefreshTokenParams(..)")
260 }
261}
262
263#[async_trait]
264pub trait OAuthRefreshTokenHandler: Send + Sync {
265 fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthRefreshTokenHandler>> {
267 None
268 }
269
270 async fn refresh_access_token(&self, refresh_token: &str) -> Result<OAuthTokenSet, String>;
271
272 async fn refresh_access_token_with_context(
275 &self,
276 refresh_token: &str,
277 _context: Option<OAuthRefreshContext<'_>>,
278 ) -> Result<OAuthTokenSet, String> {
279 self.refresh_access_token(refresh_token).await
280 }
281}
282
283#[async_trait]
284pub trait OAuthIdTokenVerifier: Send + Sync {
285 async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String>;
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum OAuthTokenGrant {
291 AuthorizationCode,
292 RefreshToken,
293}
294
295#[derive(Debug, Clone)]
296pub struct OAuthClientAssertionContext {
297 pub client_id: String,
298 pub token_endpoint: String,
299 pub grant_type: OAuthTokenGrant,
300}
301
302#[async_trait]
304pub trait OAuthClientAssertionGetter: Send + Sync {
305 async fn get_client_assertion(
306 &self,
307 context: OAuthClientAssertionContext,
308 ) -> Result<String, String>;
309}
310
311#[derive(Clone)]
313pub struct OAuthClientAssertion(pub Arc<dyn OAuthClientAssertionGetter>);
314
315impl std::fmt::Debug for OAuthClientAssertion {
316 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317 f.debug_struct("OAuthClientAssertion")
318 .finish_non_exhaustive()
319 }
320}
321
322#[derive(Clone)]
324#[expect(
325 clippy::struct_excessive_bools,
326 reason = "Preserve independent public provider policy switches"
327)]
328pub struct OAuthProvider {
329 pub client_id: String,
330 pub additional_client_ids: Vec<String>,
332 pub hosted_domain: Option<String>,
334 pub require_email_verification: bool,
336 pub client_secret: String,
337 pub auth_url: String,
338 pub token_url: String,
339 pub user_info_url: Option<String>,
340 pub scopes: Vec<String>,
341 pub authorization: Option<OAuthAuthorizationPolicy>,
345 pub authorization_params: Vec<(String, String)>,
346 pub account_subject: Option<OAuthAccountSubject>,
348 pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
349 pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
350 pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
351 pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
353 pub id_token: Option<super::id_token::OAuthIdTokenConfig>,
354 pub disable_id_token_sign_in: bool,
356 pub disable_implicit_sign_up: bool,
357 pub disable_sign_up: bool,
358 pub allow_idp_initiated: bool,
360 pub override_user_info_on_sign_in: bool,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum OAuthScopeOrder {
366 ConfiguredThenRequested,
367 RequestedThenConfigured,
368}
369
370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372pub enum OAuthScopeEncoding {
373 #[default]
374 Form,
375 UriComponent,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub enum OAuthTokenEndpointAuth {
381 ClientSecretBasic,
382 ClientSecretPost,
383 PrivateKeyJwt,
384 None,
385 ClientKeyPost,
387}
388
389#[derive(Debug, Clone)]
390pub struct OAuthAuthorizationCodeContext {
391 pub code: String,
392 pub redirect_uri: String,
393 pub code_verifier: Option<String>,
394 pub device_id: Option<String>,
395}
396
397#[async_trait]
398pub trait OAuthAuthorizationCodeHandler: Send + Sync {
399 fn configured_client_ids(
401 &self,
402 _ids: &[String],
403 ) -> Option<Arc<dyn OAuthAuthorizationCodeHandler>> {
404 None
405 }
406
407 async fn validate_authorization_code(
408 &self,
409 context: OAuthAuthorizationCodeContext,
410 ) -> Result<OAuthTokenSet, String>;
411}
412
413#[derive(Clone)]
414pub struct OAuthAuthorizationCodeCallback(pub Arc<dyn OAuthAuthorizationCodeHandler>);
415impl std::fmt::Debug for OAuthAuthorizationCodeCallback {
416 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417 formatter.write_str("OAuthAuthorizationCodeCallback(..)")
418 }
419}
420
421#[derive(Debug, Clone)]
423pub struct OAuthAccountKeyContext {
424 pub tokens: OAuthTokenSet,
425 pub profile: Value,
426}
427#[async_trait]
428pub trait OAuthAccountKeyResolver: Send + Sync {
429 async fn resolve(&self, context: OAuthAccountKeyContext) -> Result<Value, String>;
430}
431#[derive(Clone)]
432pub struct OAuthAccountKey(pub Arc<dyn OAuthAccountKeyResolver>);
433impl std::fmt::Debug for OAuthAccountKey {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 f.debug_struct("OAuthAccountKey").finish_non_exhaustive()
436 }
437}
438
439#[derive(Debug, Clone)]
443pub struct OAuthAuthorizationPolicy {
444 pub account_key: Option<OAuthAccountKey>,
446 pub callback_path: Option<String>,
448 pub disable_sign_up_option: Option<bool>,
450 pub end_session: Option<OAuthEndSessionConfig>,
452 pub verify_grant_id_token: bool,
454 pub id_token_nonce_binding: bool,
456 pub discovery_openid_scope: bool,
458 pub authorization_code: Option<OAuthAuthorizationCodeCallback>,
460 pub authorization_code_pkce: Option<bool>,
462 pub client_id_parameter: String,
464 pub literal_client_id: Option<String>,
466 pub token_expiry_always: bool,
468 pub token_response_omits_id_token: bool,
470 pub scope_separator: String,
471 pub emit_empty_scope: bool,
472 pub authorization_fragment: Option<String>,
473 pub supports_refresh: bool,
475 pub supports_profile_mapper: bool,
477 pub allow_missing_access_token: bool,
479 pub default_access_token_expires_in: Option<f64>,
481 pub configured_scopes: Vec<String>,
482 pub omit_scopes: bool,
484 pub scope_encoding: OAuthScopeEncoding,
485 pub deduplicate_scopes: bool,
487 pub require_client_id: bool,
488 pub token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
490 pub refresh_token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
492 pub fixed_authorization_params: Vec<(String, String)>,
494 pub authorization_code_client_key: Option<String>,
496 pub authorization_code_headers: Vec<(String, String)>,
498 pub authorization_code_params: std::collections::BTreeMap<String, String>,
502 pub refresh_token_params: std::collections::BTreeMap<String, String>,
506 pub refresh_token_params_resolver: Option<OAuthRefreshTokenParams>,
510 pub client_assertion: Option<OAuthClientAssertion>,
512 pub refresh_scope: Option<String>,
514 pub response_type: String,
515 pub redirect_uri: Option<String>,
517 pub response_mode: Option<String>,
518 pub require_client_secret: bool,
519 pub login_hint: bool,
520 pub disable_default_scopes: bool,
521 pub scope_order: OAuthScopeOrder,
522 pub pkce: bool,
523 pub prompt: Option<String>,
524 pub default_prompt: Option<String>,
526 pub discord_permissions: Option<f64>,
528 pub propagate_grant_profile_errors: bool,
531 pub preserve_raw_email_errors: bool,
534 pub preserve_raw_profile_scalars: bool,
536 pub honor_factory_options: bool,
538 pub source_profile_exceptions: bool,
540}
541
542impl Default for OAuthAuthorizationPolicy {
543 fn default() -> Self {
544 Self {
545 account_key: None,
546 callback_path: None,
547 disable_sign_up_option: None,
548 end_session: None,
549 verify_grant_id_token: false,
550 id_token_nonce_binding: false,
551 discovery_openid_scope: false,
552 authorization_code: None,
553 authorization_code_pkce: None,
554 client_id_parameter: "client_id".into(),
555 literal_client_id: None,
556 token_expiry_always: false,
557 token_response_omits_id_token: false,
558 scope_separator: " ".into(),
559 emit_empty_scope: false,
560 authorization_fragment: None,
561 supports_refresh: true,
562 supports_profile_mapper: true,
563 default_access_token_expires_in: None,
564 allow_missing_access_token: false,
565 configured_scopes: Vec::new(),
566 omit_scopes: false,
567 scope_encoding: OAuthScopeEncoding::Form,
568 deduplicate_scopes: false,
569 require_client_id: false,
570 token_endpoint_auth: None,
571 refresh_token_endpoint_auth: None,
572 fixed_authorization_params: Vec::new(),
573 authorization_code_client_key: None,
574 authorization_code_headers: Vec::new(),
575 authorization_code_params: std::collections::BTreeMap::new(),
576 refresh_token_params: std::collections::BTreeMap::new(),
577 refresh_token_params_resolver: None,
578 client_assertion: None,
579 refresh_scope: None,
580 response_type: "code".into(),
581 redirect_uri: None,
582 response_mode: None,
583 require_client_secret: false,
584 login_hint: true,
585 disable_default_scopes: false,
586 scope_order: OAuthScopeOrder::ConfiguredThenRequested,
587 pkce: true,
588 prompt: None,
589 default_prompt: None,
590 discord_permissions: None,
591 propagate_grant_profile_errors: false,
592 preserve_raw_email_errors: false,
593 preserve_raw_profile_scalars: false,
594 honor_factory_options: true,
595 source_profile_exceptions: false,
596 }
597 }
598}
599
600impl OAuthProvider {
601 #[must_use]
605 pub fn with_profile_mapper(mut self, mapper: Arc<dyn OAuthProfileMapper>) -> Self {
606 if self
607 .authorization
608 .as_ref()
609 .is_none_or(|policy| policy.supports_profile_mapper)
610 && let Some(handler) = self.get_user_info.take()
611 {
612 self.get_user_info = Some(handler.mapped_handler(mapper).unwrap_or(handler));
613 }
614 self
615 }
616
617 #[must_use]
618 pub fn with_client_ids(mut self, client_ids: Vec<String>) -> Self {
619 if let Some(handler) = self
620 .get_user_info
621 .as_ref()
622 .and_then(|handler| handler.configured_client_ids(&client_ids))
623 {
624 self.get_user_info = Some(handler);
625 }
626 if let Some(handler) = self
627 .refresh_access_token
628 .as_ref()
629 .and_then(|handler| handler.configured_client_ids(&client_ids))
630 {
631 self.refresh_access_token = Some(handler);
632 }
633 if let Some(policy) = self.authorization.as_mut() {
634 if policy.client_id_parameter == "appid" {
635 policy.literal_client_id = Some(client_ids.join(","));
636 }
637 if let Some(handler) = policy
638 .authorization_code
639 .as_ref()
640 .and_then(|callback| callback.0.configured_client_ids(&client_ids))
641 {
642 policy.authorization_code = Some(OAuthAuthorizationCodeCallback(handler));
643 }
644 }
645 if let Some(policy) = self.id_token.as_mut() {
646 policy.client_ids = Some(client_ids.clone());
647 }
648 let mut ids = client_ids.into_iter();
649 self.client_id = ids.next().unwrap_or_default();
650 self.additional_client_ids = ids.collect();
651 self
652 }
653
654 #[must_use]
655 pub const fn require_email_verification(mut self, required: bool) -> Self {
656 self.require_email_verification = required;
657 self
658 }
659}
660
661impl std::fmt::Debug for OAuthConfig {
662 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663 f.debug_struct("OAuthConfig").finish_non_exhaustive()
664 }
665}
666
667impl std::fmt::Debug for OAuthProvider {
668 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
669 f.debug_struct("OAuthProvider").finish_non_exhaustive()
670 }
671}
672
673mod huggingface;
674mod kakao;
675mod kick;
676mod line;
677pub use huggingface::HuggingFaceOptions;
678pub use kakao::KakaoOptions;
679pub use kick::KickOptions;
680pub use line::LineOptions;
681
682mod naver;
683pub use naver::NaverOptions;
684
685#[cfg(test)]
687mod tests {
688 use super::*;
689 use std::sync::Arc;
690 use tokio::io::{AsyncReadExt, AsyncWriteExt};
691 use tokio::sync::Mutex;
692
693 async fn start_github_mock_server(
694 profile: Value,
695 emails: Value,
696 ) -> (String, String, Arc<Mutex<Vec<String>>>) {
697 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
698 let addr = listener.local_addr().unwrap();
699 let requests = Arc::new(Mutex::new(Vec::new()));
700 let captured_requests = std::sync::Arc::clone(&requests);
701
702 tokio::spawn(async move {
703 loop {
704 let Ok((mut stream, _)) = listener.accept().await else {
705 break;
706 };
707 let profile = profile.clone();
708 let emails = emails.clone();
709 let requests_2 = std::sync::Arc::clone(&captured_requests);
710 tokio::spawn(async move {
711 let mut buffer = vec![0u8; 4096];
712 let read = stream.read(&mut buffer).await.unwrap_or(0);
713 let request = String::from_utf8_lossy(
714 (buffer)
715 .get(..read)
716 .expect("fixture contains the requested index"),
717 )
718 .to_string();
719 requests_2.lock().await.push(request.clone());
720
721 let (status, body) = if request.contains("/user/emails") {
722 ("200 OK", emails.to_string())
723 } else if request.contains("/user") {
724 ("200 OK", profile.to_string())
725 } else {
726 (
727 "404 Not Found",
728 serde_json::json!({ "error": "not found" }).to_string(),
729 )
730 };
731
732 let response = format!(
733 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
734 body.len(),
735 );
736
737 _ = stream.write_all(response.as_bytes()).await;
738 _ = stream.flush().await;
739 });
740 }
741 });
742
743 let base_url = format!("http://127.0.0.1:{}", addr.port());
744 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
745 (
746 format!("{base_url}/user"),
747 format!("{base_url}/user/emails"),
748 requests,
749 )
750 }
751
752 #[test]
754 fn github_provider_uses_ts_default_scopes() {
755 let provider = OAuthProvider::github("github-client-id", "github-client-secret");
756
757 assert_eq!(
758 provider.scopes,
759 vec!["read:user".to_owned(), "user:email".to_owned()]
760 );
761 assert!(provider.get_user_info.is_some());
762 assert!(provider.map_user_info.is_none());
763 }
764
765 #[tokio::test]
767 async fn github_provider_get_user_info_uses_email_fallback_and_login_name() {
768 let (user_url, emails_url, requests) = start_github_mock_server(
769 serde_json::json!({
770 "id": 42,
771 "login": "octocat",
772 "name": null,
773 "email": null,
774 "avatar_url": "https://avatars.githubusercontent.com/u/42?v=4",
775 }),
776 serde_json::json!([
777 {
778 "email": "octocat@example.com",
779 "primary": true,
780 "verified": true,
781 "visibility": "private"
782 },
783 {
784 "email": "secondary@example.com",
785 "primary": false,
786 "verified": false,
787 "visibility": "private"
788 }
789 ]),
790 )
791 .await;
792
793 let provider = OAuthProvider::github_with_endpoints(
794 "github-client-id",
795 "github-client-secret",
796 "https://github.com/login/oauth/authorize",
797 "https://github.com/login/oauth/access_token",
798 &user_url,
799 &emails_url,
800 );
801 let handler = provider.get_user_info.as_ref().unwrap();
802
803 let response = handler
804 .get_user_info(OAuthUserInfoRequest {
805 access_token: Some("github-access-token".to_owned()),
806 ..Default::default()
807 })
808 .await
809 .unwrap();
810
811 assert_eq!(response.user.id, "42");
812 assert_eq!(response.user.email, "octocat@example.com");
813 assert_eq!(response.user.name.as_deref(), Some("octocat"));
814 assert_eq!(
815 response.user.image.as_deref(),
816 Some("https://avatars.githubusercontent.com/u/42?v=4")
817 );
818 assert!(response.user.email_verified);
819 assert_eq!(
820 (*(response.data)
821 .get("email")
822 .expect("fixture contains the requested index")),
823 serde_json::json!("octocat@example.com")
824 );
825
826 let requests = requests.lock().await;
827 assert_eq!(requests.len(), 2);
828 for request in requests.iter() {
829 let lowered = request.to_ascii_lowercase();
830 assert!(lowered.contains("authorization: bearer github-access-token"));
831 assert!(lowered.contains("user-agent: better-auth"));
832 }
833 }
834
835 #[tokio::test]
837 async fn github_provider_get_user_info_keeps_inline_email() {
838 let (user_url, emails_url, _) = start_github_mock_server(
839 serde_json::json!({
840 "id": "github-inline-email",
841 "login": "octocat",
842 "name": "Octo Cat",
843 "email": "public@example.com",
844 "avatar_url": null,
845 }),
846 serde_json::json!([
847 {
848 "email": "primary@example.com",
849 "primary": true,
850 "verified": true,
851 "visibility": "private"
852 },
853 {
854 "email": "public@example.com",
855 "primary": false,
856 "verified": false,
857 "visibility": "public"
858 }
859 ]),
860 )
861 .await;
862
863 let provider = OAuthProvider::github_with_endpoints(
864 "github-client-id",
865 "github-client-secret",
866 "https://github.com/login/oauth/authorize",
867 "https://github.com/login/oauth/access_token",
868 &user_url,
869 &emails_url,
870 );
871 let handler = provider.get_user_info.as_ref().unwrap();
872
873 let response = handler
874 .get_user_info(OAuthUserInfoRequest {
875 access_token: Some("github-access-token".to_owned()),
876 ..Default::default()
877 })
878 .await
879 .unwrap();
880
881 assert_eq!(response.user.email, "public@example.com");
882 assert_eq!(response.user.name.as_deref(), Some("Octo Cat"));
883 assert!(!response.user.email_verified);
884 }
885}
886mod linkedin;
889pub use linkedin::LinkedInOptions;
890mod linear;
891pub use linear::LinearOptions;