1use 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#[derive(Clone, better_auth_core::PluginConfig)]
25#[plugin(name = "OAuthProxyPlugin")]
26pub struct OAuthProxyConfig {
27 #[config(default = None)]
29 pub production_url: Option<String>,
30 #[config(default = None)]
32 pub current_url: Option<String>,
33 #[config(default = None)]
35 pub secret: Option<String>,
36 #[config(default = 60.0)]
38 pub max_age: f64,
39}
40
41pub 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 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 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, ¶ms.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}