1use async_trait::async_trait;
2use better_auth_core::{AuthError, AuthResult, FieldDate, FieldValue, NativeRequest, SchemaValue};
3use indexmap::IndexMap;
4use serde::Deserialize;
5use serde_json::Value;
6
7pub(super) mod apple;
8mod constructors;
9pub(super) mod defaults;
10pub(super) mod facebook;
11pub(super) mod line;
12pub(super) mod microsoft;
13pub(super) mod paybin;
14pub(super) mod paypal;
15pub(super) mod tiktok;
16pub(super) mod twitch;
17pub(super) mod twitter;
18pub(super) mod wechat;
19use super::OAuthProfileMapper;
20use super::token::{TokenEndpointAuth, TokenEndpointSecretAuthentication, TokenGrantType};
21use defaults::ProviderKind;
22pub(super) use defaults::github_profile;
23use std::{collections::HashSet, sync::Arc};
24
25#[derive(Clone, Default)]
27pub struct OAuthConfig {
28 pub providers: IndexMap<String, OAuthProvider>,
29}
30
31#[derive(Debug, Clone, Default)]
32pub struct OAuthTokenSet {
33 pub token_type: Option<String>,
34 pub access_token: Option<String>,
35 pub refresh_token: Option<String>,
36 pub access_token_expires_at: Option<FieldDate>,
37 pub refresh_token_expires_at: Option<FieldDate>,
38 pub scopes: Vec<String>,
39 pub id_token: Option<String>,
40 pub raw: Option<Value>,
41}
42
43#[derive(Debug, Clone)]
45pub struct OAuthUserInfo {
46 pub additional_fields: better_auth_core::FieldMap,
48 pub id: String,
49 pub email: SchemaValue<Option<String>>,
51 pub name: SchemaValue<Option<String>>,
53 pub image: Option<Option<String>>,
55 pub email_verified: SchemaValue<Option<bool>>,
57}
58
59impl OAuthUserInfo {
60 pub(super) fn email(&self) -> AuthResult<Option<&str>> {
61 profile_email(&self.email)
62 }
63
64 pub(super) fn email_verified(&self) -> AuthResult<bool> {
65 profile_email_verified(&self.email_verified)
66 }
67
68 pub(super) fn name(&self) -> AuthResult<Option<&str>> {
69 match &self.name {
70 SchemaValue::Typed(Some(value)) | SchemaValue::Dynamic(FieldValue::String(value)) => {
71 Ok(Some(value))
72 }
73 SchemaValue::Undefined
74 | SchemaValue::Typed(None)
75 | SchemaValue::Dynamic(FieldValue::Null) => Ok(None),
76 SchemaValue::Dynamic(_) => Err(AuthError::internal(
77 "OAuth profile name must be a string, null, or undefined",
78 )),
79 }
80 }
81}
82
83pub(super) fn decode_profile_name(value: Option<&Value>) -> SchemaValue<Option<String>> {
84 match value {
85 Some(Value::String(value)) => Some(value.clone()).into(),
86 Some(Value::Null) => None.into(),
87 None | Some(_) => SchemaValue::Undefined,
88 }
89}
90
91pub(super) fn profile_email_verified(value: &SchemaValue<Option<bool>>) -> AuthResult<bool> {
92 match value {
93 SchemaValue::Typed(Some(value)) | SchemaValue::Dynamic(FieldValue::Bool(value)) => {
94 Ok(*value)
95 }
96 SchemaValue::Undefined
97 | SchemaValue::Typed(None)
98 | SchemaValue::Dynamic(FieldValue::Null) => Ok(false),
99 SchemaValue::Dynamic(_) => Err(AuthError::internal(
100 "OAuth profile email verification must be a boolean, null, or undefined",
101 )),
102 }
103}
104
105pub(super) fn profile_email(value: &SchemaValue<Option<String>>) -> AuthResult<Option<&str>> {
106 match value {
107 SchemaValue::Undefined
108 | SchemaValue::Typed(None)
109 | SchemaValue::Dynamic(FieldValue::Null) => Ok(None),
110 SchemaValue::Typed(Some(value)) | SchemaValue::Dynamic(FieldValue::String(value)) => {
111 Ok(Some(value))
112 }
113 SchemaValue::Dynamic(_) => Err(AuthError::internal(
114 "OAuth profile email must be a string, null, or undefined",
115 )),
116 }
117}
118
119#[derive(Debug, Clone, Default, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct OAuthCallbackUserPayload {
122 pub name: Option<OAuthCallbackUserName>,
123 pub email: Option<String>,
124}
125
126#[derive(Debug, Clone, Default, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct OAuthCallbackUserName {
129 pub first_name: Option<String>,
130 pub last_name: Option<String>,
131}
132
133#[derive(Debug, Clone, Default)]
134pub struct OAuthUserInfoRequest {
135 pub token_type: Option<String>,
136 pub access_token: Option<String>,
137 pub refresh_token: Option<String>,
138 pub access_token_expires_at: Option<FieldDate>,
139 pub refresh_token_expires_at: Option<FieldDate>,
140 pub scopes: Vec<String>,
141 pub id_token: Option<String>,
142 pub raw: Option<Value>,
143 pub user: Option<OAuthCallbackUserPayload>,
144}
145
146#[derive(Debug, Clone)]
147pub struct OAuthUserInfoResponse {
148 pub user: OAuthUserInfo,
149 pub data: Value,
150}
151
152#[async_trait]
154pub trait OAuthUserInfoHandler: Send + Sync {
155 async fn get_user_info(
157 &self,
158 request: OAuthUserInfoRequest,
159 ) -> AuthResult<Option<OAuthUserInfoResponse>>;
160}
161
162#[async_trait]
163pub trait OAuthRefreshTokenHandler: Send + Sync {
164 async fn refresh_access_token(
166 &self,
167 refresh_token: &str,
168 context: Option<NativeRequest<'_>>,
169 ) -> Result<OAuthTokenSet, String>;
170}
171
172#[async_trait]
173pub trait OAuthIdTokenVerifier: Send + Sync {
174 async fn verify_id_token(
176 &self,
177 token: &str,
178 nonce: Option<&str>,
179 context: Option<NativeRequest<'_>>,
180 ) -> Result<bool, String>;
181}
182
183#[derive(Clone)]
185pub struct OAuthProvider {
186 kind: ProviderKind,
187 pub client_id: String,
188 pub client_secret: String,
189 pub client_key: Option<String>,
191 pub auth_url: String,
192 pub token_url: String,
193 pub token_endpoint_auth: Option<TokenEndpointAuth>,
196 pub user_info_url: Option<String>,
197 pub redirect_uri: Option<String>,
199 pub end_session_endpoint: Option<String>,
201 pub post_logout_redirect_uri: Option<String>,
203 pub scopes: Option<Vec<String>>,
205 pub disable_default_scope: bool,
207 pub disable_id_token_sign_in: bool,
209 pub prompt: Option<String>,
212 pub authorization_params: Vec<(String, String)>,
213 pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
215 pub map_profile_to_user: Option<Arc<dyn OAuthProfileMapper>>,
217 pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
218 pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
219 pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
220 pub disable_implicit_sign_up: Option<bool>,
221 pub disable_sign_up: Option<bool>,
222 pub require_email_verification: Option<bool>,
224 pub override_user_info_on_sign_in: bool,
225}
226
227impl OAuthProvider {
228 pub(super) fn microsoft_options(&self) -> Option<&super::MicrosoftOptions> {
229 match &self.kind {
230 ProviderKind::Microsoft { options, .. } => Some(options),
231 _ => None,
232 }
233 }
234
235 pub(super) fn cognito_options(&self) -> Option<&super::CognitoOptions> {
236 match &self.kind {
237 ProviderKind::Cognito(options) => Some(options),
238 _ => None,
239 }
240 }
241
242 pub(super) fn supports_refresh(&self) -> bool {
243 !matches!(self.kind, ProviderKind::Vercel)
244 }
245
246 pub(super) fn user_info_request(
247 &self,
248 url: &str,
249 access_token: &str,
250 ) -> reqwest::RequestBuilder {
251 if self.is_vk() {
252 return reqwest::Client::new()
253 .post(url)
254 .header("Accept", "*/*")
255 .form(&[
256 ("access_token", access_token),
257 ("client_id", &self.client_id),
258 ]);
259 }
260 let method = match self.kind {
261 ProviderKind::Dropbox | ProviderKind::Linear => reqwest::Method::POST,
262 _ => reqwest::Method::GET,
263 };
264 let request = reqwest::Client::new()
265 .request(method, url)
266 .bearer_auth(access_token);
267 let request = if self.is_tiktok() {
268 request
269 } else {
270 request.header(
271 "Accept",
272 if self.is_twitter() || self.cognito_options().is_some() {
273 "*/*"
274 } else {
275 "application/json"
276 },
277 )
278 };
279 if matches!(self.kind, ProviderKind::Linear) {
280 request.json(&serde_json::json!({
281 "query": "query { viewer { id name email avatarUrl active createdAt updatedAt } }"
282 }))
283 } else if self.is_reddit() {
284 request.header("User-Agent", "better-auth")
285 } else if matches!(self.kind, ProviderKind::Notion) {
286 request.header("Notion-Version", "2022-06-28")
287 } else {
288 request
289 }
290 }
291
292 pub(super) fn is_figma(&self) -> bool {
293 matches!(self.kind, ProviderKind::Figma)
294 }
295
296 pub(super) fn is_atlassian(&self) -> bool {
297 matches!(self.kind, ProviderKind::Atlassian)
298 }
299
300 pub(super) fn is_salesforce(&self) -> bool {
301 matches!(self.kind, ProviderKind::Salesforce)
302 }
303
304 pub(super) fn required_credentials_message(&self) -> Option<&'static str> {
305 if self.is_paypal() {
306 return Some(
307 "Client Id and Client Secret is required for PayPal. Make sure to provide them in the options.",
308 );
309 }
310 match self.kind {
311 ProviderKind::Apple(_) => Some(
312 "Client ID and client secret are required for Apple. Make sure to provide them in the options.",
313 ),
314 ProviderKind::Facebook(_) => Some(
315 "Client ID and client secret are required for Facebook. Make sure to provide them in the options.",
316 ),
317 ProviderKind::Figma => Some(
318 "Client Id and Client Secret are required for Figma. Make sure to provide them in the options.",
319 ),
320 ProviderKind::Atlassian => Some("Client Id and Secret are required for Atlassian"),
321 ProviderKind::Salesforce => Some(
322 "Client Id and Client Secret are required for Salesforce. Make sure to provide them in the options.",
323 ),
324 ProviderKind::Paybin { .. } => Some(
325 "Client Id and Client Secret is required for Paybin. Make sure to provide them in the options.",
326 ),
327 _ => None,
328 }
329 }
330
331 pub(super) fn fixed_authorization_params(&self) -> &'static [(&'static str, &'static str)] {
332 match self.kind {
333 ProviderKind::Atlassian => &[("audience", "api.atlassian.com")],
334 ProviderKind::Notion => &[("owner", "user")],
335 _ => &[],
336 }
337 }
338
339 pub(super) fn wechat_refresh_url(&self) -> Option<&str> {
340 match &self.kind {
341 ProviderKind::WeChat { refresh_url } => Some(refresh_url),
342 _ => None,
343 }
344 }
345
346 pub(super) fn is_tiktok(&self) -> bool {
347 matches!(self.kind, ProviderKind::TikTok)
348 }
349
350 pub(super) fn is_twitter(&self) -> bool {
351 matches!(self.kind, ProviderKind::Twitter)
352 }
353
354 pub(super) fn is_vk(&self) -> bool {
355 matches!(self.kind, ProviderKind::Vk)
356 }
357
358 pub(super) fn is_discord(&self) -> bool {
359 matches!(self.kind, ProviderKind::Discord)
360 }
361
362 pub(super) fn is_roblox(&self) -> bool {
363 matches!(self.kind, ProviderKind::Roblox)
364 }
365
366 pub(super) fn omits_request_nonce(&self) -> bool {
367 !matches!(self.kind, ProviderKind::Custom)
368 }
369
370 pub(super) fn is_reddit(&self) -> bool {
371 matches!(self.kind, ProviderKind::Reddit)
372 }
373
374 pub(super) fn uses_pkce(&self) -> bool {
375 !matches!(
376 self.kind,
377 ProviderKind::Facebook(_)
378 | ProviderKind::Discord
379 | ProviderKind::LinkedIn
380 | ProviderKind::Slack
381 | ProviderKind::Naver
382 | ProviderKind::Linear
383 | ProviderKind::Reddit
384 | ProviderKind::Roblox
385 | ProviderKind::TikTok
386 | ProviderKind::Notion
387 | ProviderKind::Twitch(_)
388 | ProviderKind::Kakao
389 | ProviderKind::Zoom { pkce: false }
390 | ProviderKind::WeChat { .. }
391 )
392 }
393
394 pub(super) fn forwards_code_verifier(&self) -> bool {
395 matches!(self.kind, ProviderKind::Zoom { .. } | ProviderKind::TikTok) || self.uses_pkce()
396 }
397
398 pub(super) fn omits_login_hint(&self) -> bool {
399 matches!(
400 self.kind,
401 ProviderKind::Apple(_)
402 | ProviderKind::Cognito(_)
403 | ProviderKind::Discord
404 | ProviderKind::Spotify
405 | ProviderKind::HuggingFace
406 | ProviderKind::Polar
407 | ProviderKind::Vercel
408 | ProviderKind::Figma
409 | ProviderKind::Dropbox
410 | ProviderKind::Kick
411 | ProviderKind::PayPal
412 | ProviderKind::Cloudflare
413 | ProviderKind::Slack
414 | ProviderKind::Naver
415 | ProviderKind::Atlassian
416 | ProviderKind::Reddit
417 | ProviderKind::Salesforce
418 | ProviderKind::Railway
419 | ProviderKind::Roblox
420 | ProviderKind::TikTok
421 | ProviderKind::Twitch(_)
422 | ProviderKind::Kakao
423 | ProviderKind::Zoom { .. }
424 | ProviderKind::Twitter
425 | ProviderKind::WeChat { .. }
426 | ProviderKind::Vk
427 )
428 }
429
430 pub(super) fn code_client_key(&self) -> Option<&str> {
431 match self.kind {
432 ProviderKind::Custom
433 | ProviderKind::PayPal
434 | ProviderKind::Reddit
435 | ProviderKind::WeChat { .. }
436 | ProviderKind::TikTok => None,
437 _ => self.client_key.as_deref(),
438 }
439 }
440
441 pub(super) fn omits_device_id(&self) -> bool {
442 matches!(
443 self.kind,
444 ProviderKind::Microsoft { .. }
445 | ProviderKind::Google { .. }
446 | ProviderKind::Apple(_)
447 | ProviderKind::Facebook(_)
448 | ProviderKind::Discord
449 | ProviderKind::Cognito(_)
450 | ProviderKind::PayPal
451 | ProviderKind::Cloudflare
452 | ProviderKind::LinkedIn
453 | ProviderKind::Line { .. }
454 | ProviderKind::Paybin { .. }
455 | ProviderKind::Slack
456 | ProviderKind::Naver
457 | ProviderKind::Linear
458 | ProviderKind::Atlassian
459 | ProviderKind::Reddit
460 | ProviderKind::Salesforce
461 | ProviderKind::Railway
462 | ProviderKind::Roblox
463 | ProviderKind::TikTok
464 | ProviderKind::Notion
465 | ProviderKind::Twitch(_)
466 | ProviderKind::Kakao
467 | ProviderKind::Zoom { .. }
468 | ProviderKind::Twitter
469 | ProviderKind::WeChat { .. }
470 )
471 }
472
473 pub(super) fn http_profile_data(&self, mut response: Value) -> Result<Option<Value>, String> {
474 if matches!(self.kind, ProviderKind::Atlassian | ProviderKind::Kakao) && response.is_null()
475 {
476 Ok(None)
477 } else if matches!(self.kind, ProviderKind::Discord) {
478 defaults::prepare_discord_profile(&mut response)?;
479 Ok(Some(response))
480 } else if matches!(self.kind, ProviderKind::Kick) {
481 response
482 .pointer_mut("/data/0")
483 .map(|profile| Some(profile.take()))
484 .ok_or_else(|| "Kick user info response did not contain a profile".into())
485 } else if matches!(self.kind, ProviderKind::Linear) {
486 Ok(response
487 .pointer_mut("/data/viewer")
488 .filter(|profile| !profile.is_null())
489 .map(Value::take))
490 } else if matches!(self.kind, ProviderKind::Notion) {
491 Ok(response
492 .pointer_mut("/bot/owner/user")
493 .filter(|profile| crate::plugins::json_body::is_truthy(profile))
494 .map(Value::take))
495 } else if matches!(self.kind, ProviderKind::Cloudflare) {
496 if response.get("success").and_then(Value::as_bool) != Some(true) {
497 return Ok(None);
498 }
499 Ok(response
500 .get_mut("result")
501 .filter(|profile| !profile.is_null())
502 .map(Value::take))
503 } else {
504 Ok(Some(response))
505 }
506 }
507
508 pub(super) fn is_cloudflare(&self) -> bool {
509 matches!(self.kind, ProviderKind::Cloudflare)
510 }
511
512 pub(super) fn token_endpoint_auth(&self) -> Option<&TokenEndpointAuth> {
513 match &self.kind {
514 ProviderKind::Microsoft {
515 token_endpoint_auth,
516 ..
517 } => token_endpoint_auth.as_ref(),
518 ProviderKind::Cloudflare => self.token_endpoint_auth.as_ref(),
519 _ => None,
520 }
521 }
522
523 pub(super) fn token_authentication(
524 &self,
525 grant_type: TokenGrantType,
526 ) -> Option<TokenEndpointSecretAuthentication> {
527 (self.is_paypal()
528 || self.is_figma()
529 || self.is_reddit()
530 || self.is_twitter()
531 || matches!(self.kind, ProviderKind::Railway)
532 || matches!(self.kind, ProviderKind::Notion)
533 && grant_type == TokenGrantType::AuthorizationCode
534 || matches!(self.kind, ProviderKind::Cloudflare) && !self.client_secret.is_empty())
535 .then_some(TokenEndpointSecretAuthentication::Basic)
536 }
537
538 pub fn disable_implicit_sign_up(&self) -> bool {
540 self.disable_implicit_sign_up.unwrap_or(false)
541 }
542
543 pub fn disable_sign_up(&self) -> bool {
545 self.disable_sign_up.unwrap_or(false)
546 }
547
548 pub(super) fn google_jwks_url(&self) -> Option<&str> {
549 match &self.kind {
550 ProviderKind::Google { jwks_url, .. } => Some(jwks_url),
551 _ => None,
552 }
553 }
554
555 pub(in crate::plugins) fn google_client_ids(&self) -> Vec<String> {
556 let additional = match &self.kind {
557 ProviderKind::Google { options, .. } => options.additional_client_ids.as_slice(),
558 _ => &[],
559 };
560 std::iter::once(self.client_id.clone())
561 .chain(additional.iter().cloned())
562 .collect()
563 }
564
565 pub(super) fn google_hosted_domain(&self) -> Option<&str> {
566 self.authorization_params
567 .iter()
568 .find(|(name, _)| name == "hd")
569 .map(|(_, value)| value.as_str())
570 }
571
572 #[cfg(test)]
573 pub(super) fn set_google_jwks_url(&mut self, url: String) {
574 let ProviderKind::Google { jwks_url, .. } = &mut self.kind else {
575 panic!("Google fixture requires a Google provider");
576 };
577 *jwks_url = url;
578 }
579
580 pub(super) fn github_endpoints(&self) -> Option<(&str, &str)> {
581 match &self.kind {
582 ProviderKind::GitHub {
583 user_url,
584 emails_url,
585 } => Some((user_url, emails_url)),
586 _ => None,
587 }
588 }
589
590 pub(super) fn account_info_includes_id(&self) -> bool {
591 self.get_user_info.is_some()
592 || (self.github_endpoints().is_none() && self.map_user_info.is_some())
593 }
594
595 pub(super) fn decode_profile(
596 &self,
597 profile: Value,
598 ) -> better_auth_core::AuthResult<Option<OAuthUserInfo>> {
599 if let Some(mapper) = self.map_user_info {
600 mapper(profile)
601 .map(Some)
602 .map_err(better_auth_core::AuthError::internal)
603 } else {
604 self.kind.decode_profile(profile)
605 }
606 }
607
608 pub(super) fn resolve(&self) -> Self {
609 let mut provider = self.clone();
610 if provider.get_user_info.is_some() {
611 provider.map_profile_to_user = None;
613 }
614 provider
615 }
616
617 pub(super) fn social_scopes<'a>(&'a self, request: Option<&'a [String]>) -> Vec<&'a str> {
618 if matches!(self.kind, ProviderKind::Zoom { .. } | ProviderKind::PayPal) {
619 return Vec::new();
620 }
621 let configured = self.scopes.as_deref().unwrap_or_default();
622 if matches!(self.kind, ProviderKind::Custom) {
623 return request
624 .unwrap_or(configured)
625 .iter()
626 .map(String::as_str)
627 .collect();
628 }
629 let mut scopes = if self.disable_default_scope {
630 Vec::new()
631 } else {
632 self.kind.scopes().to_vec()
633 };
634 let request = request.unwrap_or_default();
635 let (first, second) = if matches!(self.kind, ProviderKind::Discord | ProviderKind::Slack) {
636 (request, configured)
637 } else {
638 (configured, request)
639 };
640 scopes.extend(first.iter().chain(second).map(String::as_str));
641 if matches!(self.kind, ProviderKind::Cloudflare) {
642 let mut seen = HashSet::new();
643 scopes.retain(|scope| seen.insert(*scope));
644 }
645 scopes
646 }
647
648 pub(super) fn social_prompt(&self) -> Option<&str> {
649 match self.kind {
650 ProviderKind::Roblox => self
651 .prompt
652 .as_deref()
653 .filter(|value| !value.is_empty())
654 .or(Some("select_account consent")),
655 ProviderKind::Custom
656 | ProviderKind::Google { .. }
657 | ProviderKind::GitHub { .. }
658 | ProviderKind::Discord
659 | ProviderKind::Polar
660 | ProviderKind::PayPal
661 | ProviderKind::Cognito(_)
662 | ProviderKind::Microsoft { .. }
663 | ProviderKind::Paybin { .. }
664 | ProviderKind::Atlassian => self
665 .prompt
666 .as_deref()
667 .filter(|value| !value.is_empty())
668 .or_else(|| matches!(self.kind, ProviderKind::Discord).then_some("none")),
669 ProviderKind::Apple(_)
670 | ProviderKind::Facebook(_)
671 | ProviderKind::GitLab
672 | ProviderKind::Spotify
673 | ProviderKind::HuggingFace
674 | ProviderKind::Vercel
675 | ProviderKind::Figma
676 | ProviderKind::Dropbox
677 | ProviderKind::Kick
678 | ProviderKind::LinkedIn
679 | ProviderKind::Line { .. }
680 | ProviderKind::Slack
681 | ProviderKind::Naver
682 | ProviderKind::Linear
683 | ProviderKind::Reddit
684 | ProviderKind::Cloudflare
685 | ProviderKind::Salesforce
686 | ProviderKind::Railway
687 | ProviderKind::Notion
688 | ProviderKind::Twitch(_)
689 | ProviderKind::Kakao
690 | ProviderKind::Twitter
691 | ProviderKind::WeChat { .. }
692 | ProviderKind::Vk
693 | ProviderKind::TikTok
694 | ProviderKind::Zoom { .. } => None,
695 }
696 }
697}
698
699#[cfg(test)]
700mod tests;