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