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 drop(output.insert("email".into(), Value::String(self.email.clone())));
121 drop(output.insert("emailVerified".into(), Value::Bool(self.email_verified)));
122 if include_id {
123 drop(output.insert("id".into(), Value::String(self.id.clone())));
124 }
125 if let Some(name) = &self.name {
126 drop(output.insert("name".into(), Value::String(name.clone())));
127 }
128 if let Some(image) = &self.image {
129 drop(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 drop(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 override_user_info_on_sign_in: bool,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum OAuthScopeOrder {
364 ConfiguredThenRequested,
365 RequestedThenConfigured,
366}
367
368#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
370pub enum OAuthScopeEncoding {
371 #[default]
372 Form,
373 UriComponent,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum OAuthTokenEndpointAuth {
379 ClientSecretBasic,
380 ClientSecretPost,
381 PrivateKeyJwt,
382 None,
383 ClientKeyPost,
385}
386
387#[derive(Debug, Clone)]
388pub struct OAuthAuthorizationCodeContext {
389 pub code: String,
390 pub redirect_uri: String,
391 pub code_verifier: Option<String>,
392 pub device_id: Option<String>,
393}
394
395#[async_trait]
396pub trait OAuthAuthorizationCodeHandler: Send + Sync {
397 fn configured_client_ids(
399 &self,
400 _ids: &[String],
401 ) -> Option<Arc<dyn OAuthAuthorizationCodeHandler>> {
402 None
403 }
404
405 async fn validate_authorization_code(
406 &self,
407 context: OAuthAuthorizationCodeContext,
408 ) -> Result<OAuthTokenSet, String>;
409}
410
411#[derive(Clone)]
412pub struct OAuthAuthorizationCodeCallback(pub Arc<dyn OAuthAuthorizationCodeHandler>);
413impl std::fmt::Debug for OAuthAuthorizationCodeCallback {
414 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 formatter.write_str("OAuthAuthorizationCodeCallback(..)")
416 }
417}
418
419#[derive(Debug, Clone)]
421pub struct OAuthAccountKeyContext {
422 pub tokens: OAuthTokenSet,
423 pub profile: Value,
424}
425#[async_trait]
426pub trait OAuthAccountKeyResolver: Send + Sync {
427 async fn resolve(&self, context: OAuthAccountKeyContext) -> Result<Value, String>;
428}
429#[derive(Clone)]
430pub struct OAuthAccountKey(pub Arc<dyn OAuthAccountKeyResolver>);
431impl std::fmt::Debug for OAuthAccountKey {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 f.debug_struct("OAuthAccountKey").finish_non_exhaustive()
434 }
435}
436
437#[derive(Debug, Clone)]
441pub struct OAuthAuthorizationPolicy {
442 pub account_key: Option<OAuthAccountKey>,
444 pub callback_path: Option<String>,
446 pub disable_sign_up_option: Option<bool>,
448 pub end_session: Option<OAuthEndSessionConfig>,
450 pub verify_grant_id_token: bool,
452 pub id_token_nonce_binding: bool,
454 pub discovery_openid_scope: bool,
456 pub authorization_code: Option<OAuthAuthorizationCodeCallback>,
458 pub authorization_code_pkce: Option<bool>,
460 pub client_id_parameter: String,
462 pub literal_client_id: Option<String>,
464 pub token_expiry_always: bool,
466 pub token_response_omits_id_token: bool,
468 pub scope_separator: String,
469 pub emit_empty_scope: bool,
470 pub authorization_fragment: Option<String>,
471 pub supports_refresh: bool,
473 pub supports_profile_mapper: bool,
475 pub allow_missing_access_token: bool,
477 pub default_access_token_expires_in: Option<f64>,
479 pub configured_scopes: Vec<String>,
480 pub omit_scopes: bool,
482 pub scope_encoding: OAuthScopeEncoding,
483 pub deduplicate_scopes: bool,
485 pub require_client_id: bool,
486 pub token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
488 pub refresh_token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
490 pub fixed_authorization_params: Vec<(String, String)>,
492 pub authorization_code_client_key: Option<String>,
494 pub authorization_code_headers: Vec<(String, String)>,
496 pub authorization_code_params: std::collections::BTreeMap<String, String>,
500 pub refresh_token_params: std::collections::BTreeMap<String, String>,
504 pub refresh_token_params_resolver: Option<OAuthRefreshTokenParams>,
508 pub client_assertion: Option<OAuthClientAssertion>,
510 pub refresh_scope: Option<String>,
512 pub response_type: String,
513 pub redirect_uri: Option<String>,
515 pub response_mode: Option<String>,
516 pub require_client_secret: bool,
517 pub login_hint: bool,
518 pub disable_default_scopes: bool,
519 pub scope_order: OAuthScopeOrder,
520 pub pkce: bool,
521 pub prompt: Option<String>,
522 pub default_prompt: Option<String>,
524 pub discord_permissions: Option<f64>,
526 pub propagate_grant_profile_errors: bool,
529 pub preserve_raw_email_errors: bool,
532 pub preserve_raw_profile_scalars: bool,
534 pub honor_factory_options: bool,
536 pub source_profile_exceptions: bool,
538}
539
540impl Default for OAuthAuthorizationPolicy {
541 fn default() -> Self {
542 Self {
543 account_key: None,
544 callback_path: None,
545 disable_sign_up_option: None,
546 end_session: None,
547 verify_grant_id_token: false,
548 id_token_nonce_binding: false,
549 discovery_openid_scope: false,
550 authorization_code: None,
551 authorization_code_pkce: None,
552 client_id_parameter: "client_id".into(),
553 literal_client_id: None,
554 token_expiry_always: false,
555 token_response_omits_id_token: false,
556 scope_separator: " ".into(),
557 emit_empty_scope: false,
558 authorization_fragment: None,
559 supports_refresh: true,
560 supports_profile_mapper: true,
561 default_access_token_expires_in: None,
562 allow_missing_access_token: false,
563 configured_scopes: Vec::new(),
564 omit_scopes: false,
565 scope_encoding: OAuthScopeEncoding::Form,
566 deduplicate_scopes: false,
567 require_client_id: false,
568 token_endpoint_auth: None,
569 refresh_token_endpoint_auth: None,
570 fixed_authorization_params: Vec::new(),
571 authorization_code_client_key: None,
572 authorization_code_headers: Vec::new(),
573 authorization_code_params: std::collections::BTreeMap::new(),
574 refresh_token_params: std::collections::BTreeMap::new(),
575 refresh_token_params_resolver: None,
576 client_assertion: None,
577 refresh_scope: None,
578 response_type: "code".into(),
579 redirect_uri: None,
580 response_mode: None,
581 require_client_secret: false,
582 login_hint: true,
583 disable_default_scopes: false,
584 scope_order: OAuthScopeOrder::ConfiguredThenRequested,
585 pkce: true,
586 prompt: None,
587 default_prompt: None,
588 discord_permissions: None,
589 propagate_grant_profile_errors: false,
590 preserve_raw_email_errors: false,
591 preserve_raw_profile_scalars: false,
592 honor_factory_options: true,
593 source_profile_exceptions: false,
594 }
595 }
596}
597
598impl OAuthProvider {
599 #[must_use]
603 pub fn with_profile_mapper(mut self, mapper: Arc<dyn OAuthProfileMapper>) -> Self {
604 if self
605 .authorization
606 .as_ref()
607 .is_none_or(|policy| policy.supports_profile_mapper)
608 && let Some(handler) = self.get_user_info.take()
609 {
610 self.get_user_info = Some(handler.mapped_handler(mapper).unwrap_or(handler));
611 }
612 self
613 }
614
615 #[must_use]
616 pub fn with_client_ids(mut self, client_ids: Vec<String>) -> Self {
617 if let Some(handler) = self
618 .get_user_info
619 .as_ref()
620 .and_then(|handler| handler.configured_client_ids(&client_ids))
621 {
622 self.get_user_info = Some(handler);
623 }
624 if let Some(handler) = self
625 .refresh_access_token
626 .as_ref()
627 .and_then(|handler| handler.configured_client_ids(&client_ids))
628 {
629 self.refresh_access_token = Some(handler);
630 }
631 if let Some(policy) = self.authorization.as_mut() {
632 if policy.client_id_parameter == "appid" {
633 policy.literal_client_id = Some(client_ids.join(","));
634 }
635 if let Some(handler) = policy
636 .authorization_code
637 .as_ref()
638 .and_then(|callback| callback.0.configured_client_ids(&client_ids))
639 {
640 policy.authorization_code = Some(OAuthAuthorizationCodeCallback(handler));
641 }
642 }
643 if let Some(policy) = self.id_token.as_mut() {
644 policy.client_ids = Some(client_ids.clone());
645 }
646 let mut ids = client_ids.into_iter();
647 self.client_id = ids.next().unwrap_or_default();
648 self.additional_client_ids = ids.collect();
649 self
650 }
651
652 #[must_use]
653 pub const fn require_email_verification(mut self, required: bool) -> Self {
654 self.require_email_verification = required;
655 self
656 }
657}
658
659impl std::fmt::Debug for OAuthConfig {
660 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
661 f.debug_struct("OAuthConfig").finish_non_exhaustive()
662 }
663}
664
665impl std::fmt::Debug for OAuthProvider {
666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667 f.debug_struct("OAuthProvider").finish_non_exhaustive()
668 }
669}
670
671mod huggingface;
672mod kakao;
673mod kick;
674mod line;
675pub use huggingface::HuggingFaceOptions;
676pub use kakao::KakaoOptions;
677pub use kick::KickOptions;
678pub use line::LineOptions;
679
680mod naver;
681pub use naver::NaverOptions;
682
683#[cfg(test)]
685mod tests {
686 use super::*;
687 use std::sync::Arc;
688 use tokio::io::{AsyncReadExt, AsyncWriteExt};
689 use tokio::sync::Mutex;
690
691 async fn start_github_mock_server(
692 profile: Value,
693 emails: Value,
694 ) -> (String, String, Arc<Mutex<Vec<String>>>) {
695 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
696 let addr = listener.local_addr().unwrap();
697 let requests = Arc::new(Mutex::new(Vec::new()));
698 let captured_requests = std::sync::Arc::clone(&requests);
699
700 tokio::spawn(async move {
701 loop {
702 let Ok((mut stream, _)) = listener.accept().await else {
703 break;
704 };
705 let profile = profile.clone();
706 let emails = emails.clone();
707 let requests_2 = std::sync::Arc::clone(&captured_requests);
708 tokio::spawn(async move {
709 let mut buffer = vec![0u8; 4096];
710 let read = stream.read(&mut buffer).await.unwrap_or(0);
711 let request = String::from_utf8_lossy(
712 (buffer)
713 .get(..read)
714 .expect("fixture contains the requested index"),
715 )
716 .to_string();
717 requests_2.lock().await.push(request.clone());
718
719 let (status, body) = if request.contains("/user/emails") {
720 ("200 OK", emails.to_string())
721 } else if request.contains("/user") {
722 ("200 OK", profile.to_string())
723 } else {
724 (
725 "404 Not Found",
726 serde_json::json!({ "error": "not found" }).to_string(),
727 )
728 };
729
730 let response = format!(
731 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
732 body.len(),
733 );
734
735 drop(stream.write_all(response.as_bytes()).await);
736 drop(stream.flush().await);
737 });
738 }
739 });
740
741 let base_url = format!("http://127.0.0.1:{}", addr.port());
742 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
743 (
744 format!("{base_url}/user"),
745 format!("{base_url}/user/emails"),
746 requests,
747 )
748 }
749
750 #[test]
752 fn github_provider_uses_ts_default_scopes() {
753 let provider = OAuthProvider::github("github-client-id", "github-client-secret");
754
755 assert_eq!(
756 provider.scopes,
757 vec!["read:user".to_owned(), "user:email".to_owned()]
758 );
759 assert!(provider.get_user_info.is_some());
760 assert!(provider.map_user_info.is_none());
761 }
762
763 #[tokio::test]
765 async fn github_provider_get_user_info_uses_email_fallback_and_login_name() {
766 let (user_url, emails_url, requests) = start_github_mock_server(
767 serde_json::json!({
768 "id": 42,
769 "login": "octocat",
770 "name": null,
771 "email": null,
772 "avatar_url": "https://avatars.githubusercontent.com/u/42?v=4",
773 }),
774 serde_json::json!([
775 {
776 "email": "octocat@example.com",
777 "primary": true,
778 "verified": true,
779 "visibility": "private"
780 },
781 {
782 "email": "secondary@example.com",
783 "primary": false,
784 "verified": false,
785 "visibility": "private"
786 }
787 ]),
788 )
789 .await;
790
791 let provider = OAuthProvider::github_with_endpoints(
792 "github-client-id",
793 "github-client-secret",
794 "https://github.com/login/oauth/authorize",
795 "https://github.com/login/oauth/access_token",
796 &user_url,
797 &emails_url,
798 );
799 let handler = provider.get_user_info.as_ref().unwrap();
800
801 let response = handler
802 .get_user_info(OAuthUserInfoRequest {
803 access_token: Some("github-access-token".to_owned()),
804 ..Default::default()
805 })
806 .await
807 .unwrap();
808
809 assert_eq!(response.user.id, "42");
810 assert_eq!(response.user.email, "octocat@example.com");
811 assert_eq!(response.user.name.as_deref(), Some("octocat"));
812 assert_eq!(
813 response.user.image.as_deref(),
814 Some("https://avatars.githubusercontent.com/u/42?v=4")
815 );
816 assert!(response.user.email_verified);
817 assert_eq!(
818 (*(response.data)
819 .get("email")
820 .expect("fixture contains the requested index")),
821 serde_json::json!("octocat@example.com")
822 );
823
824 let requests = requests.lock().await;
825 assert_eq!(requests.len(), 2);
826 for request in requests.iter() {
827 let lowered = request.to_ascii_lowercase();
828 assert!(lowered.contains("authorization: bearer github-access-token"));
829 assert!(lowered.contains("user-agent: better-auth"));
830 }
831 }
832
833 #[tokio::test]
835 async fn github_provider_get_user_info_keeps_inline_email() {
836 let (user_url, emails_url, _) = start_github_mock_server(
837 serde_json::json!({
838 "id": "github-inline-email",
839 "login": "octocat",
840 "name": "Octo Cat",
841 "email": "public@example.com",
842 "avatar_url": null,
843 }),
844 serde_json::json!([
845 {
846 "email": "primary@example.com",
847 "primary": true,
848 "verified": true,
849 "visibility": "private"
850 },
851 {
852 "email": "public@example.com",
853 "primary": false,
854 "verified": false,
855 "visibility": "public"
856 }
857 ]),
858 )
859 .await;
860
861 let provider = OAuthProvider::github_with_endpoints(
862 "github-client-id",
863 "github-client-secret",
864 "https://github.com/login/oauth/authorize",
865 "https://github.com/login/oauth/access_token",
866 &user_url,
867 &emails_url,
868 );
869 let handler = provider.get_user_info.as_ref().unwrap();
870
871 let response = handler
872 .get_user_info(OAuthUserInfoRequest {
873 access_token: Some("github-access-token".to_owned()),
874 ..Default::default()
875 })
876 .await
877 .unwrap();
878
879 assert_eq!(response.user.email, "public@example.com");
880 assert_eq!(response.user.name.as_deref(), Some("Octo Cat"));
881 assert!(!response.user.email_verified);
882 }
883}
884mod linkedin;
887pub use linkedin::LinkedInOptions;
888mod linear;
889pub use linear::LinearOptions;