Skip to main content

alibi_plugins/oauth/providers/
mod.rs

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/// Trusted OIDC logout endpoint and optional default return URI.
79#[derive(Debug, Clone)]
80pub struct OAuthEndSessionConfig {
81    pub endpoint: String,
82    pub post_logout_redirect_uri: Option<String>,
83}
84
85/// Configuration for the OAuth plugin, containing all registered providers.
86#[derive(Clone, Default)]
87pub struct OAuthConfig {
88    pub providers: HashMap<String, OAuthProvider>,
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct OAuthTokenSet {
93    pub token_type: Option<String>,
94    pub access_token: Option<String>,
95    pub refresh_token: Option<String>,
96    pub access_token_expires_at: Option<DateTime<Utc>>,
97    pub refresh_token_expires_at: Option<DateTime<Utc>>,
98    pub scopes: Vec<String>,
99    pub id_token: Option<String>,
100    pub raw: Option<Value>,
101}
102
103/// User information extracted from an OAuth provider's user info endpoint.
104#[derive(Debug, Clone)]
105pub struct OAuthUserInfo {
106    /// Trusted mapped application values; writes select initialized declared input fields.
107    pub additional_fields: alibi_core::field_policy::FieldOutput,
108    pub id: String,
109    pub email: String,
110    pub name: Option<String>,
111    pub image: Option<String>,
112    pub email_verified: bool,
113}
114
115impl OAuthUserInfo {
116    /// Original mapped public values. This is output, never account authority.
117    #[must_use]
118    pub fn public_profile(&self, include_id: bool) -> alibi_core::field_policy::FieldOutput {
119        let mut output = self.additional_fields.clone();
120        _ = output.insert("email".into(), Value::String(self.email.clone()));
121        _ = output.insert("emailVerified".into(), Value::Bool(self.email_verified));
122        if include_id {
123            _ = output.insert("id".into(), Value::String(self.id.clone()));
124        }
125        if let Some(name) = &self.name {
126            _ = output.insert("name".into(), Value::String(name.clone()));
127        }
128        if let Some(image) = &self.image {
129            _ = output.insert("image".into(), Value::String(image.clone()));
130        }
131        output
132    }
133}
134
135#[derive(Debug, Clone, Default, Deserialize)]
136pub struct OAuthCallbackUserPayload {
137    pub name: Option<OAuthCallbackUserName>,
138    pub email: Option<String>,
139}
140
141#[derive(Debug, Clone, Default, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct OAuthCallbackUserName {
144    pub first_name: Option<String>,
145    pub last_name: Option<String>,
146}
147
148#[derive(Debug, Clone, Default)]
149pub struct OAuthUserInfoRequest {
150    pub token_type: Option<String>,
151    pub access_token: Option<String>,
152    pub refresh_token: Option<String>,
153    pub access_token_expires_at: Option<DateTime<Utc>>,
154    pub refresh_token_expires_at: Option<DateTime<Utc>>,
155    pub scopes: Vec<String>,
156    pub id_token: Option<String>,
157    pub raw: Option<Value>,
158    pub user: Option<OAuthCallbackUserPayload>,
159}
160
161#[derive(Debug, Clone)]
162pub struct OAuthUserInfoResponse {
163    /// Original mapped public profile, captured before raw account-subject resolution.
164    pub user_output: Option<alibi_core::field_policy::FieldOutput>,
165    pub user: OAuthUserInfo,
166    pub data: Value,
167}
168
169/// Resolve a provider's stable account identity from its original profile.
170pub type OAuthAccountSubject = fn(&Value) -> Result<String, String>;
171
172#[async_trait]
173pub trait OAuthUserInfoHandler: Send + Sync {
174    /// Retain factory-specific use of the original configured client array.
175    fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthUserInfoHandler>> {
176        None
177    }
178
179    /// Custom application callbacks throw on failure; factory transports can
180    /// return a missing profile and tag only their uncaught projection failures.
181    fn errors_are_exceptions(&self) -> bool {
182        true
183    }
184
185    /// Factory handlers can install application mapping before projecting raw
186    /// profile fields. Custom getUserInfo callbacks retain their precedence.
187    fn mapped_handler(
188        &self,
189        _mapper: Arc<dyn OAuthProfileMapper>,
190    ) -> Option<Arc<dyn OAuthUserInfoHandler>> {
191        None
192    }
193
194    async fn get_user_info(
195        &self,
196        request: OAuthUserInfoRequest,
197    ) -> Result<OAuthUserInfoResponse, String>;
198}
199
200/// Asynchronous partial application mapping of the original provider profile.
201/// Absent keys retain published defaults; raw output remains independent from
202/// typed persistence, and returned IDs never replace original account authority.
203#[async_trait]
204pub trait OAuthProfileMapper: Send + Sync {
205    async fn map_profile(
206        &self,
207        profile: Value,
208    ) -> Result<alibi_core::field_policy::FieldOutput, String>;
209}
210
211pub(super) fn apply_application_mapping(
212    response: &mut OAuthUserInfoResponse,
213    mapped: alibi_core::field_policy::FieldOutput,
214) -> Result<(), String> {
215    let output = response
216        .user_output
217        .get_or_insert_with(|| response.user.public_profile(true));
218    output.extend(mapped.clone());
219    for (key, value) in mapped {
220        match key.as_str() {
221            "id" => response.user.id = remaining_profile::js_string(&value)?,
222            "email" => response.user.email = value.as_str().unwrap_or_default().into(),
223            "emailVerified" => response.user.email_verified = remaining_profile::truthy(&value),
224            "name" => {
225                response.user.name = remaining_profile::scalar(
226                    Some(&value).filter(|value| remaining_profile::truthy(value)),
227                )?;
228            }
229            "image" => response.user.image = remaining_profile::scalar(Some(&value))?,
230            _ => {
231                _ = response.user.additional_fields.insert(key, value);
232            }
233        }
234    }
235    Ok(())
236}
237
238/// Metadata of the request triggering an OAuth token refresh.
239/// Headers, cookies and body fields are untrusted. Applications must validate
240/// tenant/scope/audience entitlements before forwarding derived values.
241#[derive(Clone, Copy)]
242pub struct OAuthRefreshContext<'a> {
243    pub request: &'a alibi_core::AuthRequest,
244}
245
246/// An asynchronous alternative to static `refreshTokenParams`.
247#[async_trait]
248pub trait OAuthRefreshTokenParamsResolver: Send + Sync {
249    async fn resolve(
250        &self,
251        context: Option<OAuthRefreshContext<'_>>,
252    ) -> Result<Option<std::collections::BTreeMap<String, String>>, String>;
253}
254
255#[derive(Clone)]
256pub struct OAuthRefreshTokenParams(pub Arc<dyn OAuthRefreshTokenParamsResolver>);
257impl std::fmt::Debug for OAuthRefreshTokenParams {
258    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        formatter.write_str("OAuthRefreshTokenParams(..)")
260    }
261}
262
263#[async_trait]
264pub trait OAuthRefreshTokenHandler: Send + Sync {
265    /// Reconfigure a factory transport that directly interpolates client IDs.
266    fn configured_client_ids(&self, _ids: &[String]) -> Option<Arc<dyn OAuthRefreshTokenHandler>> {
267        None
268    }
269
270    async fn refresh_access_token(&self, refresh_token: &str) -> Result<OAuthTokenSet, String>;
271
272    /// Context-aware application handlers may override this method. Existing
273    /// factory handlers retain their token-only implementation.
274    async fn refresh_access_token_with_context(
275        &self,
276        refresh_token: &str,
277        _context: Option<OAuthRefreshContext<'_>>,
278    ) -> Result<OAuthTokenSet, String> {
279        self.refresh_access_token(refresh_token).await
280    }
281}
282
283#[async_trait]
284pub trait OAuthIdTokenVerifier: Send + Sync {
285    async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String>;
286}
287
288/// The actual token grant for an application's asynchronous client assertion.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum OAuthTokenGrant {
291    AuthorizationCode,
292    RefreshToken,
293}
294
295#[derive(Debug, Clone)]
296pub struct OAuthClientAssertionContext {
297    pub client_id: String,
298    pub token_endpoint: String,
299    pub grant_type: OAuthTokenGrant,
300}
301
302/// Produces a fresh application credential for the actual bound token request.
303#[async_trait]
304pub trait OAuthClientAssertionGetter: Send + Sync {
305    async fn get_client_assertion(
306        &self,
307        context: OAuthClientAssertionContext,
308    ) -> Result<String, String>;
309}
310
311/// Cloneable application callback whose Debug output never prints credentials.
312#[derive(Clone)]
313pub struct OAuthClientAssertion(pub Arc<dyn OAuthClientAssertionGetter>);
314
315impl std::fmt::Debug for OAuthClientAssertion {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        f.debug_struct("OAuthClientAssertion")
318            .finish_non_exhaustive()
319    }
320}
321
322/// Configuration for a single OAuth provider.
323#[derive(Clone)]
324#[expect(
325    clippy::struct_excessive_bools,
326    reason = "Preserve independent public provider policy switches"
327)]
328pub struct OAuthProvider {
329    pub client_id: String,
330    /// Additional Google client IDs accepted when verifying ID tokens.
331    pub additional_client_ids: Vec<String>,
332    /// Google Workspace domain restriction, independently of authorization parameters.
333    pub hosted_domain: Option<String>,
334    /// Require a verified provider email before creating the authentication session.
335    pub require_email_verification: bool,
336    pub client_secret: String,
337    pub auth_url: String,
338    pub token_url: String,
339    pub user_info_url: Option<String>,
340    pub scopes: Vec<String>,
341    /// Built-in authorization behavior. `None` preserves custom-provider behavior.
342    /// `scopes` remains the provider's base scope list; this policy adds configured
343    /// and request scopes in the provider's published order.
344    pub authorization: Option<OAuthAuthorizationPolicy>,
345    pub authorization_params: Vec<(String, String)>,
346    /// Query parameter names accepted from per-request `authorizationParams`.
347    /// Empty by default. Unlisted names and reserved OAuth parameters are ignored.
348    /// Allowed values override `authorization_params` and `additionalParams`,
349    /// but provider policy's `fixed_authorization_params` take precedence.
350    pub allowed_request_params: Vec<String>,
351    /// Selects the factory account subject from the original provider profile.
352    pub account_subject: Option<OAuthAccountSubject>,
353    pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
354    pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
355    pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
356    /// Application override takes precedence over the trusted built-in JWKS policy.
357    pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
358    pub id_token: Option<super::id_token::OAuthIdTokenConfig>,
359    /// Disable the complete ID-token branch, including an application verifier override.
360    pub disable_id_token_sign_in: bool,
361    pub disable_implicit_sign_up: bool,
362    pub disable_sign_up: bool,
363    /// Start a new state-bound authorization flow for a callback without state.
364    pub allow_idp_initiated: bool,
365    pub override_user_info_on_sign_in: bool,
366}
367
368/// Ordering of configured and per-request additions to a provider's base scopes.
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub enum OAuthScopeOrder {
371    ConfiguredThenRequested,
372    RequestedThenConfigured,
373}
374
375/// Encoding of the scope query value required by a provider's authorization endpoint.
376#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
377pub enum OAuthScopeEncoding {
378    #[default]
379    Form,
380    UriComponent,
381}
382
383/// OAuth token-endpoint credential transport selected by a provider.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum OAuthTokenEndpointAuth {
386    ClientSecretBasic,
387    ClientSecretPost,
388    PrivateKeyJwt,
389    None,
390    /// `TikTok` authenticates with `client_key` and `client_secret`, never `client_id`.
391    ClientKeyPost,
392}
393
394#[derive(Debug, Clone)]
395pub struct OAuthAuthorizationCodeContext {
396    pub code: String,
397    pub redirect_uri: String,
398    pub code_verifier: Option<String>,
399    pub device_id: Option<String>,
400}
401
402#[async_trait]
403pub trait OAuthAuthorizationCodeHandler: Send + Sync {
404    /// Reconfigure a factory transport that directly interpolates client IDs.
405    fn configured_client_ids(
406        &self,
407        _ids: &[String],
408    ) -> Option<Arc<dyn OAuthAuthorizationCodeHandler>> {
409        None
410    }
411
412    async fn validate_authorization_code(
413        &self,
414        context: OAuthAuthorizationCodeContext,
415    ) -> Result<OAuthTokenSet, String>;
416}
417
418#[derive(Clone)]
419pub struct OAuthAuthorizationCodeCallback(pub Arc<dyn OAuthAuthorizationCodeHandler>);
420impl std::fmt::Debug for OAuthAuthorizationCodeCallback {
421    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        formatter.write_str("OAuthAuthorizationCodeCallback(..)")
423    }
424}
425
426/// Raw provider profile and actual grant tokens for application account authority.
427#[derive(Debug, Clone)]
428pub struct OAuthAccountKeyContext {
429    pub tokens: OAuthTokenSet,
430    pub profile: Value,
431}
432#[async_trait]
433pub trait OAuthAccountKeyResolver: Send + Sync {
434    async fn resolve(&self, context: OAuthAccountKeyContext) -> Result<Value, String>;
435}
436#[derive(Clone)]
437pub struct OAuthAccountKey(pub Arc<dyn OAuthAccountKeyResolver>);
438impl std::fmt::Debug for OAuthAccountKey {
439    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        f.debug_struct("OAuthAccountKey").finish_non_exhaustive()
441    }
442}
443
444/// Immutable authorization configuration used by the built-in social providers.
445/// Scope entries retain their original order and whitespace; providers may
446/// opt into removing exact duplicates.
447#[derive(Debug, Clone)]
448pub struct OAuthAuthorizationPolicy {
449    /// Optional application subject resolver; precedes the profile-only factory subject.
450    pub account_key: Option<OAuthAccountKey>,
451    /// Application callback path, normalized relative to the authentication base.
452    pub callback_path: Option<String>,
453    /// Preserve explicit false separately from absent provider signup configuration.
454    pub disable_sign_up_option: Option<bool>,
455    /// RP-initiated logout from trusted generic-provider configuration.
456    pub end_session: Option<OAuthEndSessionConfig>,
457    /// Verify code-grant ID tokens against trusted discovery metadata before profile admission.
458    pub verify_grant_id_token: bool,
459    /// Bind discovered ID tokens to a nonce in authenticated OAuth state.
460    pub id_token_nonce_binding: bool,
461    /// Discovery advertised OIDC signing algorithms; prepend openid if absent.
462    pub discovery_openid_scope: bool,
463    /// Application-owned or provider-specific code grant implementation.
464    pub authorization_code: Option<OAuthAuthorizationCodeCallback>,
465    /// A code grant can retain PKCE even when authorization disables it (Zoom).
466    pub authorization_code_pkce: Option<bool>,
467    /// Name of the client identifier in custom authorization URLs.
468    pub client_id_parameter: String,
469    /// Custom URL factories may interpolate the entire client array.
470    pub literal_client_id: Option<String>,
471    /// `WeChat`'s custom factory constructs an expiry even for zero/null seconds.
472    pub token_expiry_always: bool,
473    /// Custom factory token objects can omit returned grant ID tokens.
474    pub token_response_omits_id_token: bool,
475    pub scope_separator: String,
476    pub emit_empty_scope: bool,
477    pub authorization_fragment: Option<String>,
478    /// Dedicated factories may lack a built-in refresh implementation (Vercel).
479    pub supports_refresh: bool,
480    /// `TikTok`'s published factory ignores mapProfileToUser.
481    pub supports_profile_mapper: bool,
482    /// Published social factories pass through omitted access tokens to userinfo.
483    pub allow_missing_access_token: bool,
484    /// Generic-provider fallback, applied only when a grant omits actual expiry.
485    pub default_access_token_expires_in: Option<f64>,
486    pub configured_scopes: Vec<String>,
487    /// Providers such as `PayPal` deliberately omit even configured/requested scopes.
488    pub omit_scopes: bool,
489    pub scope_encoding: OAuthScopeEncoding,
490    /// Retain the first occurrence of each scope, as Cloudflare requires.
491    pub deduplicate_scopes: bool,
492    pub require_client_id: bool,
493    /// `None` preserves the existing generic provider credential transport.
494    pub token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
495    /// Grant-specific authentication when refresh differs from code exchange.
496    pub refresh_token_endpoint_auth: Option<OAuthTokenEndpointAuth>,
497    /// Provider parameters applied after caller additions.
498    pub fixed_authorization_params: Vec<(String, String)>,
499    /// Optional application client key sent in authorization-code forms only.
500    pub authorization_code_client_key: Option<String>,
501    /// Trusted provider headers applied to authorization-code grants only.
502    pub authorization_code_headers: Vec<(String, String)>,
503    /// Trusted additional code-grant form fields (`tokenUrlParams`). Fields
504    /// already supplied by the grant, including PKCE, are preserved. Token
505    /// authentication is applied afterwards and retains credential authority.
506    pub authorization_code_params: std::collections::BTreeMap<String, String>,
507    /// Trusted static refresh form fields (`refreshTokenParams`). Ordinary
508    /// fields replace defaults; grant type and refresh token cannot be replaced.
509    /// Validate tenant, scope, and audience entitlements before configuring them.
510    pub refresh_token_params: std::collections::BTreeMap<String, String>,
511    /// When configured, replaces the static map at each default refresh grant.
512    /// `Ok(None)` supplies no additions; errors abort before transport/writes.
513    /// Custom refresh handlers take precedence and bypass this resolver.
514    pub refresh_token_params_resolver: Option<OAuthRefreshTokenParams>,
515    /// Required by `private_key_jwt`; invoked afresh for each real token grant.
516    pub client_assertion: Option<OAuthClientAssertion>,
517    /// Exact configured refresh scope, including an explicitly empty value.
518    pub refresh_scope: Option<String>,
519    pub response_type: String,
520    /// Application callback URI overrides the generated provider callback.
521    pub redirect_uri: Option<String>,
522    pub response_mode: Option<String>,
523    pub require_client_secret: bool,
524    pub login_hint: bool,
525    pub disable_default_scopes: bool,
526    pub scope_order: OAuthScopeOrder,
527    pub pkce: bool,
528    pub prompt: Option<String>,
529    /// Used when `prompt` is absent or empty; Discord defaults to `none`.
530    pub default_prompt: Option<String>,
531    /// Discord emits this JS number only when the effective scopes contain `bot`.
532    pub discord_permissions: Option<f64>,
533    /// Preserve thrown decoded grant-profile failures instead of redirecting.
534    /// Missing/falsy ID tokens still follow the absent-profile redirect.
535    pub propagate_grant_profile_errors: bool,
536    /// Preserve effective raw email type errors at their callback stage.
537    /// Typed native profile fields remain unchanged.
538    pub preserve_raw_email_errors: bool,
539    /// Retain the published adapter scalar rather than discarding it in bool projection.
540    pub preserve_raw_profile_scalars: bool,
541    /// Some published factories omit their returned options object entirely.
542    pub honor_factory_options: bool,
543    /// Preserve uncaught published profile/application callback exceptions.
544    pub source_profile_exceptions: bool,
545}
546
547impl Default for OAuthAuthorizationPolicy {
548    fn default() -> Self {
549        Self {
550            account_key: None,
551            callback_path: None,
552            disable_sign_up_option: None,
553            end_session: None,
554            verify_grant_id_token: false,
555            id_token_nonce_binding: false,
556            discovery_openid_scope: false,
557            authorization_code: None,
558            authorization_code_pkce: None,
559            client_id_parameter: "client_id".into(),
560            literal_client_id: None,
561            token_expiry_always: false,
562            token_response_omits_id_token: false,
563            scope_separator: " ".into(),
564            emit_empty_scope: false,
565            authorization_fragment: None,
566            supports_refresh: true,
567            supports_profile_mapper: true,
568            default_access_token_expires_in: None,
569            allow_missing_access_token: false,
570            configured_scopes: Vec::new(),
571            omit_scopes: false,
572            scope_encoding: OAuthScopeEncoding::Form,
573            deduplicate_scopes: false,
574            require_client_id: false,
575            token_endpoint_auth: None,
576            refresh_token_endpoint_auth: None,
577            fixed_authorization_params: Vec::new(),
578            authorization_code_client_key: None,
579            authorization_code_headers: Vec::new(),
580            authorization_code_params: std::collections::BTreeMap::new(),
581            refresh_token_params: std::collections::BTreeMap::new(),
582            refresh_token_params_resolver: None,
583            client_assertion: None,
584            refresh_scope: None,
585            response_type: "code".into(),
586            redirect_uri: None,
587            response_mode: None,
588            require_client_secret: false,
589            login_hint: true,
590            disable_default_scopes: false,
591            scope_order: OAuthScopeOrder::ConfiguredThenRequested,
592            pkce: true,
593            prompt: None,
594            default_prompt: None,
595            discord_permissions: None,
596            propagate_grant_profile_errors: false,
597            preserve_raw_email_errors: false,
598            preserve_raw_profile_scalars: false,
599            honor_factory_options: true,
600            source_profile_exceptions: false,
601        }
602    }
603}
604
605impl OAuthProvider {
606    /// Install asynchronous partial mapping on the dedicated factory's profile
607    /// handler. Installing a custom userinfo handler afterwards replaces both
608    /// the default transport and mapping, matching getUserInfo precedence.
609    #[must_use]
610    pub fn with_profile_mapper(mut self, mapper: Arc<dyn OAuthProfileMapper>) -> Self {
611        if self
612            .authorization
613            .as_ref()
614            .is_none_or(|policy| policy.supports_profile_mapper)
615            && let Some(handler) = self.get_user_info.take()
616        {
617            self.get_user_info = Some(handler.mapped_handler(mapper).unwrap_or(handler));
618        }
619        self
620    }
621
622    #[must_use]
623    pub fn with_client_ids(mut self, client_ids: Vec<String>) -> Self {
624        if let Some(handler) = self
625            .get_user_info
626            .as_ref()
627            .and_then(|handler| handler.configured_client_ids(&client_ids))
628        {
629            self.get_user_info = Some(handler);
630        }
631        if let Some(handler) = self
632            .refresh_access_token
633            .as_ref()
634            .and_then(|handler| handler.configured_client_ids(&client_ids))
635        {
636            self.refresh_access_token = Some(handler);
637        }
638        if let Some(policy) = self.authorization.as_mut() {
639            if policy.client_id_parameter == "appid" {
640                policy.literal_client_id = Some(client_ids.join(","));
641            }
642            if let Some(handler) = policy
643                .authorization_code
644                .as_ref()
645                .and_then(|callback| callback.0.configured_client_ids(&client_ids))
646            {
647                policy.authorization_code = Some(OAuthAuthorizationCodeCallback(handler));
648            }
649        }
650        if let Some(policy) = self.id_token.as_mut() {
651            policy.client_ids = Some(client_ids.clone());
652        }
653        let mut ids = client_ids.into_iter();
654        self.client_id = ids.next().unwrap_or_default();
655        self.additional_client_ids = ids.collect();
656        self
657    }
658
659    #[must_use]
660    pub const fn require_email_verification(mut self, required: bool) -> Self {
661        self.require_email_verification = required;
662        self
663    }
664}
665
666impl std::fmt::Debug for OAuthConfig {
667    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
668        f.debug_struct("OAuthConfig").finish_non_exhaustive()
669    }
670}
671
672impl std::fmt::Debug for OAuthProvider {
673    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
674        f.debug_struct("OAuthProvider").finish_non_exhaustive()
675    }
676}
677
678mod huggingface;
679mod kakao;
680mod kick;
681mod line;
682pub use huggingface::HuggingFaceOptions;
683pub use kakao::KakaoOptions;
684pub use kick::KickOptions;
685pub use line::LineOptions;
686
687mod naver;
688pub use naver::NaverOptions;
689
690// LCOV_EXCL_START
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use std::sync::Arc;
695    use tokio::io::{AsyncReadExt, AsyncWriteExt};
696    use tokio::sync::Mutex;
697
698    async fn start_github_mock_server(
699        profile: Value,
700        emails: Value,
701    ) -> (String, String, Arc<Mutex<Vec<String>>>) {
702        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
703        let addr = listener.local_addr().unwrap();
704        let requests = Arc::new(Mutex::new(Vec::new()));
705        let captured_requests = std::sync::Arc::clone(&requests);
706
707        tokio::spawn(async move {
708            loop {
709                let Ok((mut stream, _)) = listener.accept().await else {
710                    break;
711                };
712                let profile = profile.clone();
713                let emails = emails.clone();
714                let requests_2 = std::sync::Arc::clone(&captured_requests);
715                tokio::spawn(async move {
716                    let mut buffer = vec![0u8; 4096];
717                    let read = stream.read(&mut buffer).await.unwrap_or(0);
718                    let request = String::from_utf8_lossy(
719                        (buffer)
720                            .get(..read)
721                            .expect("fixture contains the requested index"),
722                    )
723                    .to_string();
724                    requests_2.lock().await.push(request.clone());
725
726                    let (status, body) = if request.contains("/user/emails") {
727                        ("200 OK", emails.to_string())
728                    } else if request.contains("/user") {
729                        ("200 OK", profile.to_string())
730                    } else {
731                        (
732                            "404 Not Found",
733                            serde_json::json!({ "error": "not found" }).to_string(),
734                        )
735                    };
736
737                    let response = format!(
738                        "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
739                        body.len(),
740                    );
741
742                    _ = stream.write_all(response.as_bytes()).await;
743                    _ = stream.flush().await;
744                });
745            }
746        });
747
748        let base_url = format!("http://127.0.0.1:{}", addr.port());
749        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
750        (
751            format!("{base_url}/user"),
752            format!("{base_url}/user/emails"),
753            requests,
754        )
755    }
756
757    // Upstream source: packages/core/src/social-providers/github.ts :: github().createAuthorizationURL default scope list.
758    #[test]
759    fn github_provider_uses_ts_default_scopes() {
760        let provider = OAuthProvider::github("github-client-id", "github-client-secret");
761
762        assert_eq!(
763            provider.scopes,
764            vec!["read:user".to_owned(), "user:email".to_owned()]
765        );
766        assert!(provider.get_user_info.is_some());
767        assert!(provider.map_user_info.is_none());
768    }
769
770    // Upstream source: packages/core/src/social-providers/github.ts :: github().getUserInfo fallback from profile.email to /user/emails, login fallback for name, and request headers.
771    #[tokio::test]
772    async fn github_provider_get_user_info_uses_email_fallback_and_login_name() {
773        let (user_url, emails_url, requests) = start_github_mock_server(
774            serde_json::json!({
775                "id": 42,
776                "login": "octocat",
777                "name": null,
778                "email": null,
779                "avatar_url": "https://avatars.githubusercontent.com/u/42?v=4",
780            }),
781            serde_json::json!([
782                {
783                    "email": "octocat@example.com",
784                    "primary": true,
785                    "verified": true,
786                    "visibility": "private"
787                },
788                {
789                    "email": "secondary@example.com",
790                    "primary": false,
791                    "verified": false,
792                    "visibility": "private"
793                }
794            ]),
795        )
796        .await;
797
798        let provider = OAuthProvider::github_with_endpoints(
799            "github-client-id",
800            "github-client-secret",
801            "https://github.com/login/oauth/authorize",
802            "https://github.com/login/oauth/access_token",
803            &user_url,
804            &emails_url,
805        );
806        let handler = provider.get_user_info.as_ref().unwrap();
807
808        let response = handler
809            .get_user_info(OAuthUserInfoRequest {
810                access_token: Some("github-access-token".to_owned()),
811                ..Default::default()
812            })
813            .await
814            .unwrap();
815
816        assert_eq!(response.user.id, "42");
817        assert_eq!(response.user.email, "octocat@example.com");
818        assert_eq!(response.user.name.as_deref(), Some("octocat"));
819        assert_eq!(
820            response.user.image.as_deref(),
821            Some("https://avatars.githubusercontent.com/u/42?v=4")
822        );
823        assert!(response.user.email_verified);
824        assert_eq!(
825            (*(response.data)
826                .get("email")
827                .expect("fixture contains the requested index")),
828            serde_json::json!("octocat@example.com")
829        );
830
831        let requests = requests.lock().await;
832        assert_eq!(requests.len(), 2);
833        for request in requests.iter() {
834            let lowered = request.to_ascii_lowercase();
835            assert!(lowered.contains("authorization: bearer github-access-token"));
836            assert!(lowered.contains("user-agent: better-auth"));
837        }
838    }
839
840    // Upstream source: packages/core/src/social-providers/github.ts :: github().getUserInfo keeps profile.email when present and resolves verified status from the matching email record.
841    #[tokio::test]
842    async fn github_provider_get_user_info_keeps_inline_email() {
843        let (user_url, emails_url, _) = start_github_mock_server(
844            serde_json::json!({
845                "id": "github-inline-email",
846                "login": "octocat",
847                "name": "Octo Cat",
848                "email": "public@example.com",
849                "avatar_url": null,
850            }),
851            serde_json::json!([
852                {
853                    "email": "primary@example.com",
854                    "primary": true,
855                    "verified": true,
856                    "visibility": "private"
857                },
858                {
859                    "email": "public@example.com",
860                    "primary": false,
861                    "verified": false,
862                    "visibility": "public"
863                }
864            ]),
865        )
866        .await;
867
868        let provider = OAuthProvider::github_with_endpoints(
869            "github-client-id",
870            "github-client-secret",
871            "https://github.com/login/oauth/authorize",
872            "https://github.com/login/oauth/access_token",
873            &user_url,
874            &emails_url,
875        );
876        let handler = provider.get_user_info.as_ref().unwrap();
877
878        let response = handler
879            .get_user_info(OAuthUserInfoRequest {
880                access_token: Some("github-access-token".to_owned()),
881                ..Default::default()
882            })
883            .await
884            .unwrap();
885
886        assert_eq!(response.user.email, "public@example.com");
887        assert_eq!(response.user.name.as_deref(), Some("Octo Cat"));
888        assert!(!response.user.email_verified);
889    }
890}
891// LCOV_EXCL_STOP
892
893mod linkedin;
894pub use linkedin::LinkedInOptions;
895mod linear;
896pub use linear::LinearOptions;