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