Skip to main content

better_auth_api/plugins/oauth/
proxy.rs

1//! Transfer verified OAuth profiles from a production callback to a preview.
2
3use better_auth_core::{
4    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
5    AuthSchema, BeforeRequestAction, HttpMethod, OAuthStateStrategy,
6};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use url::Url;
11
12use super::{
13    OAuthProvider, OAuthTokenSet, OAuthUserInfo, OAuthUserInfoRequest, handlers,
14    state::{self, OAuthStatePayload},
15};
16use crate::plugins::{json_body, symmetric};
17
18mod environment;
19
20#[cfg(test)]
21mod config_tests;
22
23/// Production-to-preview callback configuration.
24#[derive(Clone, better_auth_core::PluginConfig)]
25#[plugin(name = "OAuthProxyPlugin")]
26pub struct OAuthProxyConfig {
27    /// Production callback base URL; also overrides the environment for proxy skip checks.
28    #[config(default = None)]
29    pub production_url: Option<String>,
30    /// Explicit preview URL; otherwise use a trusted request host, hosting environment, or base URL.
31    #[config(default = None)]
32    pub current_url: Option<String>,
33    /// Shared encryption secret; defaults to the authentication secret.
34    #[config(default = None)]
35    pub secret: Option<String>,
36    /// Maximum profile age in seconds, including fractions and non-finite values.
37    #[config(default = 60.0)]
38    pub max_age: f64,
39}
40
41/// Complete preview authentication without creating a production session.
42pub struct OAuthProxyPlugin {
43    config: OAuthProxyConfig,
44}
45
46pub(super) const REDIRECT_BASE_CONTEXT: &str = "oauthProxyRedirectBase";
47const CALLBACK_CONTEXT: &str = "oauthProxyCallback";
48
49pub(super) fn validate_callback_url<S: AuthSchema>(
50    req: &AuthRequest,
51    callback: &str,
52    ctx: &AuthContext<S>,
53) -> AuthResult<()> {
54    // The proxy validates the original target before generating this configured receiver.
55    // Exact equality keeps later hooks from substituting an untrusted target.
56    if req
57        .server_context(CALLBACK_CONTEXT)?
58        .as_ref()
59        .and_then(better_auth_core::FieldValue::as_str)
60        == Some(callback)
61    {
62        return Ok(());
63    }
64    handlers::validate_redirect_target(callback, ctx, "Invalid callbackURL")
65}
66
67#[derive(Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69struct StatePackage {
70    state: String,
71    state_cookie: String,
72    is_o_auth_proxy: bool,
73}
74
75#[derive(Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77struct ProfileUser {
78    #[serde(default, flatten, with = "better_auth_core::field_value::serde::map")]
79    additional_fields: better_auth_core::FieldMap,
80    id: String,
81    email: String,
82    name: String,
83    #[serde(
84        default,
85        skip_serializing_if = "better_auth_core::SchemaValue::is_undefined"
86    )]
87    email_verified: better_auth_core::SchemaValue<Option<bool>>,
88    #[serde(
89        default,
90        skip_serializing_if = "Option::is_none",
91        with = "::serde_with::rust::double_option"
92    )]
93    image: Option<Option<String>>,
94}
95
96#[derive(Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98struct ProfileAccount {
99    account_id: String,
100    provider_id: String,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    access_token: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    refresh_token: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    id_token: Option<String>,
107    #[serde(
108        skip_serializing_if = "Option::is_none",
109        default,
110        with = "better_auth_core::field_value::serde::optional_date"
111    )]
112    access_token_expires_at: Option<better_auth_core::FieldDate>,
113    #[serde(
114        skip_serializing_if = "Option::is_none",
115        default,
116        with = "better_auth_core::field_value::serde::optional_date"
117    )]
118    refresh_token_expires_at: Option<better_auth_core::FieldDate>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    scope: Option<String>,
121}
122
123#[derive(Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125struct Profile {
126    user_info: ProfileUser,
127    account: ProfileAccount,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    profile: Option<serde_json::Map<String, Value>>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    scopes: Option<Vec<String>>,
132    state: String,
133    #[serde(rename = "callbackURL")]
134    callback_url: String,
135    #[serde(rename = "newUserURL", skip_serializing_if = "Option::is_none")]
136    new_user_url: Option<String>,
137    #[serde(rename = "errorURL", skip_serializing_if = "Option::is_none")]
138    error_url: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    disable_sign_up: Option<bool>,
141    timestamp: f64,
142}
143
144#[async_trait::async_trait]
145impl<S: AuthSchema> AuthPlugin<S> for OAuthProxyPlugin {
146    fn name(&self) -> &'static str {
147        "oauth-proxy"
148    }
149    fn routes(&self) -> Vec<AuthRoute> {
150        vec![
151            AuthRoute::get("/callback/{id}/oauth-proxy", "oAuthProxyCompletion")
152                .query_validator(crate::plugins::query_input::proxy),
153            AuthRoute::get("/oauth-proxy-callback", "oauthProxyCallback")
154                .query_validator(crate::plugins::query_input::proxy),
155        ]
156    }
157    async fn on_request(
158        &self,
159        req: &AuthRequest,
160        ctx: &AuthContext<S>,
161    ) -> AuthResult<Option<AuthResponse>> {
162        if req.method() != &HttpMethod::Get {
163            return Ok(None);
164        }
165        if req.path() == "/oauth-proxy-callback" {
166            return self.complete(None, req, ctx).await.map(Some);
167        }
168        if let Some(provider) = req
169            .path()
170            .strip_prefix("/callback/")
171            .and_then(|path| path.strip_suffix("/oauth-proxy"))
172            .filter(|provider| !provider.is_empty() && !provider.contains('/'))
173        {
174            return self.complete(Some(provider), req, ctx).await.map(Some);
175        }
176        Ok(None)
177    }
178    async fn before_request(
179        &self,
180        req: &AuthRequest,
181        ctx: &AuthContext<S>,
182    ) -> AuthResult<Option<BeforeRequestAction>> {
183        if req.method() == &HttpMethod::Post
184            && matches!(req.path(), "/sign-in/social" | "/link-social")
185        {
186            return better_auth_core::observability::instrumentation::with_endpoint_hook(
187                &ctx.config,
188                req,
189                "before",
190                "plugin:oauth-proxy",
191                async { self.initiate(req, ctx) },
192            )
193            .await;
194        }
195        if let Some(provider) = req
196            .path()
197            .strip_prefix("/callback/")
198            .filter(|provider| !provider.is_empty() && !provider.contains('/'))
199        {
200            return better_auth_core::observability::instrumentation::with_endpoint_hook(
201                &ctx.config,
202                req,
203                "before",
204                "plugin:oauth-proxy",
205                async {
206                    Ok(self
207                        .production_callback(provider, req, ctx)
208                        .await?
209                        .map(BeforeRequestAction::Respond))
210                },
211            )
212            .await;
213        }
214        Ok(None)
215    }
216    async fn after_request(
217        &self,
218        req: &AuthRequest,
219        response: &mut AuthResponse,
220        ctx: &AuthContext<S>,
221    ) -> AuthResult<()> {
222        if req.path().starts_with("/sign-in/social") || req.path() == "/link-social" {
223            return better_auth_core::observability::instrumentation::with_endpoint_hook(
224                &ctx.config,
225                req,
226                "after",
227                "plugin:oauth-proxy",
228                async {
229                    if req.server_context(REDIRECT_BASE_CONTEXT)?.is_some() {
230                        self.wrap_authorization(response, ctx).await?;
231                    }
232                    Ok(())
233                },
234            )
235            .await;
236        }
237        if !req
238            .path()
239            .strip_prefix("/callback/")
240            .is_some_and(|provider| !provider.is_empty() && !provider.contains('/'))
241        {
242            return Ok(());
243        }
244        better_auth_core::observability::instrumentation::with_endpoint_hook(
245            &ctx.config,
246            req,
247            "after",
248            "plugin:oauth-proxy",
249            async {
250                let Some(location) = response.headers.get("location").filter(|location| {
251                    location.contains("/oauth-proxy?callbackURL")
252                        || location.contains("/oauth-proxy-callback?callbackURL")
253                }) else {
254                    return Ok(());
255                };
256                let Ok(location) = Url::parse(location) else {
257                    return Ok(());
258                };
259                let production = parse_url(
260                    self.config
261                        .production_url
262                        .as_deref()
263                        .filter(|url| !url.is_empty())
264                        .unwrap_or(ctx.base_url()),
265                )?;
266                if location.origin() == production.origin()
267                    && let Some((_, target)) =
268                        location.query_pairs().find(|(key, _)| key == "callbackURL")
269                {
270                    let _ = response.headers.insert("location", target.into_owned());
271                }
272                Ok(())
273            },
274        )
275        .await
276    }
277}
278
279impl OAuthProxyPlugin {
280    fn profile_expired(&self, timestamp: f64, now: i64) -> bool {
281        let age = (now as f64 - timestamp) / 1000.0;
282        age > self.config.max_age || age < -10.0
283    }
284
285    fn encryption_key<'a, S: AuthSchema>(
286        &'a self,
287        ctx: &'a AuthContext<S>,
288    ) -> better_auth_core::SecretKey<'a> {
289        self.config
290            .secret
291            .as_deref()
292            .map_or_else(|| ctx.config.encryption_secret(), Into::into)
293    }
294
295    fn initiate<S: AuthSchema>(
296        &self,
297        req: &AuthRequest,
298        ctx: &AuthContext<S>,
299    ) -> AuthResult<Option<BeforeRequestAction>> {
300        if self.config.skip_proxy(req, ctx) {
301            return Ok(None);
302        }
303        let mut body = match json_body::parse(req) {
304            Ok(Some(Value::Object(body))) => body,
305            _ => return Ok(None),
306        };
307        let Some(provider) = body
308            .get("provider")
309            .and_then(Value::as_str)
310            .filter(|value| !value.is_empty())
311        else {
312            return Ok(None);
313        };
314        let current = self.config.resolve_current_url(req, ctx)?;
315        let original = body
316            .get("callbackURL")
317            .and_then(Value::as_str)
318            .filter(|value| !value.is_empty())
319            .map(str::to_owned)
320            .unwrap_or_else(|| handlers::auth_base_url(ctx));
321        handlers::validate_redirect_target(&original, ctx, "Invalid callbackURL")?;
322        let mut callback = parse_url(&format!(
323            "{}{}{}{}",
324            current.origin().ascii_serialization(),
325            ctx.config.base_path,
326            "/callback/",
327            provider
328        ))?;
329        callback.set_path(&format!("{}/oauth-proxy", callback.path()));
330        let _ = callback
331            .query_pairs_mut()
332            .append_pair("callbackURL", &original);
333        let _ = body.insert("callbackURL".into(), callback.to_string().into());
334        req.set_server_context(CALLBACK_CONTEXT, callback.to_string().into())?;
335        let redirect_base = self
336            .config
337            .production_url
338            .as_deref()
339            .filter(|url| !url.is_empty())
340            .map(|url| format!("{}{}", url.trim_end_matches('/'), ctx.config.base_path))
341            .unwrap_or_else(|| handlers::auth_base_url(ctx));
342        req.set_server_context(REDIRECT_BASE_CONTEXT, redirect_base.into())?;
343        Ok(Some(BeforeRequestAction::ReplaceBody(serde_json::to_vec(
344            &body,
345        )?)))
346    }
347
348    async fn wrap_authorization<S: AuthSchema>(
349        &self,
350        response: &mut AuthResponse,
351        ctx: &AuthContext<S>,
352    ) -> AuthResult<()> {
353        if response.status != 200 {
354            return Ok(());
355        }
356        let mut body: serde_json::Map<String, Value> =
357            serde_json::from_slice(&response.body.bytes()?)?;
358        let Some(url) = body
359            .get("url")
360            .and_then(Value::as_str)
361            .filter(|value| !value.is_empty())
362        else {
363            return Ok(());
364        };
365        let mut url = parse_url(url)?;
366        let Some((_, original_state)) = url.query_pairs().find(|(key, _)| key == "state") else {
367            return Ok(());
368        };
369        let original_state = original_state.into_owned();
370        let payload = match ctx.config.account.store_state_strategy() {
371            OAuthStateStrategy::Database => {
372                let verification = ctx
373                    .database
374                    .get_verification_by_identifier(&original_state)
375                    .await?
376                    .ok_or_else(|| AuthError::internal("OAuth proxy state was not persisted"))?;
377                verification.value.typed()?.clone()
378            }
379            OAuthStateStrategy::Cookie => {
380                let name = state::state_cookie_name(&ctx.config);
381                let value = response
382                    .headers
383                    .get_all("set-cookie")
384                    .find_map(|header| header.split(';').next()?.strip_prefix(&format!("{name}=")))
385                    .ok_or_else(|| {
386                        AuthError::internal("OAuth proxy state cookie was not issued")
387                    })?;
388                serde_json::to_string(&state::decode_cookie_state_value(
389                    ctx.config.encryption_secret(),
390                    value,
391                )?)?
392            }
393        };
394        let package = StatePackage {
395            state: original_state,
396            state_cookie: symmetric::encrypt(self.encryption_key(ctx), &payload)?,
397            is_o_auth_proxy: true,
398        };
399        let mut encrypted = Some(symmetric::encrypt(
400            self.encryption_key(ctx),
401            &serde_json::to_string(&package)?,
402        )?);
403        let pairs: Vec<_> = url
404            .query_pairs()
405            .filter_map(|(key, value)| {
406                let value = if key == "state" {
407                    encrypted.take()?
408                } else {
409                    value.into_owned()
410                };
411                Some((key.into_owned(), value))
412            })
413            .collect();
414        let _ = url.query_pairs_mut().clear().extend_pairs(pairs);
415        let _ = body.insert("url".into(), url.to_string().into());
416        response.body = better_auth_core::ResponseBody::Bytes(serde_json::to_vec(&body)?);
417        Ok(())
418    }
419
420    async fn production_callback<S: AuthSchema>(
421        &self,
422        provider_id: &str,
423        req: &AuthRequest,
424        ctx: &AuthContext<S>,
425    ) -> AuthResult<Option<AuthResponse>> {
426        let mut raw = req
427            .query
428            .as_ref()
429            .and_then(serde_json::Value::as_object)
430            .cloned()
431            .unwrap_or_default();
432        // The proxy before hook uses safeParse and leaves invalid input for endpoint validation.
433        if req.method() == &HttpMethod::Post
434            && let Some(bytes) = &req.body
435        {
436            let body = req.body_as_json::<serde_json::Value>().unwrap_or_else(|_| {
437                serde_json::Value::Object(
438                    url::form_urlencoded::parse(bytes)
439                        .map(|(key, value)| {
440                            (
441                                key.into_owned(),
442                                serde_json::Value::String(value.into_owned()),
443                            )
444                        })
445                        .collect(),
446                )
447            });
448            if let Some(body) = body.as_object() {
449                for (key, value) in body {
450                    let _ = raw.entry(key.clone()).or_insert_with(|| value.clone());
451                }
452            }
453        }
454        let Some(state) = raw
455            .get("state")
456            .and_then(serde_json::Value::as_str)
457            .map(str::to_owned)
458        else {
459            return Ok(None);
460        };
461        let Ok(query) = crate::plugins::query_input::strings(
462            Some(raw.into()),
463            false,
464            &[("code", false), ("error", false), ("user", false)],
465        ) else {
466            return Ok(None);
467        };
468        let mut params = crate::plugins::query_input::string_map(&query)?;
469        let _ = params.insert("state".to_owned(), state);
470        let Some(state) = params.get("state") else {
471            return Ok(None);
472        };
473        let Ok(package) = symmetric::decrypt(self.encryption_key(ctx), state)
474            .and_then(|plain| serde_json::from_str::<StatePackage>(&plain).map_err(Into::into))
475        else {
476            return Ok(None);
477        };
478        if !package.is_o_auth_proxy || package.state.is_empty() || package.state_cookie.is_empty() {
479            return Ok(None);
480        }
481        let Ok(state) = symmetric::decrypt(self.encryption_key(ctx), &package.state_cookie)
482            .and_then(|plain| OAuthStatePayload::parse(&plain))
483        else {
484            return Ok(None);
485        };
486        let default_error = ctx
487            .config
488            .api_error
489            .error_url
490            .clone()
491            .filter(|value| !value.is_empty())
492            .unwrap_or_else(|| format!("{}/error", handlers::auth_base_url(ctx)));
493        let error_url = state
494            .error_url
495            .as_deref()
496            .filter(|url| !url.is_empty())
497            .unwrap_or(&default_error);
498        if state
499            .oauth_state
500            .as_deref()
501            .is_some_and(|value| value != package.state)
502        {
503            return Ok(Some(redirect_error(error_url, "state_mismatch", None)?));
504        }
505        if let Some(error) = params.get("error").filter(|value| !value.is_empty()) {
506            return Ok(Some(redirect_error(error_url, error, None)?));
507        }
508        let Some(code) = params.get("code").filter(|value| !value.is_empty()) else {
509            return Ok(Some(redirect_error(error_url, "no_code", None)?));
510        };
511        let Some(provider) = ctx
512            .extensions
513            .get::<std::sync::Arc<super::resolved::ResolvedOAuthConfig>>()
514            .and_then(|config| config.providers.get(provider_id))
515        else {
516            return Ok(Some(redirect_error(
517                error_url,
518                "oauth_provider_not_found",
519                None,
520            )?));
521        };
522        let tokens = match super::provider_tokens::validate_authorization_code_via_provider(
523            provider,
524            code,
525            &format!("{}/callback/{provider_id}", handlers::auth_base_url(ctx)),
526            Some(&state.code_verifier),
527            None,
528        )
529        .await
530        {
531            Ok(tokens) => tokens,
532            Err(_) => return Ok(Some(redirect_error(error_url, "invalid_code", None)?)),
533        };
534        let info = match handlers::fetch_user_info_for_code(
535            provider,
536            OAuthUserInfoRequest {
537                access_token: tokens.access_token.clone(),
538                refresh_token: tokens.refresh_token.clone(),
539                id_token: tokens.id_token.clone(),
540                access_token_expires_at: tokens.access_token_expires_at.clone(),
541                refresh_token_expires_at: tokens.refresh_token_expires_at.clone(),
542                token_type: tokens.token_type.clone(),
543                scopes: tokens.scopes.clone(),
544                raw: tokens.raw.clone(),
545                user: handlers::parse_callback_user_payload(params.get("user").map(String::as_str)),
546            },
547            state.id_token_nonce.as_deref(),
548        )
549        .await
550        {
551            Ok(Some(info)) => info,
552            Err(error) => return Err(error),
553            Ok(None) => {
554                return Ok(Some(redirect_error(
555                    error_url,
556                    "unable_to_get_user_info",
557                    None,
558                )?));
559            }
560        };
561        let Some(email) = info.user.email()?.filter(|email| !email.is_empty()) else {
562            return Ok(Some(redirect_error(error_url, "email_not_found", None)?));
563        };
564        let email = email.to_owned();
565        let name = info.user.name()?.unwrap_or_default().to_owned();
566        let mut callback = parse_url(&state.callback_url)?;
567        let final_url = callback
568            .query_pairs()
569            .find(|(key, _)| key == "callbackURL")
570            .map(|(_, value)| value.into_owned())
571            .filter(|value| !value.is_empty())
572            .unwrap_or_else(|| state.callback_url.clone());
573        let payload = Profile {
574            user_info: ProfileUser {
575                additional_fields: info.user.additional_fields,
576                id: info.user.id.clone(),
577                email,
578                name,
579                image: info.user.image,
580                email_verified: info.user.email_verified,
581            },
582            account: ProfileAccount {
583                account_id: info.user.id,
584                provider_id: provider_id.to_owned(),
585                access_token: tokens.access_token,
586                refresh_token: tokens.refresh_token,
587                id_token: tokens.id_token,
588                access_token_expires_at: tokens.access_token_expires_at,
589                refresh_token_expires_at: tokens.refresh_token_expires_at,
590                scope: Some(tokens.scopes.join(",")),
591            },
592            profile: info.data.as_object().cloned(),
593            scopes: Some(tokens.scopes),
594            state: package.state,
595            callback_url: final_url,
596            new_user_url: state.new_user_url,
597            error_url: state.error_url,
598            disable_sign_up: Some(
599                provider.config.disable_sign_up()
600                    || provider.config.disable_implicit_sign_up()
601                        && !state.request_sign_up.unwrap_or(false),
602            ),
603            timestamp: Utc::now().timestamp_millis() as f64,
604        };
605        let encrypted =
606            symmetric::encrypt(self.encryption_key(ctx), &serde_json::to_string(&payload)?)?;
607        let _ = callback
608            .query_pairs_mut()
609            .append_pair("profile", &encrypted);
610        Ok(Some(handlers::redirect_response(callback.as_str())))
611    }
612
613    async fn complete<S: AuthSchema>(
614        &self,
615        provider: Option<&str>,
616        req: &AuthRequest,
617        ctx: &AuthContext<S>,
618    ) -> AuthResult<AuthResponse> {
619        let Some(callback) = req.query_string("callbackURL")? else {
620            return Ok(json_body::validation_error(
621                "[query.callbackURL] Invalid input: expected string, received undefined",
622            ));
623        };
624        handlers::validate_redirect_target(callback, ctx, "Invalid callbackURL")?;
625        let default_error = ctx
626            .config
627            .api_error
628            .error_url
629            .clone()
630            .filter(|value| !value.is_empty())
631            .unwrap_or_else(|| format!("{}/error", ctx.base_url().trim_end_matches('/')));
632        let Some(encrypted) = req
633            .query_string("profile")?
634            .filter(|value| !value.is_empty())
635        else {
636            return redirect_error(&default_error, "missing_profile", None);
637        };
638        let plain = match symmetric::decrypt(self.encryption_key(ctx), encrypted) {
639            Ok(plain) => plain,
640            Err(_) => return redirect_error(&default_error, "invalid_profile", None),
641        };
642        let profile = match serde_json::from_str::<Profile>(&plain) {
643            Ok(profile) if !profile.state.is_empty() && !profile.callback_url.is_empty() => profile,
644            _ => return redirect_error(&default_error, "invalid_payload", None),
645        };
646        let error_url = profile
647            .error_url
648            .as_deref()
649            .filter(|url| !url.is_empty())
650            .unwrap_or(&default_error);
651        if provider.is_some_and(|provider| provider != profile.account.provider_id) {
652            return redirect_error(error_url, "provider_mismatch", None);
653        }
654        if self.profile_expired(profile.timestamp, Utc::now().timestamp_millis()) {
655            return redirect_error(error_url, "payload_expired", None);
656        }
657        let state = match ctx.config.account.store_state_strategy() {
658            OAuthStateStrategy::Database => ctx
659                .database
660                .consume_verification_by_identifier(&profile.state)
661                .await?
662                .map(|verification| {
663                    verification
664                        .value
665                        .display_string()
666                        .map(|value| OAuthStatePayload::parse(&value).ok())
667                })
668                .transpose()?
669                .flatten(),
670            OAuthStateStrategy::Cookie => {
671                state::get_cookie(req, &state::state_cookie_name(&ctx.config))
672                    .and_then(|value| {
673                        state::decode_cookie_state_value(ctx.config.encryption_secret(), &value)
674                            .ok()
675                    })
676                    .filter(|state| {
677                        !state.is_expired()
678                            && state.oauth_state.as_deref() == Some(profile.state.as_str())
679                    })
680            }
681        };
682        let Some(state) = state else {
683            return redirect_error(error_url, "state_mismatch", None);
684        };
685        if state.is_expired()
686            || state
687                .oauth_state
688                .as_deref()
689                .is_some_and(|value| value != profile.state)
690        {
691            return redirect_error(error_url, "state_mismatch", None);
692        }
693        if let Some(anonymous_user) = state.server_context.get("anonymousUserId") {
694            req.set_server_context(
695                "anonymousUserId",
696                better_auth_core::FieldValue::from_json(anonymous_user.clone())?,
697            )?;
698        }
699        let clear = better_auth_core::utils::cookie_utils::create_clear_cookie(
700            &state::state_cookie_name(&ctx.config),
701            &ctx.config,
702        )?;
703        let user = OAuthUserInfo {
704            additional_fields: profile.user_info.additional_fields,
705            id: profile.account.account_id,
706            email: Some(profile.user_info.email.to_lowercase()).into(),
707            name: Some(profile.user_info.name).into(),
708            image: profile.user_info.image,
709            email_verified: profile.user_info.email_verified,
710        };
711        let tokens = OAuthTokenSet {
712            access_token: profile.account.access_token,
713            refresh_token: profile.account.refresh_token,
714            id_token: profile.account.id_token,
715            access_token_expires_at: profile.account.access_token_expires_at,
716            refresh_token_expires_at: profile.account.refresh_token_expires_at,
717            scopes: profile.scopes.unwrap_or_else(|| {
718                profile
719                    .account
720                    .scope
721                    .as_deref()
722                    .unwrap_or_default()
723                    .split(',')
724                    .map(str::to_owned)
725                    .collect()
726            }),
727            ..Default::default()
728        };
729        let provider_profile = profile.profile.clone().map(Value::Object);
730        if let Some(link) = state.link {
731            if let Err(error) = handlers::complete_link_social(
732                &profile.account.provider_id,
733                &user,
734                &tokens,
735                &link,
736                Some(provider_profile.as_ref().unwrap_or(&serde_json::json!({}))),
737                &crate::plugins::endpoint_context::EndpointContext::new(
738                    Some(req),
739                    better_auth_core::FieldValue::Null,
740                    ctx,
741                ),
742            )
743            .await
744            {
745                let (code, description) = error.redirect_parts()?;
746                return Ok(redirect_error(error_url, &code, description.as_deref())?
747                    .with_appended_header("Set-Cookie", clear));
748            }
749            return Ok(handlers::redirect_response(&profile.callback_url)
750                .with_appended_header("Set-Cookie", clear));
751        }
752        let fallback = super::resolved::ResolvedProvider {
753            config: OAuthProvider::google("", ""),
754            generic: None,
755        };
756        let provider = ctx
757            .extensions
758            .get::<std::sync::Arc<super::resolved::ResolvedOAuthConfig>>()
759            .and_then(|config| config.providers.get(&profile.account.provider_id))
760            .unwrap_or(&fallback);
761        let outcome = match super::signin::process_oauth_sign_in(
762            &profile.account.provider_id,
763            provider,
764            &user,
765            &tokens,
766            super::signin::OAuthSignInOptions {
767                request: req,
768                profile: provider_profile.as_ref(),
769                body: Value::Null,
770                disable_sign_up: profile.disable_sign_up.unwrap_or(false),
771                override_user_info: false,
772                callback_url: &profile.callback_url,
773                email_verification: ctx
774                    .extensions
775                    .get::<std::sync::Arc<super::resolved::ResolvedOAuthConfig>>()
776                    .and_then(|config| config.email_verification.as_deref()),
777            },
778            &better_auth_core::RequestMeta::from_request_with_config(
779                req,
780                &ctx.config.advanced.ip_address,
781            ),
782            ctx,
783        )
784        .await
785        {
786            Ok(outcome) => outcome,
787            Err(error) => {
788                let (code, description) = error.redirect_parts()?;
789                return Ok(redirect_error(error_url, &code, description.as_deref())?
790                    .with_appended_header("Set-Cookie", clear));
791            }
792        };
793        let target = if outcome.is_register {
794            profile
795                .new_user_url
796                .as_deref()
797                .filter(|url| !url.is_empty())
798                .unwrap_or(&profile.callback_url)
799        } else {
800            &profile.callback_url
801        };
802        req.append_response_header("Set-Cookie", clear)?;
803        ctx.session_manager()
804            .set_native_session_cookie(req, outcome.issued, None)
805            .await?;
806        Ok(handlers::redirect_response(target))
807    }
808}
809
810fn parse_url(value: &str) -> AuthResult<Url> {
811    Url::parse(value)
812        .map_err(|error| AuthError::bad_request(format!("Invalid OAuth proxy URL: {error}")))
813}
814
815fn redirect_error(
816    target: &str,
817    error: &str,
818    description: Option<&str>,
819) -> AuthResult<AuthResponse> {
820    let mut params = url::form_urlencoded::Serializer::new(String::new());
821    let _ = params.append_pair("error", error);
822    if let Some(description) = description {
823        let _ = params.append_pair("error_description", description);
824    }
825    Ok(handlers::redirect_response(
826        &better_auth_core::utils::url::append_query_params(target, &params.finish())?,
827    ))
828}
829
830#[cfg(test)]
831mod verification_presence_tests {
832    #![expect(
833        clippy::unwrap_used,
834        clippy::indexing_slicing,
835        reason = "Serialization fixtures fail immediately on an invalid ordinary field shape."
836    )]
837
838    use super::ProfileUser;
839    use serde_json::{Value, json};
840
841    #[test]
842    fn proxy_profile_preserves_verification_presence() {
843        for value in [
844            None,
845            Some(Value::Null),
846            Some(json!(false)),
847            Some(json!(true)),
848        ] {
849            let mut profile =
850                json!({"id":"ordinary-account","email":"owner@example.test","name":"Owner"});
851            if let Some(value) = value {
852                profile["emailVerified"] = value;
853            }
854            let decoded: ProfileUser = serde_json::from_value(profile.clone()).unwrap();
855            assert_eq!(serde_json::to_value(decoded).unwrap(), profile);
856        }
857    }
858}