Skip to main content

better_auth_api/plugins/oauth/
providers.rs

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/// Configuration for the OAuth plugin, containing all registered providers.
26#[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/// User information extracted from an OAuth provider's user info endpoint.
44#[derive(Debug, Clone)]
45pub struct OAuthUserInfo {
46    /// Mapped application fields validated by the configured user schema.
47    pub additional_fields: better_auth_core::FieldMap,
48    pub id: String,
49    /// Preserve a missing email with `Undefined`, null with `Typed(None)`, or a string.
50    pub email: SchemaValue<Option<String>>,
51    /// Preserve a missing name with `Undefined`, null with `Typed(None)`, or a string.
52    pub name: SchemaValue<Option<String>>,
53    /// Omit the image with `None`, clear it with `Some(None)`, or supply a URL.
54    pub image: Option<Option<String>>,
55    /// Preserve omitted or null provider values without treating either as verified.
56    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/// Fetch a provider profile while preserving application errors at the endpoint boundary.
153#[async_trait]
154pub trait OAuthUserInfoHandler: Send + Sync {
155    /// Return `None` when no profile is available, or preserve an application error.
156    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    /// Endpoint calls supply original request metadata; standalone calls may omit the context.
165    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    /// Request metadata is supplied by direct sign-in and linking; standalone calls may omit it.
175    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/// Input options for a social OAuth provider. Constructors retain omitted options.
184#[derive(Clone)]
185pub struct OAuthProvider {
186    kind: ProviderKind,
187    pub client_id: String,
188    pub client_secret: String,
189    /// Additional key for built-in authorization-code exchanges that accept this option.
190    pub client_key: Option<String>,
191    pub auth_url: String,
192    pub token_url: String,
193    /// Cloudflare token endpoint authentication override for code exchange and refresh.
194    /// Other Social providers retain their fixed authentication methods.
195    pub token_endpoint_auth: Option<TokenEndpointAuth>,
196    pub user_info_url: Option<String>,
197    /// Provider callback URI for authorization and code exchange; an empty value uses the route URI.
198    pub redirect_uri: Option<String>,
199    /// OIDC provider endpoint used for RP-initiated logout.
200    pub end_session_endpoint: Option<String>,
201    /// Default redirect after provider logout; a request callback overrides it.
202    pub post_logout_redirect_uri: Option<String>,
203    /// Additional scopes for built-in providers; omission preserves their defaults.
204    pub scopes: Option<Vec<String>>,
205    /// Exclude the built-in provider scopes from authorization requests.
206    pub disable_default_scope: bool,
207    /// Reject direct ID-token sign-in and linking before invoking the verifier.
208    pub disable_id_token_sign_in: bool,
209    /// Authentication prompt for providers that support this option.
210    /// Omission preserves the provider default.
211    pub prompt: Option<String>,
212    pub authorization_params: Vec<(String, String)>,
213    /// Replace the base JSON decoder for a custom provider.
214    pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
215    /// Map local fields after built-in profile decoding without changing account identity.
216    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    /// Require the local user's email to be verified before issuing a session.
223    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    /// Return the effective implicit sign-up policy.
539    pub fn disable_implicit_sign_up(&self) -> bool {
540        self.disable_implicit_sign_up.unwrap_or(false)
541    }
542
543    /// Return the effective sign-up policy.
544    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            // Upstream custom user-info handlers return before profile mapping.
612            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;