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