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