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