Skip to main content

better_auth_api/plugins/oauth/
mod.rs

1use async_trait::async_trait;
2use std::{collections::HashMap, sync::Arc};
3
4use super::email_verification::EmailVerificationPlugin;
5use tokio::sync::OnceCell;
6
7use better_auth_core::AuthResult;
8use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
9use better_auth_core::{AuthRequest, AuthResponse, HttpMethod};
10
11mod account;
12#[cfg(test)]
13mod apple_client_secret_tests;
14#[cfg(test)]
15mod apple_flow_tests;
16#[cfg(test)]
17mod apple_tests;
18pub use providers::apple::{AppleClientSecretError, AppleClientSecretSigner, AppleOptions};
19mod authorization;
20mod callback;
21mod callbacks;
22pub use callbacks::{OAuthCallbacks, OAuthVerifierFuture};
23mod cognito;
24#[cfg(test)]
25mod cognito_contract_tests;
26#[cfg(test)]
27mod cognito_signed_tests;
28pub use cognito::CognitoOptions;
29pub mod encryption;
30#[cfg(test)]
31mod facebook_contract_tests;
32#[cfg(test)]
33mod facebook_signed_tests;
34mod generic;
35mod generic_presets;
36mod generic_profile;
37#[cfg(all(test, feature = "axum"))]
38mod gitlab_issuer_tests;
39pub(super) mod google;
40pub use google::GoogleOptions;
41#[cfg(all(test, feature = "axum"))]
42mod google_client_ids_tests;
43mod handlers;
44mod id_token;
45pub use providers::facebook::FacebookOptions;
46pub use providers::microsoft::{MicrosoftOptions, MicrosoftProfilePhotoSize};
47#[cfg(all(test, feature = "axum"))]
48mod microsoft_tests;
49pub use providers::twitch::TwitchOptions;
50mod line;
51mod microsoft_entra;
52pub(crate) use handlers::validate_redirect_target;
53pub(crate) use signin::sign_in_verified_profile;
54mod logout;
55pub(super) use logout::handle_sign_out;
56mod oidc;
57#[cfg(all(test, feature = "axum"))]
58mod paybin_tests;
59#[cfg(test)]
60mod paypal_tests;
61mod popup;
62mod provider_tokens;
63mod providers;
64pub use popup::{OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupPlugin};
65mod proxy;
66pub use proxy::{OAuthProxyConfig, OAuthProxyPlugin};
67mod request;
68mod resolved;
69#[cfg(test)]
70mod salesforce_tests;
71mod signin;
72#[cfg(test)]
73mod social_nonce_tests;
74mod social_profile;
75#[cfg(all(test, feature = "axum"))]
76mod social_token_wire_tests;
77mod state;
78mod state_json;
79#[cfg(all(test, feature = "axum"))]
80mod tiktok_tests;
81mod token;
82#[cfg(test)]
83mod twitter_tests;
84mod types;
85#[cfg(test)]
86mod vk_tests;
87#[cfg(test)]
88mod wechat_tests;
89
90#[cfg(test)]
91mod google_test_support;
92#[cfg(test)]
93mod google_tests;
94#[cfg(test)]
95mod verifier_context_tests;
96
97pub use better_auth_core::NativeRequest;
98pub use generic::{
99    GenericOAuthConfig, GenericOAuthProfileContext, GenericOAuthUserInfoHandler,
100    OAuthAccountSubject, OAuthCodeExchange, OAuthProfile, OAuthProfileMapper,
101    OAuthRefreshParameters, OAuthTokenHandler, RefreshTokenParameters, VerifiedOAuthClaims,
102};
103pub use token::{
104    ClientAssertion, ClientAssertionContext, TokenEndpointAuth, TokenEndpointRequestContext,
105    TokenEndpointSecretAuthentication, TokenGrantType, TokenRequestHook,
106};
107
108pub use providers::{
109    OAuthCallbackUserName, OAuthCallbackUserPayload, OAuthConfig, OAuthIdTokenVerifier,
110    OAuthProvider, OAuthRefreshTokenHandler, OAuthTokenSet, OAuthUserInfo, OAuthUserInfoHandler,
111    OAuthUserInfoRequest, OAuthUserInfoResponse,
112};
113
114pub struct OAuthPlugin {
115    config: OAuthConfig,
116    generic: HashMap<String, GenericOAuthConfig>,
117    email_verification: Option<Arc<EmailVerificationPlugin>>,
118    resolved: OnceCell<Arc<resolved::ResolvedOAuthConfig>>,
119}
120
121impl OAuthPlugin {
122    pub fn new() -> Self {
123        Self {
124            config: OAuthConfig::default(),
125            generic: HashMap::new(),
126            email_verification: None,
127            resolved: OnceCell::new(),
128        }
129    }
130
131    pub fn with_config(config: OAuthConfig) -> Self {
132        Self {
133            config,
134            ..Self::new()
135        }
136    }
137
138    pub fn add_provider(mut self, name: &str, provider: OAuthProvider) -> Self {
139        let _ = self.config.providers.insert(name.to_string(), provider);
140        self.resolved = OnceCell::new();
141        self
142    }
143
144    /// Register a generic OAuth/OIDC provider, resolving discovery during initialization.
145    pub fn add_generic_provider(mut self, name: &str, config: GenericOAuthConfig) -> Self {
146        let _ = self.generic.insert(name.to_owned(), config);
147        self.resolved = OnceCell::new();
148        self
149    }
150
151    /// Attach the email verification sender and OAuth sign-up/sign-in policy.
152    pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
153        self.email_verification = Some(plugin);
154        self.resolved = OnceCell::new();
155        self
156    }
157
158    /// Resolve discovery and report whether a provider is available for authentication.
159    ///
160    /// Discovery results are cached and reused during auth initialization. Providers
161    /// skipped after discovery failure return `false`; invalid static configuration
162    /// returns an error. This does not contact token, userinfo, or JWKS endpoints.
163    /// Configuring the plugin after this call invalidates the cached resolution.
164    pub async fn has_provider(&self, name: &str) -> AuthResult<bool> {
165        Ok(self.resolved_config().await?.providers.contains_key(name))
166    }
167
168    async fn resolved_config(&self) -> AuthResult<&Arc<resolved::ResolvedOAuthConfig>> {
169        self.resolved
170            .get_or_try_init(|| async {
171                resolved::ResolvedOAuthConfig::new(
172                    &self.config,
173                    &self.generic,
174                    self.email_verification.clone(),
175                )
176                .await
177                .map(Arc::new)
178            })
179            .await
180    }
181}
182
183impl Default for OAuthPlugin {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189#[async_trait]
190impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for OAuthPlugin {
191    fn name(&self) -> &'static str {
192        "oauth"
193    }
194
195    fn telemetry_plugin_id(&self) -> Option<&'static str> {
196        (!self.generic.is_empty()).then_some("generic-oauth")
197    }
198
199    fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
200        use better_auth_core::observability::telemetry::SocialProviderTelemetry;
201        if let Some(verification) = &self.email_verification {
202            <EmailVerificationPlugin as AuthPlugin<S>>::telemetry(verification, options);
203        }
204        options
205            .social_providers
206            .extend(
207                self.config
208                    .providers
209                    .iter()
210                    .map(|(id, provider)| SocialProviderTelemetry {
211                        id: id.clone(),
212                        map_profile_to_user: provider.map_profile_to_user.is_some(),
213                        disable_default_scope: provider.disable_default_scope,
214                        disable_id_token_sign_in: provider.disable_id_token_sign_in,
215                        disable_implicit_sign_up: provider.disable_implicit_sign_up,
216                        disable_sign_up: provider.disable_sign_up,
217                        get_user_info: provider.get_user_info.is_some(),
218                        override_user_info_on_sign_in: provider.override_user_info_on_sign_in,
219                        prompt: provider.prompt.clone(),
220                        verify_id_token: provider.verify_id_token.is_some(),
221                        scope: provider.scopes.clone(),
222                        refresh_access_token: provider.refresh_access_token.is_some(),
223                    }),
224            );
225    }
226
227    async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
228        let resolved = self.resolved_config().await?;
229        if let Some(callbacks) = ctx.extensions.get::<Arc<OAuthCallbacks<S>>>() {
230            for name in callbacks.verifiers.keys() {
231                if !resolved.providers.contains_key(name) {
232                    return Err(better_auth_core::AuthError::config(format!(
233                        "OAuth verifier provider {name} is not registered"
234                    )));
235                }
236            }
237        }
238        ctx.extensions.insert(self.config.clone());
239        ctx.extensions.insert(resolved.clone());
240        Ok(())
241    }
242
243    fn routes(&self) -> Vec<AuthRoute> {
244        vec![
245            AuthRoute::post("/sign-in/social", "socialSignIn").body_validator(request::validate),
246            AuthRoute::get("/callback/{id}", "callbackOAuth")
247                .body_validator(callback::body)
248                .query_validator(crate::plugins::query_input::callback)
249                .allowed_media_types(&["application/x-www-form-urlencoded", "application/json"]),
250            AuthRoute::post("/callback/{id}", "callbackOAuth")
251                .body_validator(callback::body)
252                .query_validator(crate::plugins::query_input::callback)
253                .allowed_media_types(&["application/x-www-form-urlencoded", "application/json"]),
254            AuthRoute::post("/link-social", "linkSocialAccount")
255                .require_headers(true)
256                .body_validator(request::validate),
257            AuthRoute::post("/get-access-token", "getAccessToken")
258                .body_validator(account::account_body),
259            AuthRoute::post("/refresh-token", "refreshToken").body_validator(account::account_body),
260            AuthRoute::get("/account-info", "accountInfo")
261                .query_validator(crate::plugins::query_input::account_selection),
262        ]
263    }
264
265    async fn on_request(
266        &self,
267        req: &AuthRequest,
268        ctx: &AuthContext<S>,
269    ) -> AuthResult<Option<AuthResponse>> {
270        let config = self.resolved_config().await?;
271        match (req.method(), req.path()) {
272            (HttpMethod::Post, "/sign-in/social") => Ok(Some(
273                handlers::handle_social_sign_in(config, req, ctx).await?,
274            )),
275            (HttpMethod::Get | HttpMethod::Post, path) if path_matches_callback(path) => {
276                let provider = extract_provider_from_callback(path);
277                Ok(Some(
278                    callback::handle_callback(config, &provider, req, ctx).await?,
279                ))
280            }
281            (HttpMethod::Post, "/link-social") => {
282                Ok(Some(handlers::handle_link_social(config, req, ctx).await?))
283            }
284            (HttpMethod::Post, "/get-access-token") => Ok(Some(
285                account::handle_get_access_token(config, req, ctx).await?,
286            )),
287            (HttpMethod::Post, "/refresh-token") => {
288                Ok(Some(account::handle_refresh_token(config, req, ctx).await?))
289            }
290            (HttpMethod::Get, "/account-info") => {
291                Ok(Some(account::handle_account_info(config, req, ctx).await?))
292            }
293            _ => Ok(None),
294        }
295    }
296}
297
298/// Check if the path matches `/callback/{id}` (with optional query string).
299fn path_matches_callback(path: &str) -> bool {
300    let path_without_query = path.split('?').next().unwrap_or(path);
301    path_without_query
302        .strip_prefix("/callback/")
303        .is_some_and(|provider| !provider.is_empty() && !provider.contains('/'))
304}
305
306/// Extract the provider name from `/callback/{id}?...`.
307fn extract_provider_from_callback(path: &str) -> String {
308    let path_without_query = path.split('?').next().unwrap_or(path);
309    path_without_query["/callback/".len()..].to_string()
310}
311
312#[cfg(test)]
313mod generic_signin_tests;
314
315#[cfg(test)]
316mod readiness_tests;
317
318#[cfg(test)]
319mod social_options_tests;
320
321#[cfg(test)]
322mod email_verified_tests;
323
324#[cfg(test)]
325mod http_provider_tests;