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