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)]
94pub struct OAuthTokenSet {
95 pub token_type: Option<String>,
96 pub access_token: Option<String>,
97 pub refresh_token: Option<String>,
98 pub access_token_expires_at: Option<DateTime<Utc>>,
99 pub refresh_token_expires_at: Option<DateTime<Utc>>,
100 pub scopes: Vec<String>,
101 pub id_token: Option<String>,
102 pub raw: Option<Value>,
103}
104
105#[derive(Debug, Clone)]
107pub struct OAuthUserInfo {
108 pub additional_fields: alibi_core::field_policy::FieldOutput,
110 pub id: String,
111 pub email: String,
112 pub name: Option<String>,
113 pub image: Option<String>,
114 pub email_verified: bool,
115}
116
117impl OAuthUserInfo {
118 #[must_use]
120 pub fn public_profile(&self, include_id: bool) -> alibi_core::field_policy::FieldOutput {
121 let mut output = self.additional_fields.clone();
122 _ = output.insert("email".into(), Value::String(self.email.clone()));
123 _ = output.insert("emailVerified".into(), Value::Bool(self.email_verified));
124 if include_id {
125 _ = output.insert("id".into(), Value::String(self.id.clone()));
126 }
127 if let Some(name) = &self.name {
128 _ = output.insert("name".into(), Value::String(name.clone()));
129 }
130 if let Some(image) = &self.image {
131 _ = output.insert("image".into(), Value::String(image.clone()));
132 }
133 output
134 }
135}
136
137#[derive(Debug, Clone, Default, serde::Serialize, Deserialize)]
138pub struct OAuthCallbackUserPayload {
139 pub name: Option<OAuthCallbackUserName>,
140 pub email: Option<String>,
141}
142
143#[derive(Debug, Clone, Default, serde::Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct OAuthCallbackUserName {
146 pub first_name: Option<String>,
147 pub last_name: Option<String>,
148}
149
150#[derive(Debug, Clone, Default)]
151pub struct OAuthUserInfoRequest {
152 pub token_type: Option<String>,
153 pub access_token: Option<String>,
154 pub refresh_token: Option<String>,
155 pub access_token_expires_at: Option<DateTime<Utc>>,
156 pub refresh_token_expires_at: Option<DateTime<Utc>>,
157 pub scopes: Vec<String>,
158 pub id_token: Option<String>,
159 pub raw: Option<Value>,
160 pub user: Option<OAuthCallbackUserPayload>,
161}
162
163#[derive(Debug, Clone)]
164pub struct OAuthUserInfoResponse {
165 pub user_output: Option<alibi_core::field_policy::FieldOutput>,
167 pub user: OAuthUserInfo,
168 pub data: Value,
169}
170
171pub type OAuthAccountSubject = fn(&Value) -> Result<String, String>;
173
174#[async_trait]
175pub trait OAuthUserInfoHandler: Send + Sync {
176 fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthUserInfoHandler>> {
178 None
179 }
180
181 fn errors_are_exceptions(&self) -> bool {
184 true
185 }
186
187 fn mapped_handler(
190 &self,
191 _mapper: Arc<dyn OAuthProfileMapper>,
192 ) -> Option<Arc<dyn OAuthUserInfoHandler>> {
193 None
194 }
195
196 async fn get_user_info(
197 &self,
198 request: OAuthUserInfoRequest,
199 ) -> Result<OAuthUserInfoResponse, String>;
200}
201
202#[async_trait]
206pub trait OAuthProfileMapper: Send + Sync {
207 async fn map_profile(
208 &self,
209 profile: Value,
210 ) -> Result<alibi_core::field_policy::FieldOutput, String>;
211}
212
213pub(super) fn apply_application_mapping(
214 response: &mut OAuthUserInfoResponse,
215 mapped: alibi_core::field_policy::FieldOutput,
216) -> Result<(), String> {
217 let output = response
218 .user_output
219 .get_or_insert_with(|| response.user.public_profile(true));
220 output.extend(mapped.clone());
221 for (key, value) in mapped {
222 match key.as_str() {
223 "id" => response.user.id = remaining_profile::js_string(&value)?,
224 "email" => response.user.email = value.as_str().unwrap_or_default().into(),
225 "emailVerified" => response.user.email_verified = remaining_profile::truthy(&value),
226 "name" => {
227 response.user.name = remaining_profile::scalar(
228 Some(&value).filter(|value| remaining_profile::truthy(value)),
229 )?;
230 }
231 "image" => response.user.image = remaining_profile::scalar(Some(&value))?,
232 _ => {
233 _ = response.user.additional_fields.insert(key, value);
234 }
235 }
236 }
237 Ok(())
238}
239
240#[derive(Clone, Copy)]
244pub struct OAuthRefreshContext<'a> {
245 pub request: &'a alibi_core::AuthRequest,
246}
247
248#[async_trait]
250pub trait OAuthRefreshTokenParamsResolver: Send + Sync {
251 async fn resolve(
252 &self,
253 context: Option<OAuthRefreshContext<'_>>,
254 ) -> Result<Option<std::collections::BTreeMap<String, String>>, String>;
255}
256
257#[derive(Clone)]
258pub struct OAuthRefreshTokenParams(pub Arc<dyn OAuthRefreshTokenParamsResolver>);
259impl std::fmt::Debug for OAuthRefreshTokenParams {
260 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 formatter.write_str("OAuthRefreshTokenParams(..)")
262 }
263}
264
265#[async_trait]
266pub trait OAuthRefreshTokenHandler: Send + Sync {
267 fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthRefreshTokenHandler>> {
269 None
270 }
271
272 async fn refresh_access_token(&self, refresh_token: &str) -> Result<OAuthTokenSet, String>;
273
274 async fn refresh_access_token_with_context(
277 &self,
278 refresh_token: &str,
279 _context: Option<OAuthRefreshContext<'_>>,
280 ) -> Result<OAuthTokenSet, String> {
281 self.refresh_access_token(refresh_token).await
282 }
283}
284
285#[async_trait]
286pub trait OAuthIdTokenVerifier: Send + Sync {
287 async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String>;
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum OAuthTokenGrant {
293 AuthorizationCode,
294 RefreshToken,
295}
296
297#[derive(Debug, Clone)]
298pub struct OAuthClientAssertionContext {
299 pub client_id: String,
300 pub token_endpoint: String,
301 pub grant_type: OAuthTokenGrant,
302}
303
304#[async_trait]
306pub trait OAuthClientAssertionGetter: Send + Sync {
307 async fn get_client_assertion(
308 &self,
309 context: OAuthClientAssertionContext,
310 ) -> Result<String, String>;
311}
312
313#[derive(Clone)]
315pub struct OAuthClientAssertion(pub Arc<dyn OAuthClientAssertionGetter>);
316
317impl std::fmt::Debug for OAuthClientAssertion {
318 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 f.debug_struct("OAuthClientAssertion")
320 .finish_non_exhaustive()
321 }
322}
323
324#[derive(Clone)]
326#[expect(
327 clippy::struct_excessive_bools,
328 reason = "Preserve independent public provider policy switches"
329)]
330pub struct OAuthProvider {
331 pub client_id: String,
332 pub additional_client_ids: Vec<String>,
334 pub hosted_domain: Option<String>,
336 pub require_email_verification: bool,
338 pub client_secret: String,
339 pub auth_url: String,
340 pub token_url: String,
341 pub user_info_url: Option<String>,
342 pub scopes: Vec<String>,
343 pub authorization: Option<OAuthAuthorizationPolicy>,
347 pub authorization_params: Vec<(String, String)>,
348 pub allowed_request_params: Vec<String>,
353 pub account_subject: Option<OAuthAccountSubject>,
355 pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
356 pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
357 pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
358 pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
360 pub id_token: Option<super::id_token::OAuthIdTokenConfig>,
361 pub disable_id_token_sign_in: bool,
363 pub disable_implicit_sign_up: bool,
364 pub disable_sign_up: bool,
365 pub allow_idp_initiated: bool,
367 pub override_user_info_on_sign_in: bool,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum OAuthScopeOrder {
373 ConfiguredThenRequested,
374 RequestedThenConfigured,
375}
376
377#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379pub enum OAuthScopeEncoding {
380 #[default]
381 Form,
382 UriComponent,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum OAuthTokenEndpointAuth {
388 ClientSecretBasic,
389 ClientSecretPost,
390 PrivateKeyJwt,
391 None,
392 ClientKeyPost,
394}
395
396#[derive(Debug, Clone)]
397pub struct OAuthAuthorizationCodeContext {
398 pub code: String,
399 pub redirect_uri: String,
400 pub code_verifier: Option<String>,
401 pub device_id: Option<String>,
402}
403
404#[async_trait]
405pub trait OAuthAuthorizationCodeHandler: Send + Sync {
406 fn configured_client_ids(
408 &self,
409 _ids: &[String],
410 ) -> Option<Arc<dyn OAuthAuthorizationCodeHandler>> {
411 None
412 }
413
414 async fn validate_authorization_code(
415 &self,
416 context: OAuthAuthorizationCodeContext,
417 ) -> Result<OAuthTokenSet, String>;
418}
419
420#[derive(Clone)]
421pub struct OAuthAuthorizationCodeCallback(pub Arc<dyn OAuthAuthorizationCodeHandler>);
422impl std::fmt::Debug for OAuthAuthorizationCodeCallback {
423 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424 formatter.write_str("OAuthAuthorizationCodeCallback(..)")
425 }
426}
427
428#[derive(Debug, Clone)]
430pub struct OAuthAccountKeyContext {
431 pub tokens: OAuthTokenSet,
432 pub profile: Value,
433}
434#[async_trait]
435pub trait OAuthAccountKeyResolver: Send + Sync {
436 async fn resolve(&self, context: OAuthAccountKeyContext) -> Result<Value, String>;
437}
438#[derive(Clone)]
439pub struct OAuthAccountKey(pub Arc<dyn OAuthAccountKeyResolver>);
440impl std::fmt::Debug for OAuthAccountKey {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 f.debug_struct("OAuthAccountKey").finish_non_exhaustive()
443 }
444}
445
446#[derive(Debug, Clone)]
450pub struct OAuthAuthorizationPolicy {
451 pub account_key: Option<OAuthAccountKey>,
453 pub callback_path: Option<String>,
455 pub disable_sign_up_option: Option<bool>,
457 pub issuer: Option<String>,
459 pub end_session: Option<OAuthEndSessionConfig>,
461 pub verify_grant_id_token: bool,
463 pub id_token_nonce_binding: bool,
465 pub discovery_openid_scope: bool,
467 pub authorization_code: Option<OAuthAuthorizationCodeCallback>,
469 pub authorization_code_pkce: Option<bool>,
471 pub client_id_parameter: String,
473 pub literal_client_id: Option<String>,
475 pub token_expiry_always: bool,
477 pub token_response_omits_id_token: bool,
479 pub scope_separator: String,
480 pub emit_empty_scope: bool,
481 pub authorization_fragment: Option<String>,
482 pub supports_refresh: bool,
484 pub supports_profile_mapper: bool,
486 pub allow_missing_access_token: bool,
488 pub default_access_token_expires_in: Option<f64>,
490 pub configured_scopes: Vec<String>,
491 pub omit_scopes: bool,
493 pub scope_encoding: OAuthScopeEncoding,
494 pub deduplicate_scopes: bool,
496 pub require_client_id: bool,
497 pub token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
499 pub refresh_token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
501 pub fixed_authorization_params: Vec<(String, String)>,
503 pub authorization_code_client_key: Option<String>,
505 pub authorization_code_headers: Vec<(String, String)>,
507 pub authorization_code_params: std::collections::BTreeMap<String, String>,
511 pub refresh_token_params: std::collections::BTreeMap<String, String>,
515 pub refresh_token_params_resolver: Option<OAuthRefreshTokenParams>,
519 pub client_assertion: Option<OAuthClientAssertion>,
521 pub refresh_scope: Option<String>,
523 pub response_type: String,
524 pub redirect_uri: Option<String>,
526 pub response_mode: Option<String>,
527 pub require_client_secret: bool,
528 pub login_hint: bool,
529 pub disable_default_scopes: bool,
530 pub scope_order: OAuthScopeOrder,
531 pub pkce: bool,
532 pub prompt: Option<String>,
533 pub default_prompt: Option<String>,
535 pub discord_permissions: Option<f64>,
537 pub propagate_grant_profile_errors: bool,
540 pub preserve_raw_email_errors: bool,
543 pub preserve_raw_profile_scalars: bool,
545 pub honor_factory_options: bool,
547 pub source_profile_exceptions: bool,
549}
550
551impl Default for OAuthAuthorizationPolicy {
552 fn default() -> Self {
553 Self {
554 account_key: None,
555 callback_path: None,
556 disable_sign_up_option: None,
557 issuer: None,
558 end_session: None,
559 verify_grant_id_token: false,
560 id_token_nonce_binding: false,
561 discovery_openid_scope: false,
562 authorization_code: None,
563 authorization_code_pkce: None,
564 client_id_parameter: "client_id".into(),
565 literal_client_id: None,
566 token_expiry_always: false,
567 token_response_omits_id_token: false,
568 scope_separator: " ".into(),
569 emit_empty_scope: false,
570 authorization_fragment: None,
571 supports_refresh: true,
572 supports_profile_mapper: true,
573 default_access_token_expires_in: None,
574 allow_missing_access_token: false,
575 configured_scopes: Vec::new(),
576 omit_scopes: false,
577 scope_encoding: OAuthScopeEncoding::Form,
578 deduplicate_scopes: false,
579 require_client_id: false,
580 token_endpoint_auth: None,
581 refresh_token_endpoint_auth: None,
582 fixed_authorization_params: Vec::new(),
583 authorization_code_client_key: None,
584 authorization_code_headers: Vec::new(),
585 authorization_code_params: std::collections::BTreeMap::new(),
586 refresh_token_params: std::collections::BTreeMap::new(),
587 refresh_token_params_resolver: None,
588 client_assertion: None,
589 refresh_scope: None,
590 response_type: "code".into(),
591 redirect_uri: None,
592 response_mode: None,
593 require_client_secret: false,
594 login_hint: true,
595 disable_default_scopes: false,
596 scope_order: OAuthScopeOrder::ConfiguredThenRequested,
597 pkce: true,
598 prompt: None,
599 default_prompt: None,
600 discord_permissions: None,
601 propagate_grant_profile_errors: false,
602 preserve_raw_email_errors: false,
603 preserve_raw_profile_scalars: false,
604 honor_factory_options: true,
605 source_profile_exceptions: false,
606 }
607 }
608}
609
610impl OAuthProvider {
611 #[must_use]
615 pub fn with_profile_mapper(mut self, mapper: Arc<dyn OAuthProfileMapper>) -> Self {
616 if self
617 .authorization
618 .as_ref()
619 .is_none_or(|policy| policy.supports_profile_mapper)
620 && let Some(handler) = self.get_user_info.take()
621 {
622 self.get_user_info = Some(handler.mapped_handler(mapper).unwrap_or(handler));
623 }
624 self
625 }
626
627 #[must_use]
628 pub fn with_client_ids(mut self, client_ids: Vec<String>) -> Self {
629 if let Some(handler) = self
630 .get_user_info
631 .as_ref()
632 .and_then(|handler| handler.configured_client_ids(&client_ids))
633 {
634 self.get_user_info = Some(handler);
635 }
636 if let Some(handler) = self
637 .refresh_access_token
638 .as_ref()
639 .and_then(|handler| handler.configured_client_ids(&client_ids))
640 {
641 self.refresh_access_token = Some(handler);
642 }
643 if let Some(policy) = self.authorization.as_mut() {
644 if policy.client_id_parameter == "appid" {
645 policy.literal_client_id = Some(client_ids.join(","));
646 }
647 if let Some(handler) = policy
648 .authorization_code
649 .as_ref()
650 .and_then(|callback| callback.0.configured_client_ids(&client_ids))
651 {
652 policy.authorization_code = Some(OAuthAuthorizationCodeCallback(handler));
653 }
654 }
655 if let Some(policy) = self.id_token.as_mut() {
656 policy.client_ids = Some(client_ids.clone());
657 }
658 let mut ids = client_ids.into_iter();
659 self.client_id = ids.next().unwrap_or_default();
660 self.additional_client_ids = ids.collect();
661 self
662 }
663
664 #[must_use]
665 pub const fn require_email_verification(mut self, required: bool) -> Self {
666 self.require_email_verification = required;
667 self
668 }
669}
670
671impl std::fmt::Debug for OAuthConfig {
672 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673 f.debug_struct("OAuthConfig").finish_non_exhaustive()
674 }
675}
676
677impl std::fmt::Debug for OAuthProvider {
678 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679 f.debug_struct("OAuthProvider").finish_non_exhaustive()
680 }
681}
682
683mod huggingface;
684mod kakao;
685mod kick;
686mod line;
687pub use huggingface::HuggingFaceOptions;
688pub use kakao::KakaoOptions;
689pub use kick::KickOptions;
690pub use line::LineOptions;
691
692mod naver;
693pub use naver::NaverOptions;
694
695#[cfg(test)]
697mod tests {
698 use super::*;
699 use std::sync::Arc;
700 use tokio::io::{AsyncReadExt, AsyncWriteExt};
701 use tokio::sync::Mutex;
702
703 async fn start_github_mock_server(
704 profile: Value,
705 emails: Value,
706 ) -> (String, String, Arc<Mutex<Vec<String>>>) {
707 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
708 let addr = listener.local_addr().unwrap();
709 let requests = Arc::new(Mutex::new(Vec::new()));
710 let captured_requests = std::sync::Arc::clone(&requests);
711
712 tokio::spawn(async move {
713 loop {
714 let Ok((mut stream, _)) = listener.accept().await else {
715 break;
716 };
717 let profile = profile.clone();
718 let emails = emails.clone();
719 let requests_2 = std::sync::Arc::clone(&captured_requests);
720 tokio::spawn(async move {
721 let mut buffer = vec![0u8; 4096];
722 let read = stream.read(&mut buffer).await.unwrap_or(0);
723 let request = String::from_utf8_lossy(
724 (buffer)
725 .get(..read)
726 .expect("fixture contains the requested index"),
727 )
728 .to_string();
729 requests_2.lock().await.push(request.clone());
730
731 let (status, body) = if request.contains("/user/emails") {
732 ("200 OK", emails.to_string())
733 } else if request.contains("/user") {
734 ("200 OK", profile.to_string())
735 } else {
736 (
737 "404 Not Found",
738 serde_json::json!({ "error": "not found" }).to_string(),
739 )
740 };
741
742 let response = format!(
743 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
744 body.len(),
745 );
746
747 _ = stream.write_all(response.as_bytes()).await;
748 _ = stream.flush().await;
749 });
750 }
751 });
752
753 let base_url = format!("http://127.0.0.1:{}", addr.port());
754 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
755 (
756 format!("{base_url}/user"),
757 format!("{base_url}/user/emails"),
758 requests,
759 )
760 }
761
762 #[test]
764 fn github_provider_uses_ts_default_scopes() {
765 let provider = OAuthProvider::github("github-client-id", "github-client-secret");
766
767 assert_eq!(
768 provider.scopes,
769 vec!["read:user".to_owned(), "user:email".to_owned()]
770 );
771 assert!(provider.get_user_info.is_some());
772 assert!(provider.map_user_info.is_none());
773 }
774
775 #[tokio::test]
777 async fn github_provider_get_user_info_uses_email_fallback_and_login_name() {
778 let (user_url, emails_url, requests) = start_github_mock_server(
779 serde_json::json!({
780 "id": 42,
781 "login": "octocat",
782 "name": null,
783 "email": null,
784 "avatar_url": "https://avatars.githubusercontent.com/u/42?v=4",
785 }),
786 serde_json::json!([
787 {
788 "email": "octocat@example.com",
789 "primary": true,
790 "verified": true,
791 "visibility": "private"
792 },
793 {
794 "email": "secondary@example.com",
795 "primary": false,
796 "verified": false,
797 "visibility": "private"
798 }
799 ]),
800 )
801 .await;
802
803 let provider = OAuthProvider::github_with_endpoints(
804 "github-client-id",
805 "github-client-secret",
806 "https://github.com/login/oauth/authorize",
807 "https://github.com/login/oauth/access_token",
808 &user_url,
809 &emails_url,
810 );
811 let handler = provider.get_user_info.as_ref().unwrap();
812
813 let response = handler
814 .get_user_info(OAuthUserInfoRequest {
815 access_token: Some("github-access-token".to_owned()),
816 ..Default::default()
817 })
818 .await
819 .unwrap();
820
821 assert_eq!(response.user.id, "42");
822 assert_eq!(response.user.email, "octocat@example.com");
823 assert_eq!(response.user.name.as_deref(), Some("octocat"));
824 assert_eq!(
825 response.user.image.as_deref(),
826 Some("https://avatars.githubusercontent.com/u/42?v=4")
827 );
828 assert!(response.user.email_verified);
829 assert_eq!(
830 (*(response.data)
831 .get("email")
832 .expect("fixture contains the requested index")),
833 serde_json::json!("octocat@example.com")
834 );
835
836 let requests = requests.lock().await;
837 assert_eq!(requests.len(), 2);
838 for request in requests.iter() {
839 let lowered = request.to_ascii_lowercase();
840 assert!(lowered.contains("authorization: bearer github-access-token"));
841 assert!(lowered.contains("user-agent: better-auth"));
842 }
843 }
844
845 #[tokio::test]
847 async fn github_provider_get_user_info_keeps_inline_email() {
848 let (user_url, emails_url, _) = start_github_mock_server(
849 serde_json::json!({
850 "id": "github-inline-email",
851 "login": "octocat",
852 "name": "Octo Cat",
853 "email": "public@example.com",
854 "avatar_url": null,
855 }),
856 serde_json::json!([
857 {
858 "email": "primary@example.com",
859 "primary": true,
860 "verified": true,
861 "visibility": "private"
862 },
863 {
864 "email": "public@example.com",
865 "primary": false,
866 "verified": false,
867 "visibility": "public"
868 }
869 ]),
870 )
871 .await;
872
873 let provider = OAuthProvider::github_with_endpoints(
874 "github-client-id",
875 "github-client-secret",
876 "https://github.com/login/oauth/authorize",
877 "https://github.com/login/oauth/access_token",
878 &user_url,
879 &emails_url,
880 );
881 let handler = provider.get_user_info.as_ref().unwrap();
882
883 let response = handler
884 .get_user_info(OAuthUserInfoRequest {
885 access_token: Some("github-access-token".to_owned()),
886 ..Default::default()
887 })
888 .await
889 .unwrap();
890
891 assert_eq!(response.user.email, "public@example.com");
892 assert_eq!(response.user.name.as_deref(), Some("Octo Cat"));
893 assert!(!response.user.email_verified);
894 }
895}
896mod linkedin;
899pub use linkedin::LinkedInOptions;
900mod linear;
901pub use linear::LinearOptions;