1use super::oauth::handlers::{
4 OAuthSignInError, complete_link_social, create_account_cookie_headers,
5 fetch_user_info_from_provider, parse_callback_user_payload,
6 validate_authorization_code_via_provider,
7};
8use super::oauth::state::{
9 OAuthStatePayload, RecoveredOAuthServerContext, decode_cookie_state_value, get_cookie,
10 state_cookie_name, state_verification_identifier, verified_server_context,
11};
12use super::oauth::{
13 OAuthConfig, OAuthProcessPolicy, OAuthTokenSet, OAuthUserInfo, OAuthUserInfoRequest,
14 oauth_callback_path, oauth_disable_sign_up_option, resolve_oauth_account_key,
15};
16use super::token_crypto::EncryptionPurpose;
17use alibi_core::{
18 AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
19 AuthSchema, AuthSession, BeforeRequestAction, HttpMethod, OAuthStateStrategy,
20};
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use serde_json::Map;
25use serde_json::{Value, json};
26use std::sync::atomic::{AtomicBool, Ordering};
27
28struct OAuthProxyUnhandledError(AtomicBool);
29
30#[derive(Clone)]
36pub struct OAuthProxyConfig {
37 pub current_url: Option<String>,
38 pub production_url: Option<String>,
39 pub max_age_seconds: f64,
40 pub secret: Option<String>,
41}
42
43impl std::fmt::Debug for OAuthProxyConfig {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("OAuthProxyConfig")
46 .field("current_url", &self.current_url)
47 .field("production_url", &self.production_url)
48 .field("max_age_seconds", &self.max_age_seconds)
49 .field("secret", &self.secret.as_ref().map(|_| "[redacted]"))
50 .finish()
51 }
52}
53
54impl Default for OAuthProxyConfig {
55 fn default() -> Self {
56 Self {
57 current_url: None,
58 production_url: None,
59 max_age_seconds: 60.0,
60 secret: None,
61 }
62 }
63}
64
65#[derive(Clone, Default)]
66pub struct OAuthProxyPlugin {
67 config: OAuthProxyConfig,
68}
69
70#[derive(Clone)]
71pub(crate) struct OAuthProxyFlow {
72 pub(crate) effective_auth_base_url: String,
73 pub(crate) callback_url: String,
74}
75
76#[derive(Clone)]
77pub(crate) struct IssuedProxyState {
78 pub(crate) state: String,
79 pub(crate) payload: OAuthStatePayload,
80}
81
82#[derive(Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84struct StatePackage {
85 state: String,
86 state_cookie: String,
87 #[serde(rename = "isOAuthProxy")]
88 is_oauth_proxy: bool,
89}
90
91#[derive(Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93struct ProxyUser {
94 id: String,
95 email: String,
96 name: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 image: Option<String>,
99 #[serde(flatten)]
100 additional_fields: alibi_core::field_policy::FieldOutput,
101 #[serde(default)]
102 email_verified: bool,
103}
104
105#[derive(Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107struct ProxyAccount {
108 provider_id: String,
109 account_id: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 access_token: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 refresh_token: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 id_token: Option<String>,
116 #[serde(
117 skip_serializing_if = "Option::is_none",
118 serialize_with = "alibi_core::utils::datetime::serialize_optional"
119 )]
120 access_token_expires_at: Option<DateTime<Utc>>,
121 #[serde(
122 skip_serializing_if = "Option::is_none",
123 serialize_with = "alibi_core::utils::datetime::serialize_optional"
124 )]
125 refresh_token_expires_at: Option<DateTime<Utc>>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 scope: Option<String>,
128}
129
130#[derive(Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132struct ProxyPayload {
133 user_info: ProxyUser,
134 account: ProxyAccount,
135 #[serde(
136 default,
137 deserialize_with = "alibi_core::utils::json::deserialize_optional_value"
138 )]
139 profile: Option<Value>,
140 scopes: Option<Vec<String>>,
141 state: String,
142 #[serde(rename = "callbackURL")]
143 callback_url: String,
144 #[serde(rename = "newUserURL", skip_serializing_if = "Option::is_none")]
145 new_user_url: Option<String>,
146 #[serde(rename = "errorURL", skip_serializing_if = "Option::is_none")]
147 error_url: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 disable_sign_up: Option<bool>,
150 timestamp: f64,
151}
152
153impl OAuthProxyPlugin {
154 #[must_use]
155 pub fn new() -> Self {
156 Self::default()
157 }
158 #[must_use]
159 pub const fn with_config(config: OAuthProxyConfig) -> Self {
160 Self { config }
161 }
162 fn encrypt<S: AuthSchema>(
163 &self,
164 plain: &str,
165 ctx: &AuthContext<S>,
166 purpose: EncryptionPurpose,
167 ) -> AuthResult<String> {
168 match &self.config.secret {
169 Some(secret) => super::token_crypto::encrypt_for_purpose(plain, secret, purpose),
170 None => {
171 super::token_crypto::encrypt_with_config_for_purpose(plain, &ctx.config, purpose)
172 }
173 }
174 }
175 fn decrypt<S: AuthSchema>(
176 &self,
177 stored: &str,
178 ctx: &AuthContext<S>,
179 purpose: EncryptionPurpose,
180 ) -> AuthResult<String> {
181 match &self.config.secret {
182 Some(secret) => super::token_crypto::decrypt_for_purpose(stored, secret, purpose),
183 None => {
184 super::token_crypto::decrypt_with_config_for_purpose(stored, &ctx.config, purpose)
185 }
186 }
187 }
188 fn current<S: AuthSchema>(&self, req: &AuthRequest, ctx: &AuthContext<S>) -> String {
189 if let Some(value) = self
190 .config
191 .current_url
192 .as_ref()
193 .filter(|value| !value.is_empty())
194 {
195 return value.clone();
196 }
197 if let Some(url) = req.url()
198 && ctx
199 .config
200 .is_redirect_target_trusted(&url.origin().ascii_serialization())
201 {
202 return url.to_string();
203 }
204 if let Some(url) = vendor_base_url().filter(|value| {
205 url::Url::parse(value).is_ok_and(|url| !matches!(url.origin(), url::Origin::Opaque(_)))
206 }) {
207 return url;
208 }
209 ctx.config.base_url.clone()
210 }
211 #[expect(
212 clippy::as_conversions,
213 clippy::cast_precision_loss,
214 reason = "Preserve JavaScript Number rounding at the compatibility boundary"
215 )]
216 #[expect(
217 clippy::too_many_lines,
218 reason = "Keep proxy state verification, persistence, and redirect construction in protocol order"
219 )]
220 async fn forward<S: AuthSchema>(
221 &self,
222 provider_id: &str,
223 req: &AuthRequest,
224 ctx: &AuthContext<S>,
225 ) -> AuthResult<Option<AuthResponse>> {
226 let body = req.body_as_json::<Value>().ok();
229 let mut params = std::collections::HashMap::new();
230 if let Some(object) = body.as_ref().and_then(Value::as_object) {
231 params.extend(object.iter().filter_map(|(key, value)| {
232 value.as_str().map(|value| (key.clone(), value.to_owned()))
233 }));
234 }
235 params.extend(
236 req.query
237 .iter()
238 .map(|(key, value)| (key.clone(), value.clone())),
239 );
240 let Some(encrypted) = params.get("state") else {
241 return Ok(None);
242 };
243 let Ok(plain) = self.decrypt(encrypted, ctx, EncryptionPurpose::ProxyPackage) else {
244 return Ok(None);
245 };
246 let Ok(package) = alibi_core::utils::json::from_slice::<StatePackage>(plain.as_bytes())
247 else {
248 return Ok(None);
249 };
250 if !package.is_oauth_proxy || package.state.is_empty() || package.state_cookie.is_empty() {
251 return Ok(None);
252 }
253 let Ok(plain_2) = self.decrypt(&package.state_cookie, ctx, EncryptionPurpose::ProxyState)
254 else {
255 return Ok(None);
256 };
257 let Ok(state) =
258 alibi_core::utils::json::from_slice::<OAuthStatePayload>(plain_2.as_bytes())
259 else {
260 return Ok(None);
261 };
262 let error_url = state
263 .error_url
264 .clone()
265 .filter(|value| !value.is_empty())
266 .or_else(|| {
267 ctx.config
268 .api_error_url
269 .clone()
270 .filter(|value| !value.is_empty())
271 })
272 .unwrap_or_else(|| format!("{}/error", auth_base(ctx)));
273 if state
274 .additional_data
275 .get("oauthState")
276 .is_some_and(|nonce| nonce.as_str() != Some(package.state.as_str()))
277 {
278 return Ok(Some(error_redirect(&error_url, "state_mismatch", None)?));
279 }
280 if let Some(error) = params.get("error").filter(|value| !value.is_empty()) {
281 return Ok(Some(error_redirect(&error_url, error, None)?));
282 }
283 let Some(code) = params.get("code").filter(|value| !value.is_empty()) else {
284 return Ok(Some(error_redirect(&error_url, "no_code", None)?));
285 };
286 let oauth = ctx
287 .extensions
288 .get::<OAuthConfig>()
289 .ok_or_else(|| AuthError::config("OAuth proxy requires OAuthPlugin"))?;
290 let Some(provider) = oauth.providers.get(provider_id) else {
291 return Ok(Some(error_redirect(
292 &error_url,
293 "oauth_provider_not_found",
294 None,
295 )?));
296 };
297 let Ok(tokens) = validate_authorization_code_via_provider(
298 provider,
299 code,
300 &format!(
301 "{}{}",
302 auth_base(ctx),
303 oauth_callback_path(provider_id, provider)
304 ),
305 provider
306 .authorization
307 .as_ref()
308 .is_none_or(|policy| policy.pkce)
309 .then_some(state.code_verifier.as_str()),
310 params.get("device_id").map(String::as_str),
311 )
312 .await
313 else {
314 return Ok(Some(error_redirect(&error_url, "invalid_code", None)?));
315 };
316 let mut info = fetch_user_info_from_provider(
319 provider,
320 OAuthUserInfoRequest {
321 token_type: tokens.token_type.clone(),
322 access_token: tokens.access_token.clone(),
323 refresh_token: tokens.refresh_token.clone(),
324 access_token_expires_at: tokens.access_token_expires_at,
325 refresh_token_expires_at: tokens.refresh_token_expires_at,
326 scopes: tokens.scopes.clone(),
327 id_token: tokens.id_token.clone(),
328 raw: tokens.raw.clone(),
329 user: parse_callback_user_payload(params.get("user").map(String::as_str)),
330 },
331 )
332 .await?;
333 if info.user.email.is_empty() {
334 return Ok(Some(error_redirect(&error_url, "email_not_found", None)?));
335 }
336 if resolve_oauth_account_key(provider, &tokens, &mut info)
337 .await
338 .is_err()
339 {
340 return Ok(Some(error_redirect(
341 &error_url,
342 "unable_to_get_user_info",
343 None,
344 )?));
345 }
346 let mut callback = url::Url::parse(&state.callback_url)
347 .map_err(|_error| AuthError::internal("Invalid proxy callback URL"))?;
348 let callback_url = callback
349 .query_pairs()
350 .find(|(key, _)| key == "callbackURL")
351 .map_or_else(
352 || state.callback_url.clone(),
353 |(_, value)| value.into_owned(),
354 );
355 let payload = ProxyPayload {
356 user_info: ProxyUser {
357 id: info.user.id.clone(),
358 email: info.user.email,
359 name: info.user.name.unwrap_or_default(),
360 image: info.user.image,
361 additional_fields: Map::default(),
362 email_verified: info.user.email_verified,
363 },
364 profile: Some(info.data),
365 scopes: Some(tokens.scopes.clone()),
366 account: ProxyAccount {
367 provider_id: provider_id.to_owned(),
368 account_id: info.user.id,
369 access_token: tokens.access_token,
370 refresh_token: tokens.refresh_token,
371 id_token: tokens.id_token,
372 access_token_expires_at: tokens.access_token_expires_at,
373 refresh_token_expires_at: tokens.refresh_token_expires_at,
374 scope: Some(tokens.scopes.join(",")),
375 },
376 state: package.state,
377 callback_url,
378 new_user_url: state.new_user_url,
379 error_url: state.error_url,
380 disable_sign_up: if provider.disable_implicit_sign_up
381 && !state.request_sign_up.unwrap_or(false)
382 {
383 Some(true)
384 } else {
385 oauth_disable_sign_up_option(provider)
386 },
387 timestamp: Utc::now().timestamp_millis() as f64,
388 };
389 _ = callback.query_pairs_mut().append_pair(
390 "profile",
391 &self.encrypt(
392 &alibi_core::utils::json::to_string(&payload)?,
393 ctx,
394 EncryptionPurpose::ProxyProfile,
395 )?,
396 );
397 Ok(Some(redirect(callback.as_str())))
398 }
399 #[expect(
400 clippy::as_conversions,
401 clippy::cast_precision_loss,
402 reason = "Preserve JavaScript Number rounding at the compatibility boundary"
403 )]
404 #[expect(
405 clippy::too_many_lines,
406 reason = "Keep proxy state consumption, identity linking, and session issuance in protocol order"
407 )]
408 async fn complete<S: AuthSchema>(
409 &self,
410 provider_id: Option<&str>,
411 req: &AuthRequest,
412 ctx: &AuthContext<S>,
413 ) -> AuthResult<AuthResponse> {
414 let callback = req
415 .query
416 .get("callbackURL")
417 .ok_or_else(|| AuthError::bad_request("Missing callbackURL"))?;
418 if !ctx.config.current_origin_check_disabled()
419 && !ctx.config.is_redirect_target_trusted(callback)
420 {
421 return Err(AuthError::forbidden("Invalid callbackURL"));
422 }
423 let default_error = ctx
424 .config
425 .api_error_url
426 .clone()
427 .filter(|value| !value.is_empty())
428 .unwrap_or_else(|| {
429 format!(
430 "{}/api/auth/error",
431 ctx.config.base_url.trim_end_matches('/')
432 )
433 });
434 let Some(profile) = req
435 .query
436 .get("profile")
437 .filter(|profile| !profile.is_empty())
438 else {
439 return error_redirect(&default_error, "missing_profile", None);
440 };
441 let Ok(plain) = self.decrypt(profile, ctx, EncryptionPurpose::ProxyProfile) else {
442 return error_redirect(&default_error, "invalid_profile", None);
443 };
444 let Ok(raw) = alibi_core::utils::json::from_slice::<Value>(plain.as_bytes()) else {
445 return error_redirect(&default_error, "invalid_payload", None);
446 };
447 if raw.get("profile").is_some_and(|value| !value.is_object())
448 || raw.get("scopes").is_some_and(|value| {
449 !value
450 .as_array()
451 .is_some_and(|items| items.iter().all(Value::is_string))
452 })
453 || ["newUserURL", "errorURL"]
454 .iter()
455 .any(|field| raw.get(*field).is_some_and(|value| !value.is_string()))
456 || raw
457 .get("disableSignUp")
458 .is_some_and(|value| !value.is_boolean())
459 {
460 return error_redirect(&default_error, "invalid_payload", None);
461 }
462 let Ok(payload) = alibi_core::utils::json::from_slice::<ProxyPayload>(plain.as_bytes())
463 else {
464 return error_redirect(&default_error, "invalid_payload", None);
465 };
466 if payload
467 .profile
468 .as_ref()
469 .is_some_and(|profile_2| !profile_2.is_object())
470 || !payload.timestamp.is_finite()
471 || payload.state.is_empty()
472 || payload.callback_url.is_empty()
473 {
474 return error_redirect(&default_error, "invalid_payload", None);
475 }
476 let error_url = payload
477 .error_url
478 .as_deref()
479 .filter(|value| !value.is_empty())
480 .unwrap_or(&default_error);
481 if provider_id.is_some_and(|id| id != payload.account.provider_id) {
482 return error_redirect(error_url, "provider_mismatch", None);
483 }
484 let age = (Utc::now().timestamp_millis() as f64 - payload.timestamp) / 1000.0;
485 if age > self.config.max_age_seconds || age < -10.0 {
486 return error_redirect(error_url, "payload_expired", None);
487 }
488 let cookie_name = state_cookie_name(&ctx.config);
489 let authenticated_cookie = get_cookie(req, &cookie_name);
490 let state: OAuthStatePayload = match ctx.config.account.store_state_strategy {
491 OAuthStateStrategy::Automatic | OAuthStateStrategy::Database => {
492 let Ok(Some(row)) = ctx
493 .verifications()
494 .find(&state_verification_identifier(&payload.state))
495 .await
496 else {
497 return error_redirect(error_url, "state_mismatch", None);
498 };
499 let Ok(state) = alibi_core::utils::json::from_slice(row.value()?.as_bytes()) else {
500 return error_redirect(error_url, "state_mismatch", None);
501 };
502 state
503 }
504 OAuthStateStrategy::Cookie => {
505 let Some(cookie) = authenticated_cookie.as_deref() else {
509 return error_redirect(error_url, "state_mismatch", None);
510 };
511 let Ok(state) = decode_cookie_state_value(&ctx.config, cookie) else {
512 return error_redirect(error_url, "state_mismatch", None);
513 };
514 if state
515 .additional_data
516 .get("oauthState")
517 .and_then(Value::as_str)
518 != Some(payload.state.as_str())
519 {
520 return error_redirect(error_url, "state_mismatch", None);
521 }
522 state
523 }
524 };
525 if state
526 .additional_data
527 .get("oauthState")
528 .is_some_and(|nonce| nonce.as_str() != Some(payload.state.as_str()))
529 {
530 return error_redirect(error_url, "state_mismatch", None);
531 }
532 let clear = alibi_core::utils::cookie_utils::create_clear_cookie(
533 &state_cookie_name(&ctx.config),
534 &ctx.config,
535 )?;
536 req.queue_response_header("Set-Cookie", clear);
537 if matches!(
538 ctx.config.account.store_state_strategy,
539 OAuthStateStrategy::Automatic | OAuthStateStrategy::Database
540 ) && ctx
541 .verifications()
542 .delete(&state_verification_identifier(&payload.state))
543 .await
544 .is_err()
545 {
546 return error_redirect(error_url, "state_mismatch", None);
547 }
548 if state.is_expired() {
549 return error_redirect(error_url, "state_mismatch", None);
550 }
551 let context = match ctx.config.account.store_state_strategy {
555 OAuthStateStrategy::Automatic | OAuthStateStrategy::Database => ctx
556 .config
557 .verification_secrets()
558 .find_map(|secret| verified_server_context(&state, &payload.state, secret)),
559 OAuthStateStrategy::Cookie => authenticated_cookie
560 .as_deref()
561 .and_then(|cookie| super::token_crypto::decryption_key(cookie, &ctx.config).ok())
562 .and_then(|secret| verified_server_context(&state, &payload.state, secret)),
563 };
564 if let Some(context) = context {
565 req.extensions()
566 .insert(RecoveredOAuthServerContext(context));
567 }
568 let user = OAuthUserInfo {
569 additional_fields: payload.user_info.additional_fields,
570 id: payload.account.account_id,
573 email: payload.user_info.email.to_lowercase(),
574 name: Some(payload.user_info.name),
575 image: payload.user_info.image,
576 email_verified: payload.user_info.email_verified,
577 };
578 let tokens = OAuthTokenSet {
579 access_token: payload.account.access_token,
580 refresh_token: payload.account.refresh_token,
581 id_token: payload.account.id_token,
582 access_token_expires_at: payload.account.access_token_expires_at,
583 refresh_token_expires_at: payload.account.refresh_token_expires_at,
584 scopes: payload.scopes.unwrap_or_else(|| {
585 payload
586 .account
587 .scope
588 .map(|scope| scope.split(',').map(String::from).collect())
589 .unwrap_or_default()
590 }),
591 ..Default::default()
592 };
593 let profile = payload.profile.unwrap_or(Value::Null);
594 if let Some(link) = state.link.as_ref() {
595 return match complete_link_social(
596 &payload.account.provider_id,
597 &user,
598 &profile,
599 &tokens,
600 link,
601 ctx,
602 )
603 .await
604 {
605 Ok(()) => Ok(redirect(&payload.callback_url)),
606 Err(error) => {
607 if error.is_ambiguous_account() {
608 return Ok(AuthResponse::new(500));
609 }
610 let (code, description) = match &error {
611 OAuthSignInError::Generic(message) => (message.clone(), None),
612 _ => error.redirect_parts(),
613 };
614 error_redirect(error_url, &code, description)
615 }
616 };
617 }
618 let user_output = raw
619 .get("userInfo")
620 .and_then(Value::as_object)
621 .map(|values| {
622 values
623 .iter()
624 .map(|(name, value)| (name.clone(), value.clone()))
625 .collect::<alibi_core::field_policy::FieldOutput>()
626 });
627 let outcome = match super::oauth::handlers::process_oauth_sign_in_with_output(
628 super::oauth::handlers::OAuthIdentity {
629 provider_name: &payload.account.provider_id,
630 user: &user,
631 profile: &profile,
632 },
633 &OAuthProcessPolicy {
634 callback_url: Some(payload.callback_url.clone()),
635 ..Default::default()
636 },
637 &tokens,
638 payload.disable_sign_up.unwrap_or(false),
639 &alibi_core::RequestMeta::from_request(req),
640 ctx,
641 (user_output.as_ref(), None),
642 )
643 .await
644 {
645 Ok(outcome) => outcome,
646 Err(error) => {
647 if error.is_ambiguous_account() {
648 return Ok(crate::oauth::handlers::ambiguous_account_sign_in_response(
649 ctx,
650 ));
651 }
652 if let OAuthSignInError::SessionAuth(error) = error {
653 return match error {
654 AuthError::SessionCreationCancelled => {
655 error_redirect(error_url, "unable_to_create_session", None)
656 }
657 AuthError::Api {
658 code: Some(code),
659 message,
660 ..
661 } => error_redirect(error_url, &code, Some(&message)),
662 AuthError::Upstream { code, message, .. } => {
663 error_redirect(error_url, code, Some(message))
664 }
665 error
666 if error.status_code() < 500
667 || matches!(error, AuthError::Api { .. }) =>
668 {
669 Err(error)
670 }
671 _ => {
672 _ = req.take_response_headers();
676 req.extensions()
677 .insert(OAuthProxyUnhandledError(AtomicBool::new(true)));
678 Ok(AuthResponse::new(500))
679 }
680 };
681 }
682 let (code, description) = error.redirect_parts();
683 return error_redirect(error_url, &code, description);
684 }
685 };
686 let target = if outcome.is_register {
687 payload
688 .new_user_url
689 .as_deref()
690 .filter(|value| !value.is_empty())
691 .unwrap_or(&payload.callback_url)
692 } else {
693 &payload.callback_url
694 };
695 let dont_remember = ctx.session_manager().has_dont_remember_cookie(req);
696 let mut response = redirect(target).with_appended_header(
697 "Set-Cookie",
698 alibi_core::utils::cookie_utils::create_session_cookie_with_max_age(
699 Some(outcome.session.token()),
700 (!dont_remember).then(|| ctx.config.session.expires_in.num_seconds()),
701 &ctx.config,
702 )?,
703 );
704 if dont_remember {
705 response.headers.append(
706 "Set-Cookie",
707 alibi_core::utils::cookie_utils::create_session_like_cookie(
708 &alibi_core::utils::cookie_utils::related_cookie_name(
709 &ctx.config,
710 "dont_remember",
711 ),
712 &alibi_core::utils::cookie_utils::sign_cookie_value(
713 "true",
714 ctx.config.current_secret(),
715 ),
716 None,
717 &ctx.config,
718 )?,
719 );
720 }
721 if let Some(account) = outcome.account_cookie.as_ref() {
722 for header in create_account_cookie_headers(&ctx.config, account, req)? {
723 response.headers.append("Set-Cookie", header);
724 }
725 }
726 Ok(response)
727 }
728}
729
730#[async_trait]
731impl<S: AuthSchema> AuthPlugin<S> for OAuthProxyPlugin {
732 route_openapi_metadata!(S);
733
734 fn name(&self) -> &'static str {
735 "oauth-proxy"
736 }
737 fn routes(&self) -> Vec<AuthRoute> {
738 vec![
739 AuthRoute::get("/callback/{provider}/oauth-proxy", "oauth_proxy"),
740 AuthRoute::get("/oauth-proxy-callback", "oauth_proxy_legacy"),
741 ]
742 }
743 async fn before_request(
744 &self,
745 req: &AuthRequest,
746 ctx: &AuthContext<S>,
747 ) -> AuthResult<Option<BeforeRequestAction>> {
748 if let Some(provider) = regular_provider(req.path()) {
749 return Ok(self
750 .forward(provider, req, ctx)
751 .await?
752 .map(BeforeRequestAction::Respond));
753 }
754 if *req.method() != HttpMethod::Post
755 || !(req.path().starts_with("/sign-in/social") || req.path() == "/link-social")
756 {
757 return Ok(None);
758 }
759 if req
760 .headers
761 .get("x-skip-oauth-proxy")
762 .is_some_and(|value| !value.is_empty())
763 {
764 return Ok(None);
765 }
766 let environment_production = std::env::var("BETTER_AUTH_URL")
767 .ok()
768 .filter(|value| !value.is_empty());
769 let production = self
770 .config
771 .production_url
772 .as_deref()
773 .filter(|value| !value.is_empty())
774 .or(environment_production.as_deref())
775 .unwrap_or(&ctx.config.base_url);
776 let transport = req.url().map(url::Url::as_str);
779 let vendor = vendor_base_url();
780 let skip_current = self
781 .config
782 .current_url
783 .as_deref()
784 .filter(|value| !value.is_empty())
785 .or(transport)
786 .or(vendor.as_deref());
787 if skip_current
788 .and_then(|value| url::Url::parse(value).ok())
789 .zip(url::Url::parse(production).ok())
790 .is_some_and(|(current, production)| current.origin() == production.origin())
791 {
792 return Ok(None);
793 }
794 let current = url::Url::parse(&self.current(req, ctx))
795 .map_err(|_error| AuthError::config("Invalid OAuth proxy current URL"))?;
796 let Ok(body) = req.body_as_json::<Value>() else {
797 return Ok(None);
798 };
799 let Some(provider) = body
800 .get("provider")
801 .and_then(Value::as_str)
802 .filter(|value| !value.is_empty())
803 else {
804 return Ok(None);
805 };
806 let original = body
807 .get("callbackURL")
808 .and_then(Value::as_str)
809 .filter(|value| !value.is_empty())
810 .map_or_else(|| auth_base(ctx), String::from);
811 let mut callback = url::Url::parse(&format!(
812 "{}{}/callback/{provider}/oauth-proxy",
813 current.origin().ascii_serialization(),
814 ctx.config.base_path
815 ))
816 .map_err(|_error| AuthError::config("Invalid OAuth proxy callback URL"))?;
817 _ = callback
818 .query_pairs_mut()
819 .append_pair("callbackURL", &original);
820 req.extensions().insert(OAuthProxyFlow {
821 effective_auth_base_url: format!(
822 "{}{}",
823 self.config
824 .production_url
825 .as_deref()
826 .filter(|value| !value.is_empty())
827 .unwrap_or(&ctx.config.base_url)
828 .trim_end_matches('/'),
829 ctx.config.base_path
830 ),
831 callback_url: callback.to_string(),
832 });
833 Ok(None)
834 }
835 async fn on_request(
836 &self,
837 req: &AuthRequest,
838 ctx: &AuthContext<S>,
839 ) -> AuthResult<Option<AuthResponse>> {
840 if *req.method() != HttpMethod::Get {
841 return Ok(None);
842 }
843 if let Some(provider) = completion_provider(req.path()) {
844 return Ok(Some(self.complete(Some(provider), req, ctx).await?));
845 }
846 if req.path() == "/oauth-proxy-callback" {
847 return Ok(Some(self.complete(None, req, ctx).await?));
848 }
849 Ok(None)
850 }
851 async fn after_request(
852 &self,
853 req: &AuthRequest,
854 ctx: &AuthContext<S>,
855 mut response: AuthResponse,
856 ) -> AuthResult<AuthResponse> {
857 let Some(issued) = req.extensions().get::<IssuedProxyState>() else {
858 return Ok(response);
859 };
860 let Ok(mut body) = alibi_core::utils::json::from_slice::<Value>(&response.body) else {
861 return Ok(response);
862 };
863 let Some(original_url) = body.get("url").and_then(Value::as_str) else {
864 return Ok(response);
865 };
866 let Ok(mut url) = url::Url::parse(original_url) else {
867 return Ok(response);
868 };
869 if !url
870 .query_pairs()
871 .any(|(key, value)| key == "state" && value == issued.state)
872 {
873 return Ok(response);
874 }
875 let package = StatePackage {
876 state: issued.state.clone(),
877 state_cookie: self.encrypt(
878 &alibi_core::utils::json::to_string(&issued.payload)?,
879 ctx,
880 EncryptionPurpose::ProxyState,
881 )?,
882 is_oauth_proxy: true,
883 };
884 let encrypted = self.encrypt(
885 &alibi_core::utils::json::to_string(&package)?,
886 ctx,
887 EncryptionPurpose::ProxyPackage,
888 )?;
889 let pairs: Vec<_> = url
890 .query_pairs()
891 .filter(|(key, _)| key != "state")
892 .map(|(key, value)| (key.into_owned(), value.into_owned()))
893 .collect();
894 _ = url
895 .query_pairs_mut()
896 .clear()
897 .extend_pairs(pairs)
898 .append_pair("state", &encrypted);
899 if let Some(value) = body.get_mut("url") {
900 *value = json!(url.as_str());
901 }
902 response.body = alibi_core::utils::json::to_vec(&body)?;
903 if response.headers.get("location").is_some() {
904 _ = response.headers.insert("Location", url.as_str());
905 }
906 Ok(response)
907 }
908}
909
910impl std::fmt::Debug for OAuthProxyPlugin {
911 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
912 f.debug_struct("OAuthProxyPlugin").finish_non_exhaustive()
913 }
914}
915
916#[doc(hidden)]
919#[must_use]
920pub fn take_unhandled_error(req: &AuthRequest) -> bool {
921 req.extensions()
922 .get::<OAuthProxyUnhandledError>()
923 .is_some_and(|marker| marker.0.swap(false, Ordering::Relaxed))
924}
925
926fn auth_base<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
927 format!(
928 "{}{}",
929 ctx.config.base_url.trim_end_matches('/'),
930 ctx.config.base_path
931 )
932}
933
934fn redirect(target: &str) -> AuthResponse {
935 AuthResponse::new(302)
936 .with_header("content-type", "application/json")
937 .with_header("Location", target)
938}
939
940fn error_redirect(base: &str, code: &str, description: Option<&str>) -> AuthResult<AuthResponse> {
941 let mut url =
942 url::Url::parse(base).map_err(|_error| AuthError::internal("Invalid OAuth error URL"))?;
943 _ = url.query_pairs_mut().append_pair("error", code);
944 if let Some(message) = description {
945 _ = url
946 .query_pairs_mut()
947 .append_pair("error_description", message);
948 }
949 Ok(redirect(url.as_str()))
950}
951
952fn regular_provider(path: &str) -> Option<&str> {
953 path.strip_prefix("/callback/")
954 .filter(|id| !id.is_empty() && !id.contains('/'))
955}
956
957fn completion_provider(path: &str) -> Option<&str> {
958 path.strip_prefix("/callback/")?
959 .strip_suffix("/oauth-proxy")
960 .filter(|id| !id.is_empty() && !id.contains('/'))
961}
962
963fn vendor_base_url() -> Option<String> {
964 if let Ok(value) = std::env::var("VERCEL_URL")
965 && !value.is_empty()
966 {
967 return Some(format!("https://{value}"));
968 }
969 [
970 "NETLIFY_URL",
971 "RENDER_URL",
972 "AWS_LAMBDA_FUNCTION_NAME",
973 "GOOGLE_CLOUD_FUNCTION_NAME",
974 "AZURE_FUNCTION_NAME",
975 ]
976 .into_iter()
977 .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
978}