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